feat: update workspace pages and global inbox
This commit is contained in:
@@ -3,11 +3,11 @@ import { existsSync, readFileSync } from 'node:fs'
|
||||
const requiredFiles = [
|
||||
'src/app/App.tsx',
|
||||
'src/pages/login.tsx',
|
||||
'src/pages/workspace.tsx',
|
||||
'src/pages/project.tsx',
|
||||
'src/pages/workspace-body.tsx',
|
||||
'src/pages/workspace-home.tsx',
|
||||
'src/pages/workspace-inbox.tsx',
|
||||
'src/pages/projects/project-overview.tsx',
|
||||
'src/pages/projects/project-channel-page.tsx',
|
||||
'src/pages/projects/project-inbox.tsx',
|
||||
'src/pages/projects/project-tasks.tsx',
|
||||
'src/pages/projects/project-ai.tsx',
|
||||
'src/pages/projects/project-notes.tsx',
|
||||
@@ -28,7 +28,7 @@ if (existsSync('src/App.tsx')) {
|
||||
failures.push('legacy src/App.tsx should be removed after page split')
|
||||
}
|
||||
|
||||
for (const removed of ['src/pages/projects/project-data.tsx', 'src/pages/projects/project-inspector.tsx']) {
|
||||
for (const removed of ['src/pages/projects/project-data.tsx', 'src/pages/projects/project-inspector.tsx', 'src/pages/projects/project-inbox.tsx']) {
|
||||
if (existsSync(removed)) {
|
||||
failures.push(`${removed} should be removed; frontend data must come from backend APIs`)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ const collectMetrics = async () => page.evaluate(() => {
|
||||
const workspaceActions = [...document.querySelectorAll('.workspace-page .overview-head .arco-btn')]
|
||||
.map((button) => button.textContent?.trim())
|
||||
const dashboard = document.querySelector('.dashboard-button')
|
||||
const workspaceInbox = document.querySelector('.dashboard-button[title="Inbox"]')
|
||||
const project = document.querySelector('.project-button')
|
||||
const create = document.querySelector('.create-project')
|
||||
const firstBadge = document.querySelector('.project-badge .arco-badge-number')
|
||||
@@ -83,6 +84,8 @@ const collectMetrics = async () => page.evaluate(() => {
|
||||
workspaceActions,
|
||||
dashboardButton: rect(dashboard),
|
||||
dashboardButtonActive: dashboard?.classList.contains('active') ?? false,
|
||||
workspaceInboxButton: rect(workspaceInbox),
|
||||
workspaceInboxButtonActive: workspaceInbox?.classList.contains('active') ?? false,
|
||||
projectButton: rect(project),
|
||||
projectButtonActive: project?.classList.contains('active') ?? false,
|
||||
createProjectButton: rect(create),
|
||||
@@ -108,6 +111,11 @@ await page.waitForTimeout(700)
|
||||
await page.screenshot({ path: 'test-results/workbench-react-acro-light.png', fullPage: true })
|
||||
const workspaceMetrics = await collectMetrics()
|
||||
|
||||
await page.locator('.dashboard-button[title="Inbox"]').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.screenshot({ path: 'test-results/workspace-inbox-react-acro-light.png', fullPage: true })
|
||||
const workspaceInboxMetrics = await collectMetrics()
|
||||
|
||||
await page.locator('.project-button').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.screenshot({ path: 'test-results/project-react-acro-light.png', fullPage: true })
|
||||
@@ -122,7 +130,6 @@ const channelListHoverMetrics = await collectMetrics()
|
||||
|
||||
const channelPageChecks = []
|
||||
for (const channel of [
|
||||
{ label: 'Inbox 消息流', pageClass: 'project-inbox-page', expectedHeading: 'Inbox 消息流' },
|
||||
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
|
||||
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: 'AI 会话' },
|
||||
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
|
||||
@@ -151,6 +158,7 @@ await server.close()
|
||||
|
||||
console.log(JSON.stringify({
|
||||
workspaceMetrics,
|
||||
workspaceInboxMetrics,
|
||||
projectMetrics,
|
||||
channelSidebarHoverMetrics,
|
||||
stageHoverMetrics,
|
||||
@@ -172,6 +180,8 @@ if (metrics.overflowX) failures.push('expected no horizontal overflow')
|
||||
if (!metrics.workbenchMain) failures.push('missing workbench main')
|
||||
if (!metrics.hasWorkspacePage) failures.push('expected login to land on workspace page')
|
||||
if (!metrics.dashboardButtonActive) failures.push('expected dashboard button to be active after login')
|
||||
if (!metrics.workspaceInboxButton) failures.push('expected fixed workspace inbox button to render in project rail')
|
||||
if (metrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button not to be active after login')
|
||||
if (metrics.projectButtonActive) failures.push('expected first project button not to be active on workspace landing page')
|
||||
if (metrics.channelSidebar) failures.push(`expected workspace to hide channel sidebar, got ${JSON.stringify(metrics.channelSidebar)}`)
|
||||
if (metrics.inspector) failures.push(`expected workspace to hide inspector, got ${JSON.stringify(metrics.inspector)}`)
|
||||
@@ -220,6 +230,13 @@ if (
|
||||
) {
|
||||
failures.push(`dashboard button size ${JSON.stringify(metrics.dashboardButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
|
||||
}
|
||||
if (
|
||||
!metrics.workspaceInboxButton ||
|
||||
metrics.workspaceInboxButton.width !== metrics.projectButton?.width ||
|
||||
metrics.workspaceInboxButton.height !== metrics.projectButton?.height
|
||||
) {
|
||||
failures.push(`workspace inbox button size ${JSON.stringify(metrics.workspaceInboxButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
|
||||
}
|
||||
if (
|
||||
!metrics.createProjectButton ||
|
||||
metrics.createProjectButton.width !== metrics.projectButton?.width ||
|
||||
@@ -228,6 +245,16 @@ if (
|
||||
failures.push(`create project button size ${JSON.stringify(metrics.createProjectButton)} does not match project button ${JSON.stringify(metrics.projectButton)}`)
|
||||
}
|
||||
|
||||
if (!workspaceInboxMetrics.workspaceInboxButtonActive) failures.push('expected fixed workspace inbox button to become active when selected')
|
||||
if (workspaceInboxMetrics.dashboardButtonActive) failures.push('expected dashboard button not to be active on workspace inbox page')
|
||||
if (workspaceInboxMetrics.channelSidebar) failures.push(`expected workspace inbox to hide channel sidebar, got ${JSON.stringify(workspaceInboxMetrics.channelSidebar)}`)
|
||||
if (workspaceInboxMetrics.inspector) failures.push(`expected workspace inbox to hide inspector, got ${JSON.stringify(workspaceInboxMetrics.inspector)}`)
|
||||
if (!workspaceInboxMetrics.stage || !workspaceInboxMetrics.projectRail) {
|
||||
failures.push(`missing workspace inbox layout regions: rail=${JSON.stringify(workspaceInboxMetrics.projectRail)}, stage=${JSON.stringify(workspaceInboxMetrics.stage)}`)
|
||||
} else if (Math.abs(workspaceInboxMetrics.stage.left - workspaceInboxMetrics.projectRail.right) > 1) {
|
||||
failures.push(`expected workspace inbox stage to start after project rail, got stage.left=${workspaceInboxMetrics.stage.left}, rail.right=${workspaceInboxMetrics.projectRail.right}`)
|
||||
}
|
||||
|
||||
if (!projectMetrics.hasProjectOverview) failures.push('expected first project click to show project overview page')
|
||||
if (projectMetrics.hasWorkspacePage) failures.push('expected project overview mode to leave workspace page')
|
||||
if (!projectMetrics.projectButtonActive) failures.push('expected first project button to be active in project mode')
|
||||
|
||||
@@ -271,6 +271,35 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dock-icon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 14px;
|
||||
border: 1.5px solid currentcolor;
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dock-icon span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 5px;
|
||||
background: currentcolor;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.dock-icon-left span {
|
||||
left: 0;
|
||||
border-right: 1.5px solid currentcolor;
|
||||
}
|
||||
|
||||
.dock-icon-right span {
|
||||
right: 0;
|
||||
border-left: 1.5px solid currentcolor;
|
||||
}
|
||||
|
||||
.workbench-main {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
@@ -550,6 +579,65 @@
|
||||
grid-template-columns: minmax(260px, 1fr) 82px 100px 100px 130px;
|
||||
}
|
||||
|
||||
.workspace-task-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.workspace-task-card {
|
||||
display: flex;
|
||||
min-height: 148px;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--senlin-border);
|
||||
border-radius: 6px;
|
||||
background: var(--senlin-surface);
|
||||
color: var(--senlin-text);
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
.workspace-task-card:hover {
|
||||
border-color: color-mix(in srgb, var(--senlin-primary) 42%, var(--senlin-border));
|
||||
box-shadow: 0 8px 22px color-mix(in srgb, var(--senlin-primary) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.workspace-task-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workspace-task-title svg {
|
||||
color: var(--senlin-primary);
|
||||
}
|
||||
|
||||
.workspace-task-main .arco-typography {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.workspace-task-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
color: var(--senlin-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.workspace-task-meta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ai-row {
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(260px, 1.2fr) 80px 90px;
|
||||
}
|
||||
@@ -631,6 +719,27 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-detail-card .arco-card-body {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.task-detail-progress {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-detail-summary {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--senlin-border);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.task-detail-summary h6 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mail-detail .arco-typography,
|
||||
.mail-item .arco-typography,
|
||||
.ai-session .arco-typography {
|
||||
|
||||
@@ -24,7 +24,7 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
|
||||
|
||||
return {
|
||||
project,
|
||||
channels: payload.channels.map(mapChannel),
|
||||
channels: payload.channels.filter((channel) => channel.type !== 'inbox').map(mapChannel),
|
||||
tags: payload.tags.map((tag) => (tag === 'all' ? '全部' : tag)),
|
||||
recentSessions: payload.recentSessions.map(mapAISession),
|
||||
inbox: payload.inbox.map(mapInbox),
|
||||
@@ -124,12 +124,44 @@ function mapTask(task: BackendTask): TaskItem {
|
||||
owner: task.owner,
|
||||
progress: task.completed ? '100%' : '60%',
|
||||
due: task.due || '未设置',
|
||||
createdAt: formatCreatedAt(task.createdAt),
|
||||
tag: task.tag || (task.completed ? '已完成' : '进行中'),
|
||||
summary: task.summary,
|
||||
completed: task.completed,
|
||||
}
|
||||
}
|
||||
|
||||
function formatCreatedAt(value: string) {
|
||||
const date = parseBackendDate(value)
|
||||
if (!date) return '未知时间'
|
||||
|
||||
const now = new Date()
|
||||
const today = startOfDay(now)
|
||||
const yesterday = new Date(today)
|
||||
yesterday.setDate(today.getDate() - 1)
|
||||
const dateDay = startOfDay(date)
|
||||
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
|
||||
if (dateDay.getTime() === today.getTime()) return `今天 ${time}`
|
||||
if (dateDay.getTime() === yesterday.getTime()) return `昨天 ${time}`
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${time}`
|
||||
}
|
||||
|
||||
function parseBackendDate(value: string) {
|
||||
if (!value) return null
|
||||
const normalized = value.includes('T') ? value : `${value.replace(' ', 'T')}Z`
|
||||
const date = new Date(normalized)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function startOfDay(value: Date) {
|
||||
return new Date(value.getFullYear(), value.getMonth(), value.getDate())
|
||||
}
|
||||
|
||||
function pad(value: number) {
|
||||
return String(value).padStart(2, '0')
|
||||
}
|
||||
|
||||
function mapAISession(session: BackendAISession): AISession {
|
||||
return {
|
||||
id: session.id,
|
||||
|
||||
@@ -45,6 +45,7 @@ export type BackendTask = {
|
||||
completed: boolean
|
||||
owner: string
|
||||
due: string
|
||||
createdAt: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import { login, setApiBaseUrl, type ApiSession } from '../api/client'
|
||||
import { mapWorkspace } from '../api/mappers'
|
||||
import { fetchProjectWorkspace, fetchProjects } from '../api/projects'
|
||||
import { LoginPage } from '../pages/login'
|
||||
import { ProjectPage } from '../pages/project'
|
||||
import type { ChannelKey, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
||||
import { ProjectPage } from '../pages/workspace-home'
|
||||
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
||||
|
||||
function App() {
|
||||
const [screen, setScreen] = useState<Screen>('login')
|
||||
@@ -17,6 +17,7 @@ function App() {
|
||||
const [workspaces, setWorkspaces] = useState<ProjectWorkspace[]>([])
|
||||
const [activeProjectID, setActiveProjectID] = useState<string>('')
|
||||
const [activeChannel, setActiveChannel] = useState<ChannelKey>('overview')
|
||||
const [activeTaskID, setActiveTaskID] = useState<string | null>(null)
|
||||
const [, setSelectedItem] = useState('Inbox 待处理(36)')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -45,6 +46,13 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
function openTask(project: Project, taskID: string) {
|
||||
setActiveProjectID(project.id)
|
||||
setActiveView('project')
|
||||
setActiveChannel('tasks')
|
||||
setActiveTaskID(taskID)
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<main className={dark ? 'app theme-dark' : 'app'}>
|
||||
@@ -58,15 +66,23 @@ function App() {
|
||||
activeWorkspace={activeWorkspace}
|
||||
workspaces={workspaces}
|
||||
activeChannel={activeChannel}
|
||||
activeTaskID={activeTaskID}
|
||||
theme={theme}
|
||||
onSelectWorkspace={() => setActiveView('workspace')}
|
||||
onSelectWorkspaceInbox={() => setActiveView('workspace-inbox')}
|
||||
onSelectProject={(project) => {
|
||||
setActiveProjectID(project.id)
|
||||
setActiveView('project')
|
||||
setActiveChannel('overview')
|
||||
setActiveTaskID(null)
|
||||
}}
|
||||
onSelectChannel={(channel) => {
|
||||
setActiveChannel(channel)
|
||||
setActiveTaskID(null)
|
||||
}}
|
||||
onSelectChannel={setActiveChannel}
|
||||
onSelectItem={setSelectedItem}
|
||||
onOpenTask={openTask}
|
||||
onCloseTask={() => setActiveTaskID(null)}
|
||||
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Form, Input, Space, Typography } from '@arco-design/web-react'
|
||||
import { IconBook, IconCheckCircleFill, IconLaunch, IconSafe, IconUserGroup } from '@arco-design/web-react/icon'
|
||||
import {
|
||||
IconBook,
|
||||
IconCheckCircleFill,
|
||||
IconCloseCircleFill,
|
||||
IconLaunch,
|
||||
IconLoading,
|
||||
IconSafe,
|
||||
IconUserGroup,
|
||||
} from '@arco-design/web-react/icon'
|
||||
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
type ConnectionStatus = 'checking' | 'online' | 'offline'
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) {
|
||||
const [server, setServer] = useState('http://127.0.0.1:8080')
|
||||
const [server, setServer] = useState('http://localhost:9150')
|
||||
const [email, setEmail] = useState('demo@senlin.ai')
|
||||
const [password, setPassword] = useState('password123')
|
||||
const [status, setStatus] = useState<ConnectionStatus>('checking')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const timer = window.setTimeout(() => {
|
||||
void checkServerStatus(server, controller.signal, setStatus)
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [server])
|
||||
|
||||
return (
|
||||
<section className="login-screen">
|
||||
@@ -16,15 +39,14 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai
|
||||
<div className="login-brand-panel">
|
||||
<div className="brand-lockup large">
|
||||
<span className="brand-symbol">S</span>
|
||||
<Title heading={2}>SenlinAI</Title>
|
||||
<Title heading={2}>森林Agent</Title>
|
||||
</div>
|
||||
<Text className="login-slogan">私有部署 · 安全可控的智能协作平台</Text>
|
||||
<Text type="secondary">知识沉淀 · 团队协作 · AI 助理</Text>
|
||||
<Text type="secondary">知识沉淀 · 团队协作 · AI Agent</Text>
|
||||
<Space className="login-footer-links" size={24}>
|
||||
<Text type="secondary">v1.0.0</Text>
|
||||
<Text type="secondary">隐私政策</Text>
|
||||
<Text type="secondary">服务协议</Text>
|
||||
<Text type="secondary">关于 SenlinAI</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +56,9 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai
|
||||
<Form layout="vertical" className="login-form">
|
||||
<Form.Item label="服务器地址(IP 或域名优先)">
|
||||
<Input value={server} onChange={setServer} placeholder="例如:https://10.0.0.15:8080" suffix={<IconLaunch />} />
|
||||
<ConnectionCard status={status} onCheck={() => checkServerStatus(server, undefined, setStatus)} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="账号">
|
||||
<Input value={email} onChange={setEmail} placeholder="请输入用户名或邮箱" />
|
||||
</Form.Item>
|
||||
@@ -51,14 +75,6 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai
|
||||
<Button type="primary" long size="large" onClick={() => onLogin({ server, email, password })}>
|
||||
登录
|
||||
</Button>
|
||||
<div className="connection-card">
|
||||
<IconCheckCircleFill />
|
||||
<div>
|
||||
<Text className="connection-title">连接正常</Text>
|
||||
<Text type="secondary">已连接到 {server}</Text>
|
||||
</div>
|
||||
<Button type="text" size="mini">更换服务器</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -76,6 +92,48 @@ export function LoginPage({ onLogin }: { onLogin: (input: { server: string; emai
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionCard({ status, onCheck }: { status: ConnectionStatus; onCheck: () => void }) {
|
||||
const icon = status === 'checking' ? <IconLoading /> : status === 'online' ? <IconCheckCircleFill /> : <IconCloseCircleFill />
|
||||
const title = status === 'checking' ? '正在检查' : status === 'online' ? '连接正常' : '连接异常'
|
||||
|
||||
return (
|
||||
<div className={`connection-card ${status}`}>
|
||||
{icon}
|
||||
<div>
|
||||
<Text className="connection-title">{title}</Text>
|
||||
</div>
|
||||
<Button type="text" size="mini" loading={status === 'checking'} onClick={onCheck}>
|
||||
重新检查
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function checkServerStatus(
|
||||
server: string,
|
||||
signal: AbortSignal | undefined,
|
||||
setStatus: (status: ConnectionStatus) => void,
|
||||
) {
|
||||
const baseUrl = normalizeBaseUrl(server)
|
||||
setStatus('checking')
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/status`, { signal })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const payload = await response.json() as { timestamp_ms?: number }
|
||||
if (typeof payload.timestamp_ms !== 'number') throw new Error('invalid status payload')
|
||||
setStatus('online')
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return
|
||||
setStatus('offline')
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
||||
}
|
||||
|
||||
function ValuePoint({ icon, title, desc }: { icon: ReactElement; title: string; desc: string }) {
|
||||
return (
|
||||
<div className="value-point">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ProjectAi } from './project-ai'
|
||||
import { ProjectCron } from './project-cron'
|
||||
import { ProjectInbox } from './project-inbox'
|
||||
import { ProjectNotes } from './project-notes'
|
||||
import { ProjectOverview } from './project-overview'
|
||||
import { ProjectTasks } from './project-tasks'
|
||||
@@ -9,17 +8,21 @@ import type { ChannelKey, ProjectWorkspace } from './project-types'
|
||||
export function ProjectChannelPage({
|
||||
activeChannel,
|
||||
activeWorkspace,
|
||||
activeTaskID,
|
||||
onSelectItem,
|
||||
onOpenTask,
|
||||
onCloseTask,
|
||||
}: {
|
||||
activeChannel: ChannelKey
|
||||
activeWorkspace: ProjectWorkspace
|
||||
activeTaskID: string | null
|
||||
onSelectItem: (title: string) => void
|
||||
onOpenTask: (taskID: string) => void
|
||||
onCloseTask: () => void
|
||||
}) {
|
||||
switch (activeChannel) {
|
||||
case 'inbox':
|
||||
return <ProjectInbox activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||
case 'tasks':
|
||||
return <ProjectTasks activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} />
|
||||
case 'ai':
|
||||
return <ProjectAi activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} />
|
||||
case 'notes':
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconArchive, IconEmail, IconRefresh, IconSend, IconStar } from '@arco-design/web-react/icon'
|
||||
import type { ProjectWorkspace } from './project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
const folders = [
|
||||
{ label: '未处理', count: 18, color: 'red' },
|
||||
{ label: '待回复', count: 9, color: 'orange' },
|
||||
{ label: '已归档', count: 42, color: 'gray' },
|
||||
]
|
||||
|
||||
export function ProjectInbox({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
|
||||
const { inbox, project } = activeWorkspace
|
||||
const selected = inbox[0]
|
||||
|
||||
return (
|
||||
<div className="project-channel-page project-inbox-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>Inbox 消息流</Title>
|
||||
<Text type="secondary">{project.name} 的邮件式收件箱,聚合外部反馈、系统通知和待处理线索。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<IconRefresh />}>刷新</Button>
|
||||
<Button type="primary" icon={<IconArchive />}>批量归档</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
{folders.map((folder) => (
|
||||
<Col span={8} key={folder.label}>
|
||||
<Card className="compact-card" bordered>
|
||||
<Space>
|
||||
<Tag color={folder.color}>{folder.count}</Tag>
|
||||
<Text>{folder.label}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<div className="mail-layout">
|
||||
<Card className="mail-list queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>今日消息</Title>
|
||||
<Button type="text" size="mini">全部标记已读</Button>
|
||||
</div>
|
||||
{inbox.map((item, index) => (
|
||||
<button
|
||||
className={index === 0 ? 'mail-item active' : 'mail-item'}
|
||||
key={item.title}
|
||||
onClick={() => onSelectItem(item.title)}
|
||||
>
|
||||
<span><IconEmail /> {item.title}</span>
|
||||
<Text type="secondary">{item.meta}</Text>
|
||||
<div>
|
||||
<Tag>{item.tag}</Tag>
|
||||
<time>{item.time}</time>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card className="mail-detail queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>{selected?.title ?? '暂无消息'}</Title>
|
||||
<Space>
|
||||
<Button type="text" icon={<IconStar />} />
|
||||
<Button type="text" icon={<IconSend />} />
|
||||
</Space>
|
||||
</div>
|
||||
<Text type="secondary">{selected ? `${selected.meta} · ${selected.owner}` : '真实接口暂无消息'}</Text>
|
||||
<Paragraph>
|
||||
已收到新的项目消息,需要判断是否转为任务、笔记或资料。当前建议先补充影响范围,再同步给相关负责人。
|
||||
</Paragraph>
|
||||
<div className="reply-box">
|
||||
<Text type="secondary">快速处理</Text>
|
||||
<Space>
|
||||
<Button>生成任务</Button>
|
||||
<Button>转为笔记</Button>
|
||||
<Button type="primary">回复</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -86,7 +86,7 @@ function TaskSection({ tasks, onSelectItem }: { tasks: ProjectWorkspace['tasks']
|
||||
<Tag color="arcoblue">{task.tag}</Tag>
|
||||
<span>进度 {task.progress}</span>
|
||||
<span>负责人 {task.owner}</span>
|
||||
<time>截止 {task.due}</time>
|
||||
<time>创建 {task.createdAt}</time>
|
||||
</button>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Badge, Button, Layout, Space } from '@arco-design/web-react'
|
||||
import { IconDashboard, IconPlus } from '@arco-design/web-react/icon'
|
||||
import { IconDashboard, IconEmail, IconPlus } from '@arco-design/web-react/icon'
|
||||
import type { Project, WorkbenchView } from './project-types'
|
||||
|
||||
const { Sider } = Layout
|
||||
@@ -10,12 +10,14 @@ export function ProjectRail({
|
||||
projects,
|
||||
activeView,
|
||||
onSelectWorkspace,
|
||||
onSelectWorkspaceInbox,
|
||||
onSelectProject,
|
||||
}: {
|
||||
activeProject: Project
|
||||
projects: Project[]
|
||||
activeView: WorkbenchView
|
||||
onSelectWorkspace: () => void
|
||||
onSelectWorkspaceInbox: () => void
|
||||
onSelectProject: (project: Project) => void
|
||||
}) {
|
||||
return (
|
||||
@@ -27,10 +29,22 @@ export function ProjectRail({
|
||||
aria-label="工作台"
|
||||
title="工作台"
|
||||
/>
|
||||
<Button
|
||||
className={activeView === 'workspace-inbox' ? 'dashboard-button active' : 'dashboard-button'}
|
||||
icon={<IconEmail />}
|
||||
onClick={onSelectWorkspaceInbox}
|
||||
aria-label="Inbox"
|
||||
title="Inbox"
|
||||
/>
|
||||
<Space className="project-stack" direction="vertical" size={12}>
|
||||
{projects.map((project) => (
|
||||
<Badge key={project.id} count={project.badge} dot={false} className={project.urgent ? 'project-badge urgent' : 'project-badge'}>
|
||||
<button className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'} onClick={() => onSelectProject(project)} style={{ '--project-color': project.color } as CSSProperties} title={project.name}>
|
||||
<button
|
||||
className={activeView === 'project' && activeProject.id === project.id ? 'project-button active' : 'project-button'}
|
||||
onClick={() => onSelectProject(project)}
|
||||
style={{ '--project-color': project.color } as CSSProperties}
|
||||
title={project.name}
|
||||
>
|
||||
{project.short}
|
||||
</button>
|
||||
</Badge>
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import { Button, Card, Progress, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconCalendar, IconCheckCircle, IconPlus, IconUser } from '@arco-design/web-react/icon'
|
||||
import { Button, Card, Descriptions, Progress, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconArrowLeft, IconCalendar, IconCheckCircle, IconPlus, IconUser } from '@arco-design/web-react/icon'
|
||||
import type { ProjectWorkspace, TaskItem } from './project-types'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export function ProjectTasks({ activeWorkspace, onSelectItem }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void }) {
|
||||
export function ProjectTasks({
|
||||
activeWorkspace,
|
||||
activeTaskID,
|
||||
onOpenTask,
|
||||
onCloseTask,
|
||||
onSelectItem,
|
||||
}: {
|
||||
activeWorkspace: ProjectWorkspace
|
||||
activeTaskID: string | null
|
||||
onOpenTask: (taskID: string) => void
|
||||
onCloseTask: () => void
|
||||
onSelectItem: (title: string) => void
|
||||
}) {
|
||||
const { tasks, project } = activeWorkspace
|
||||
const activeTask = tasks.find((task) => task.id === activeTaskID)
|
||||
const lanes = makeLanes(tasks)
|
||||
|
||||
if (activeTask) {
|
||||
return <TaskDetail activeWorkspace={activeWorkspace} task={activeTask} onBack={onCloseTask} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="project-channel-page project-tasks-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>工作计划</Title>
|
||||
<Text type="secondary">{project.name} 的 TODO 计划、负责人和截止时间。</Text>
|
||||
<Text type="secondary">{project.name} 的 TODO 计划、负责人和创建时间。</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<IconPlus />}>新建任务</Button>
|
||||
</div>
|
||||
@@ -20,14 +38,14 @@ export function ProjectTasks({ activeWorkspace, onSelectItem }: { activeWorkspac
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>任务清单</Title>
|
||||
<Tag color="arcoblue">{tasks.length} 个进行中</Tag>
|
||||
<Tag color="arcoblue">{tasks.length} 个任务</Tag>
|
||||
</div>
|
||||
{tasks.map((task) => (
|
||||
<button className="data-row task-row" key={task.title} onClick={() => onSelectItem(task.title)}>
|
||||
<button className="data-row task-row" key={task.id} onClick={() => onOpenTask(task.id)}>
|
||||
<span className="row-title"><IconCheckCircle /> {task.title}</span>
|
||||
<Tag color="arcoblue">{task.tag}</Tag>
|
||||
<span><IconUser /> {task.owner}</span>
|
||||
<span><IconCalendar /> {task.due}</span>
|
||||
<span><IconCalendar /> {task.createdAt}</span>
|
||||
<Progress percent={Number.parseInt(task.progress, 10)} size="small" />
|
||||
</button>
|
||||
))}
|
||||
@@ -42,7 +60,7 @@ export function ProjectTasks({ activeWorkspace, onSelectItem }: { activeWorkspac
|
||||
</div>
|
||||
<Space direction="vertical" size={8}>
|
||||
{lane.items.map((title) => (
|
||||
<button className="task-card-button" key={title} onClick={() => onSelectItem(title)}>
|
||||
<button className="task-card-button" key={title} onClick={() => openTaskByTitle(tasks, title, onOpenTask, onSelectItem)}>
|
||||
<Text>{title}</Text>
|
||||
<Text type="secondary">优先级 · 本周</Text>
|
||||
</button>
|
||||
@@ -55,6 +73,53 @@ export function ProjectTasks({ activeWorkspace, onSelectItem }: { activeWorkspac
|
||||
)
|
||||
}
|
||||
|
||||
function TaskDetail({ activeWorkspace, task, onBack }: { activeWorkspace: ProjectWorkspace; task: TaskItem; onBack: () => void }) {
|
||||
const percent = Number.parseInt(task.progress, 10)
|
||||
|
||||
return (
|
||||
<div className="project-channel-page project-task-detail-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Button type="text" icon={<IconArrowLeft />} onClick={onBack}>返回任务列表</Button>
|
||||
<Title heading={4}>{task.title}</Title>
|
||||
<Text type="secondary">{activeWorkspace.project.name} 的任务详情</Text>
|
||||
</div>
|
||||
<Tag color={task.completed ? 'green' : 'arcoblue'}>{task.tag}</Tag>
|
||||
</div>
|
||||
|
||||
<Card className="queue-section task-detail-card" bordered>
|
||||
<Descriptions
|
||||
colon=":"
|
||||
column={2}
|
||||
data={[
|
||||
{ label: '所属项目', value: activeWorkspace.project.name },
|
||||
{ label: '负责人', value: task.owner },
|
||||
{ label: '创建时间', value: task.createdAt },
|
||||
{ label: '完成状态', value: task.completed ? '已完成' : '未完成' },
|
||||
]}
|
||||
/>
|
||||
<div className="task-detail-progress">
|
||||
<Text type="secondary">任务进度</Text>
|
||||
<Progress percent={Number.isNaN(percent) ? 0 : percent} />
|
||||
</div>
|
||||
<div className="task-detail-summary">
|
||||
<Title heading={6}>任务说明</Title>
|
||||
<Text>{task.summary || '暂无任务说明'}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function openTaskByTitle(tasks: TaskItem[], title: string, onOpenTask: (taskID: string) => void, onSelectItem: (title: string) => void) {
|
||||
const task = tasks.find((item) => item.title === title)
|
||||
if (task) {
|
||||
onOpenTask(task.id)
|
||||
return
|
||||
}
|
||||
onSelectItem(title)
|
||||
}
|
||||
|
||||
function makeLanes(tasks: TaskItem[]) {
|
||||
const done = tasks.filter((task) => task.completed)
|
||||
const active = tasks.filter((task) => !task.completed)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Badge, Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
|
||||
import { IconApps, IconArrowLeft, IconArrowRight, IconMoon, IconNotification, IconQuestionCircle, IconSearch, IconSun } from '@arco-design/web-react/icon'
|
||||
import { Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
|
||||
import { IconApps, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon'
|
||||
import type { Theme } from './project-types'
|
||||
|
||||
const { Header } = Layout
|
||||
const { Text } = Typography
|
||||
|
||||
export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => void }) {
|
||||
const desktop = isDesktopRuntime()
|
||||
|
||||
return (
|
||||
<Header className="topbar">
|
||||
<div className="brand-lockup">
|
||||
@@ -18,12 +20,26 @@ export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggle
|
||||
</div>
|
||||
<Space className="topbar-actions">
|
||||
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
|
||||
<Button icon={<IconArrowLeft />} disabled />
|
||||
<Button icon={<IconArrowRight />} disabled />
|
||||
<Badge count={8} dot><Button icon={<IconNotification />} /></Badge>
|
||||
<Button icon={<IconQuestionCircle />} />
|
||||
{desktop && (
|
||||
<>
|
||||
<Button aria-label="停靠左边" icon={<DockIcon side="left" />} />
|
||||
<Button aria-label="停靠右边" icon={<DockIcon side="right" />} />
|
||||
</>
|
||||
)}
|
||||
<Button icon={<IconApps />} />
|
||||
</Space>
|
||||
</Header>
|
||||
)
|
||||
}
|
||||
|
||||
function isDesktopRuntime() {
|
||||
return typeof window !== 'undefined' && '__TAURI__' in window
|
||||
}
|
||||
|
||||
function DockIcon({ side }: { side: 'left' | 'right' }) {
|
||||
return (
|
||||
<span className={`dock-icon dock-icon-${side}`} aria-hidden="true">
|
||||
<span />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactElement } from 'react'
|
||||
|
||||
export type Screen = 'login' | 'workbench'
|
||||
export type Theme = 'light' | 'dark'
|
||||
export type WorkbenchView = 'workspace' | 'project'
|
||||
export type WorkbenchView = 'workspace' | 'workspace-inbox' | 'project'
|
||||
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | `custom:${string}`
|
||||
|
||||
export type Project = {
|
||||
@@ -41,6 +41,7 @@ export type TaskItem = {
|
||||
owner: string
|
||||
progress: string
|
||||
due: string
|
||||
createdAt: string
|
||||
tag: string
|
||||
summary: string
|
||||
completed: boolean
|
||||
|
||||
92
apps/senlinai-acro-react/src/pages/workspace-body.tsx
Normal file
92
apps/senlinai-acro-react/src/pages/workspace-body.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconCalendar,
|
||||
IconCheckCircleFill,
|
||||
IconDashboard,
|
||||
IconFile,
|
||||
IconRobot,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import type { Project, ProjectWorkspace } from './projects/project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
export function WorkspacePage({ workspaces, onOpenTask }: { workspaces: ProjectWorkspace[]; onOpenTask: (project: Project, taskID: string) => void }) {
|
||||
const projects = workspaces.map((workspace) => workspace.project)
|
||||
const unfinishedTasks = workspaces.flatMap((workspace) =>
|
||||
workspace.tasks
|
||||
.filter((task) => !task.completed)
|
||||
.map((task) => ({
|
||||
...task,
|
||||
project: workspace.project,
|
||||
})),
|
||||
)
|
||||
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
|
||||
const notes = workspaces.flatMap((workspace) => workspace.notes)
|
||||
const urgentProjects = projects.filter((project) => project.urgent).length
|
||||
const workspaceMetrics = [
|
||||
{ title: '项目总数', value: projects.length, delta: `${urgentProjects} 个高优先级`, icon: <IconDashboard />, color: 'arcoblue' },
|
||||
{ title: '未完成任务', value: unfinishedTasks.length, delta: '跨项目聚合', icon: <IconCheckCircleFill />, color: 'green' },
|
||||
{ title: 'AI 会话', value: aiSessions.length, delta: '项目内上下文', icon: <IconRobot />, color: 'purple' },
|
||||
{ title: '知识资料', value: notes.length, delta: '笔记与资料合计', icon: <IconFile />, color: 'cyan' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="workspace-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>工作台</Title>
|
||||
<Text type="secondary">所有项目的统一工作台,汇总项目任务、AI 会话和知识资产。</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12} className="metric-row">
|
||||
{workspaceMetrics.map((metric) => (
|
||||
<Col span={24 / workspaceMetrics.length} key={metric.title}>
|
||||
<Card className="metric-card" bordered>
|
||||
<Space align="start">
|
||||
<Tag color={metric.color}>{metric.icon}</Tag>
|
||||
<div>
|
||||
<Text type="secondary">{metric.title}</Text>
|
||||
<Title heading={3}>{metric.value}</Title>
|
||||
<Text className="metric-delta">{metric.delta}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>未完成的任务</Title>
|
||||
<Tag color="arcoblue">{unfinishedTasks.length}</Tag>
|
||||
</div>
|
||||
{unfinishedTasks.length ? (
|
||||
<div className="workspace-task-list">
|
||||
{unfinishedTasks.map((task) => (
|
||||
<button key={`${task.project.id}-${task.id}`} className="workspace-task-card" onClick={() => onOpenTask(task.project, task.id)}>
|
||||
<div className="workspace-task-main">
|
||||
<span className="workspace-task-title">
|
||||
<IconCheckCircleFill />
|
||||
{task.title}
|
||||
</span>
|
||||
<Paragraph ellipsis={{ rows: 2 }} type="secondary">
|
||||
{task.summary || '暂无任务说明'}
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="workspace-task-meta">
|
||||
<Tag color={task.project.urgent ? 'red' : 'arcoblue'}>{task.project.name}</Tag>
|
||||
<span><IconCalendar /> {task.createdAt}</span>
|
||||
<Tag color="green">{task.tag}</Tag>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="暂无未完成任务" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Layout } from '@arco-design/web-react'
|
||||
import { WorkspacePage } from './workspace'
|
||||
import { WorkspacePage } from './workspace-body'
|
||||
import { WorkspaceInboxPage } from './workspace-inbox'
|
||||
import { ProjectChannelPage } from './projects/project-channel-page'
|
||||
import { ProjectRail } from './projects/project-rail'
|
||||
import { ProjectSidebar } from './projects/project-sidebar'
|
||||
@@ -14,25 +15,33 @@ export function ProjectPage({
|
||||
activeWorkspace,
|
||||
workspaces,
|
||||
activeChannel,
|
||||
activeTaskID,
|
||||
theme,
|
||||
onSelectProject,
|
||||
onSelectWorkspace,
|
||||
onSelectWorkspaceInbox,
|
||||
onSelectChannel,
|
||||
onSelectItem,
|
||||
onOpenTask,
|
||||
onCloseTask,
|
||||
onToggleTheme,
|
||||
}: {
|
||||
activeView: WorkbenchView
|
||||
activeWorkspace: ProjectWorkspace
|
||||
workspaces: ProjectWorkspace[]
|
||||
activeChannel: ChannelKey
|
||||
activeTaskID: string | null
|
||||
theme: Theme
|
||||
onSelectProject: (project: Project) => void
|
||||
onSelectWorkspace: () => void
|
||||
onSelectWorkspaceInbox: () => void
|
||||
onSelectChannel: (key: ChannelKey) => void
|
||||
onSelectItem: (title: string) => void
|
||||
onOpenTask: (project: Project, taskID: string) => void
|
||||
onCloseTask: () => void
|
||||
onToggleTheme: () => void
|
||||
}) {
|
||||
const isWorkspace = activeView === 'workspace'
|
||||
const isProject = activeView === 'project'
|
||||
const projects = workspaces.map((workspace) => workspace.project)
|
||||
|
||||
return (
|
||||
@@ -45,10 +54,11 @@ export function ProjectPage({
|
||||
projects={projects}
|
||||
activeView={activeView}
|
||||
onSelectWorkspace={onSelectWorkspace}
|
||||
onSelectWorkspaceInbox={onSelectWorkspaceInbox}
|
||||
onSelectProject={onSelectProject}
|
||||
/>
|
||||
|
||||
{!isWorkspace && (
|
||||
{isProject && (
|
||||
<ProjectSidebar
|
||||
activeView={activeView}
|
||||
activeWorkspace={activeWorkspace}
|
||||
@@ -59,13 +69,18 @@ export function ProjectPage({
|
||||
)}
|
||||
|
||||
<Content className="stage">
|
||||
{isWorkspace ? (
|
||||
<WorkspacePage workspaces={workspaces} onSelectProject={onSelectProject} />
|
||||
{activeView === 'workspace' ? (
|
||||
<WorkspacePage workspaces={workspaces} onOpenTask={onOpenTask} />
|
||||
) : activeView === 'workspace-inbox' ? (
|
||||
<WorkspaceInboxPage workspaces={workspaces} onSelectItem={onSelectItem} />
|
||||
) : (
|
||||
<ProjectChannelPage
|
||||
activeChannel={activeChannel}
|
||||
activeWorkspace={activeWorkspace}
|
||||
activeTaskID={activeTaskID}
|
||||
onSelectItem={onSelectItem}
|
||||
onOpenTask={(taskID) => onOpenTask(activeWorkspace.project, taskID)}
|
||||
onCloseTask={onCloseTask}
|
||||
/>
|
||||
)}
|
||||
</Content>
|
||||
95
apps/senlinai-acro-react/src/pages/workspace-inbox.tsx
Normal file
95
apps/senlinai-acro-react/src/pages/workspace-inbox.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Button, Card, Empty, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconArchive, IconEmail, IconRefresh, IconSend, IconStar } from '@arco-design/web-react/icon'
|
||||
import type { ProjectWorkspace } from './projects/project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text, Paragraph } = Typography
|
||||
|
||||
export function WorkspaceInboxPage({ workspaces, onSelectItem }: { workspaces: ProjectWorkspace[]; onSelectItem: (title: string) => void }) {
|
||||
const messages = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, project: workspace.project })))
|
||||
const selected = messages[0]
|
||||
const openCount = messages.filter((item) => item.status === 'open').length
|
||||
const handledCount = messages.length - openCount
|
||||
|
||||
return (
|
||||
<div className="workspace-inbox-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>Inbox</Title>
|
||||
<Text type="secondary">接收来自外部的消息流,统一处理客户反馈、系统通知和待归档线索。</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<IconRefresh />}>刷新</Button>
|
||||
<Button type="primary" icon={<IconArchive />}>批量归档</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Card className="compact-card" bordered>
|
||||
<Space><Tag color="red">{openCount}</Tag><Text>未处理</Text></Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="compact-card" bordered>
|
||||
<Space><Tag color="arcoblue">{messages.length}</Tag><Text>全部消息</Text></Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card className="compact-card" bordered>
|
||||
<Space><Tag color="green">{handledCount}</Tag><Text>已处理</Text></Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{messages.length ? (
|
||||
<div className="mail-layout">
|
||||
<Card className="mail-list queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>外部消息流</Title>
|
||||
<Button type="text" size="mini">全部标记已读</Button>
|
||||
</div>
|
||||
{messages.map((item, index) => (
|
||||
<button
|
||||
className={index === 0 ? 'mail-item active' : 'mail-item'}
|
||||
key={`${item.project.id}-${item.id}`}
|
||||
onClick={() => onSelectItem(item.title)}
|
||||
>
|
||||
<span><IconEmail /> {item.title}</span>
|
||||
<Text type="secondary">{item.meta}</Text>
|
||||
<div>
|
||||
<Tag color={item.project.urgent ? 'red' : 'arcoblue'}>{item.project.name}</Tag>
|
||||
<time>{item.time}</time>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card className="mail-detail queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>{selected.title}</Title>
|
||||
<Space>
|
||||
<Button type="text" icon={<IconStar />} />
|
||||
<Button type="text" icon={<IconSend />} />
|
||||
</Space>
|
||||
</div>
|
||||
<Text type="secondary">{selected.meta} · {selected.project.name}</Text>
|
||||
<Paragraph>{selected.summary || '暂无消息正文'}</Paragraph>
|
||||
<div className="reply-box">
|
||||
<Text type="secondary">快速处理</Text>
|
||||
<Space>
|
||||
<Button>生成任务</Button>
|
||||
<Button>转为笔记</Button>
|
||||
<Button type="primary">回复</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="queue-section" bordered>
|
||||
<Empty description="暂无外部消息" />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Button, Card, Grid, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import {
|
||||
IconCheckCircleFill,
|
||||
IconDashboard,
|
||||
IconEmail,
|
||||
IconFile,
|
||||
IconRobot,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import type { Project, ProjectWorkspace } from './projects/project-types'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export function WorkspacePage({ workspaces, onSelectProject }: { workspaces: ProjectWorkspace[]; onSelectProject: (project: Project) => void }) {
|
||||
const projects = workspaces.map((workspace) => workspace.project)
|
||||
const inbox = workspaces.flatMap((workspace) => workspace.inbox)
|
||||
const tasks = workspaces.flatMap((workspace) => workspace.tasks).filter((task) => !task.completed)
|
||||
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
|
||||
const notes = workspaces.flatMap((workspace) => workspace.notes)
|
||||
const totalProjectBadges = projects.reduce((sum, project) => sum + project.badge, 0)
|
||||
const urgentProjects = projects.filter((project) => project.urgent).length
|
||||
const workspaceMetrics = [
|
||||
{ title: '项目总数', value: projects.length, delta: `${urgentProjects} 个高优先级`, icon: <IconDashboard />, color: 'arcoblue' },
|
||||
{ title: '全局待处理', value: totalProjectBadges, delta: '跨项目聚合', icon: <IconEmail />, color: 'red' },
|
||||
{ title: '进行中任务', value: tasks.length, delta: '本周持续推进', icon: <IconCheckCircleFill />, color: 'green' },
|
||||
{ title: 'AI 会话', value: aiSessions.length, delta: '队列空闲', icon: <IconRobot />, color: 'purple' },
|
||||
{ title: '知识资料', value: notes.length, delta: '最近 7 天更新', icon: <IconFile />, color: 'cyan' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="workspace-page overview-page">
|
||||
<div className="overview-head">
|
||||
<div>
|
||||
<Title heading={4}>工作台</Title>
|
||||
<Text type="secondary">所有项目的统一工作台,汇总项目动态、任务压力和知识资产。</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12} className="metric-row">
|
||||
{workspaceMetrics.map((metric) => (
|
||||
<Col span={24 / workspaceMetrics.length} key={metric.title}>
|
||||
<Card className="metric-card" bordered>
|
||||
<Space align="start">
|
||||
<Tag color={metric.color}>{metric.icon}</Tag>
|
||||
<div>
|
||||
<Text type="secondary">{metric.title}</Text>
|
||||
<Title heading={3}>{metric.value}</Title>
|
||||
<Text className="metric-delta">{metric.delta}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>项目健康度</Title>
|
||||
<Button type="text" size="mini">查看全部项目</Button>
|
||||
</div>
|
||||
{projects.map((project) => (
|
||||
<button key={project.id} className="data-row" onClick={() => onSelectProject(project)}>
|
||||
<span className="row-title"><IconDashboard /> {project.name}</span>
|
||||
<Tag color={project.urgent ? 'red' : 'green'}>{project.urgent ? '高优先级' : '正常'}</Tag>
|
||||
<span>待处理 {project.badge}</span>
|
||||
<span>项目代码 {project.short}</span>
|
||||
<time>今日更新</time>
|
||||
</button>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<Card className="queue-section" bordered>
|
||||
<div className="section-header">
|
||||
<Title heading={6}>跨项目待处理</Title>
|
||||
<Button type="text" size="mini">打开全局 Inbox</Button>
|
||||
</div>
|
||||
{inbox.slice(0, 4).map((item) => (
|
||||
<div key={item.title} className="data-row">
|
||||
<span className="row-title"><IconEmail /> {item.title}</span>
|
||||
<span>{item.meta}</span>
|
||||
<Tag>{item.tag}</Tag>
|
||||
<span>{item.owner}</span>
|
||||
<time>{item.time}</time>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -56,6 +56,7 @@ type WorkTask struct {
|
||||
Completed bool `json:"completed"`
|
||||
Owner string `json:"owner"`
|
||||
Due string `json:"due"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
@@ -323,6 +324,7 @@ func (s *Service) workspaceTasks(projectID uint) ([]WorkTask, error) {
|
||||
Completed: task.Status == "done",
|
||||
Owner: owner,
|
||||
Due: displayOptionalTime(task.DueAt),
|
||||
CreatedAt: displayTime(task.CreatedAt),
|
||||
Tag: "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ func TestWorkspaceMatchesFrontendContract(t *testing.T) {
|
||||
require.Len(t, workspace.Tasks, 2)
|
||||
require.False(t, workspace.Tasks[0].Completed)
|
||||
require.Equal(t, "David", workspace.Tasks[0].Owner)
|
||||
require.NotEmpty(t, workspace.Tasks[0].CreatedAt)
|
||||
require.Len(t, workspace.AISessions, 1)
|
||||
require.Len(t, workspace.RecentSessions, 1)
|
||||
require.Len(t, workspace.NotesSources, 2)
|
||||
|
||||
Reference in New Issue
Block a user