feat: update workspace pages and global inbox

This commit is contained in:
2026-07-20 18:25:04 +08:00
parent c64daa8657
commit df12201caa
20 changed files with 597 additions and 229 deletions

View File

@@ -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`)
}

View File

@@ -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')

View File

@@ -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 {

View File

@@ -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,

View File

@@ -45,6 +45,7 @@ export type BackendTask = {
completed: boolean
owner: string
due: string
createdAt: string
tag: string
}

View File

@@ -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')}
/>
) : (

View File

@@ -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">

View File

@@ -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':

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -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

View 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>
)
}

View File

@@ -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>

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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: "",
})
}

View File

@@ -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)