refactor(desktop): simplify all-projects dashboard

This commit is contained in:
2026-07-25 22:40:29 +08:00
parent b01f14ac00
commit ac674d71e7
4 changed files with 14 additions and 19 deletions

View File

@@ -6,7 +6,7 @@
- Implementation screenshot: `test-results/app-home.png`
- Viewport: 420 × 820 CSS px at device scale factor 1; implementation image is 420 × 820 px.
- Source image: 144 × 343 px and only shows the compact icon-navigation language. It does not define the requested all-projects dashboard.
- State: signed-in light theme, “工作台 / 全部项目” selected, one pending task, one completed task, one latest file.
- State: signed-in light theme, “工作台 / 全部项目” selected, one pending task and one latest file. The content header is “全部项目” with its creation action aligned on the right.
- Primary interactions tested: default all-projects homepage, task editor modal, explore view, project picker, project-only right navigation, document panel, AI panel, and profile dropdown. Console errors: none.
## Evidence
@@ -24,11 +24,11 @@ No actionable P0, P1, or P2 findings.
- Spacing and layout rhythm: the all-projects page fills the former rail width after navigation is hidden. At 420 px, the header, tabs, statistics, actions, task lists, and latest files remain within the viewport with no horizontal overflow.
- Colors and visual tokens: workbench/explore selection uses the existing blue token, while statistic colors distinguish projects, pending work, files, and plan tasks without overpowering the task list.
- Image and icon fidelity: the existing product mark is retained and all controls use the installed Arco icon set; no placeholder or hand-drawn image assets were added.
- Copy and content: “工作台”, “探索”, and “全部项目” make scope explicit; the project-only menu contains 概况、计划、资料、AI、计划任务 and no longer exposes 收集箱.
- Copy and content: “工作台”, “探索”, and “全部项目” make scope explicit; its homepage shows only pending tasks and latest files. The project-only menu contains 概况、计划、资料、AI、计划任务 and no longer exposes 收集箱.
## Comparison history
- Current pass: changed the all-projects scope from a flat merged task list to a dedicated dashboard and hid the right rail for that scope. Re-captured `test-results/app-home.png` and verified the requested dashboard sections and task/edit interactions.
- Current pass: removed the completed-task section and upload action from the all-projects homepage, then removed the content-level “工作台” label and moved 创建项目 beside 全部项目. Re-captured `test-results/app-home.png` and verified that only pending tasks and latest files remain.
## Follow-up polish
@@ -38,7 +38,7 @@ No actionable P0, P1, or P2 findings.
- [x] Replace the project strip with workbench, explore, and current-project controls.
- [x] Make all-projects the default selection.
- [x] Add all-project statistics, pending tasks, project creation, targeted file upload, and latest files.
- [x] Add all-project statistics, pending tasks, project creation, and latest files.
- [x] Hide right navigation for all-projects and explore views.
- [x] Remove 收集箱 and rename 周期计划 to 计划任务.
- [x] Verify key interaction paths and responsive composition.

View File

@@ -72,7 +72,7 @@ if (home.width !== 420) failures.push(`expected 420px shell, got ${home.width}`)
if (home.selectedAll !== '全部项目') failures.push(`default project scope must be 全部项目, got ${home.selectedAll}`)
if (home.navCount !== 0) failures.push(`all-projects view must hide the right rail, got ${home.navCount}`)
if (home.statCount !== 4) failures.push(`expected four summary cards, got ${home.statCount}`)
if (home.pending !== 1 || home.completed !== 1) failures.push(`task sections incorrect: ${JSON.stringify(home)}`)
if (home.pending !== 1 || home.completed !== 0) failures.push(`all-project task sections incorrect: ${JSON.stringify(home)}`)
if (taskModal !== 1) failures.push('task editor did not open')
if (exploreProjects !== 1) failures.push(`expected one project in explore, got ${exploreProjects}`)
if (documents !== 1) failures.push(`expected one document, got ${documents}`)

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Avatar, Button, Dropdown, Empty, Form, Input, Menu, Message, Modal, Select, Spin, Switch, Tag, Typography } from '@arco-design/web-react'
import { useEffect, useMemo, useState } from 'react'
import { Alert, Avatar, Button, Dropdown, Empty, Form, Input, Menu, Modal, Select, Spin, Switch, Tag, Typography } from '@arco-design/web-react'
import {
IconApps, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconCompass, IconDown, IconFile,
IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRight, IconRobot,
@@ -8,7 +8,6 @@ import {
import {
ApiError, createProject, fetchProjectWorkspace, fetchProjects, getCurrentUser,
listAIExperts, login, setApiBaseUrl, updateCurrentUser, updateTask,
uploadDocument,
type ApiSession, type CurrentUser, type Expert, type Project, type Workspace, type WorkspaceTask,
} from './api'
@@ -114,7 +113,7 @@ export function App() {
</section>
{error ? <Alert className="app-alert" type="error" content={error} closable onClose={() => setError('')} /> : null}
<main className="app-main"><Spin loading={loading} block>{workspaceMode === 'explore' ? <ExplorePanel projects={projects} onOpen={(id) => { setProjectId(id); setWorkspaceMode('workbench') }} /> : !projectId ? <AllProjectsHome projects={projects} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onCreateProject={() => setProjectModalOpen(true)} onUpload={async (project, file) => { await uploadDocument(session, project, file); await loadWorkspaces() }} /> : visibleWorkspaces.length ? <Content view={view} title={title} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onLoadAI={loadAI} experts={experts} /> : <Empty description="项目加载失败,请刷新后重试" />}</Spin></main>
<main className="app-main"><Spin loading={loading} block>{workspaceMode === 'explore' ? <ExplorePanel projects={projects} onOpen={(id) => { setProjectId(id); setWorkspaceMode('workbench') }} /> : !projectId ? <AllProjectsHome projects={projects} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onCreateProject={() => setProjectModalOpen(true)} /> : visibleWorkspaces.length ? <Content view={view} title={title} workspaces={visibleWorkspaces} tasks={tasks} onEditTask={setTaskEditor} onLoadAI={loadAI} experts={experts} /> : <Empty description="项目加载失败,请刷新后重试" />}</Spin></main>
{projectId && workspaceMode === 'workbench' ? <aside className="channel-rail" aria-label="项目子菜单">
{navItems.map((item) => <button key={item.id} title={item.label} className={view === item.id ? 'active' : ''} onClick={() => { setView(item.id); if (item.id === 'ai') void loadAI() }}>{item.icon}<span>{item.label}</span></button>)}
@@ -149,17 +148,17 @@ function Content({ view, title, workspaces, tasks, onEditTask, onLoadAI, experts
return <CronPanel title={title} workspaces={workspaces} />
}
function TaskBoard({ title, subtitle, tasks, onEditTask }: { title: string; subtitle: string; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void }) {
function TaskBoard({ title, subtitle, tasks, onEditTask, showCompleted = true }: { title: string; subtitle: string; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; showCompleted?: boolean }) {
const [tag, setTag] = useState('全部')
const tags = [...new Set(tasks.map((task) => task.tag).filter(Boolean))]
const visible = tag === '全部' ? tasks : tasks.filter((task) => task.tag === tag)
const pending = visible.filter((task) => !task.completed)
const completed = visible.filter((task) => task.completed)
return <section className="content-page task-page">
<div className="content-heading"><div><Text type="secondary">{subtitle}</Text><Title heading={4}>{title}</Title></div><Tag color="arcoblue">{pending.length} </Tag></div>
<div className="content-heading"><div>{subtitle ? <Text type="secondary">{subtitle}</Text> : null}<Title heading={4}>{title}</Title></div><Tag color="arcoblue">{pending.length} </Tag></div>
<div className="tag-filter"><button className={tag === '全部' ? 'active' : ''} onClick={() => setTag('全部')}></button>{tags.map((item) => <button key={item} className={tag === item ? 'active' : ''} onClick={() => setTag(item)}>{item}</button>)}</div>
<section className="task-section"><div className="section-label"><strong></strong><span>{pending.length}</span></div><div className="task-list">{pending.map((task) => <TaskRow key={task.id} task={task} onClick={() => onEditTask(task)} />)}{!pending.length && <Empty description="当前没有待办任务" />}</div></section>
<section className="task-section completed-section"><div className="section-label"><strong></strong><span>{completed.length}</span></div><div className="task-list">{completed.map((task) => <TaskRow key={task.id} task={task} onClick={() => onEditTask(task)} />)}{!completed.length && <Empty description="尚未完成任何任务" />}</div></section>
{showCompleted ? <section className="task-section completed-section"><div className="section-label"><strong></strong><span>{completed.length}</span></div><div className="task-list">{completed.map((task) => <TaskRow key={task.id} task={task} onClick={() => onEditTask(task)} />)}{!completed.length && <Empty description="尚未完成任何任务" />}</div></section> : null}
</section>
}
@@ -168,18 +167,14 @@ function TaskRow({ task, onClick }: { task: TaskWithProject; onClick: () => void
}
function AllProjectsHome({ projects, workspaces, tasks, onEditTask, onCreateProject, onUpload }: { projects: Project[]; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onCreateProject: () => void; onUpload: (projectId: string, file: File) => Promise<void> }) {
const fileInput = useRef<HTMLInputElement>(null)
const [uploadProject, setUploadProject] = useState(projects[0]?.id ?? '')
const [uploading, setUploading] = useState(false)
useEffect(() => { if (!projects.some((project) => project.id === uploadProject)) setUploadProject(projects[0]?.id ?? '') }, [projects, uploadProject])
function AllProjectsHome({ projects, workspaces, tasks, onEditTask, onCreateProject }: { projects: Project[]; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onCreateProject: () => void }) {
const pending = tasks.filter((task) => !task.completed)
const latestFiles = workspaces.flatMap((workspace) => workspace.documents.map((document) => ({ ...document, projectName: workspace.project.name }))).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, 6)
const statItems = [
{ label: '项目', value: projects.length, color: 'blue' }, { label: '待办', value: pending.length, color: 'orange' },
{ label: '文件', value: latestFiles.length, color: 'green' }, { label: '计划任务', value: workspaces.reduce((total, workspace) => total + workspace.cronPlans.length, 0), color: 'purple' },
]
return <section className="content-page all-projects-home"><div className="content-heading"><div><Text type="secondary"></Text><Title heading={4}></Title></div></div><div className="stat-grid">{statItems.map((item) => <div className={`stat-card ${item.color}`} key={item.label}><strong>{item.value}</strong><span>{item.label}</span></div>)}</div><div className="quick-workspace-actions"><Button type="primary" icon={<IconPlus />} onClick={onCreateProject}></Button><Button icon={<IconFile />} disabled={!uploadProject} loading={uploading} onClick={() => fileInput.current?.click()}></Button><Select value={uploadProject || undefined} placeholder="选择上传项目" onChange={setUploadProject}>{projects.map((project) => <Select.Option key={project.id} value={project.id}>{project.name}</Select.Option>)}</Select><input ref={fileInput} className="hidden-file" type="file" onChange={async (event) => { const file = event.target.files?.[0]; if (!file || !uploadProject) return; setUploading(true); try { await onUpload(uploadProject, file); Message.success('文件已上传') } catch (error) { Message.error(errorMessage(error)) } finally { event.target.value = ''; setUploading(false) } }} /></div><TaskBoard title="待办任务" subtitle="全部项目" tasks={tasks} onEditTask={onEditTask} /><section className="latest-files"><div className="section-label"><strong></strong><span>{latestFiles.length}</span></div><div className="detail-list">{latestFiles.map((file) => <article className="detail-row" key={file.id}><IconFile /><div><strong>{file.name}</strong><p>{file.extension || file.mimeType || file.kind} · {formatDate(file.updatedAt)}</p></div><Tag>{file.projectName}</Tag></article>)}{!latestFiles.length && <Empty description="暂无文件,上传一份项目资料开始吧" />}</div></section></section>
return <section className="content-page all-projects-home"><div className="content-heading"><div><Title heading={4}></Title></div><Button type="primary" icon={<IconPlus />} onClick={onCreateProject}></Button></div><div className="stat-grid">{statItems.map((item) => <div className={`stat-card ${item.color}`} key={item.label}><strong>{item.value}</strong><span>{item.label}</span></div>)}</div><TaskBoard title="待办任务" subtitle="" tasks={tasks} onEditTask={onEditTask} showCompleted={false} /><section className="latest-files"><div className="section-label"><strong></strong><span>{latestFiles.length}</span></div><div className="detail-list">{latestFiles.map((file) => <article className="detail-row" key={file.id}><IconFile /><div><strong>{file.name}</strong><p>{file.extension || file.mimeType || file.kind} · {formatDate(file.updatedAt)}</p></div><Tag>{file.projectName}</Tag></article>)}{!latestFiles.length && <Empty description="暂无文件" />}</div></section></section>
}
function ExplorePanel({ projects, onOpen }: { projects: Project[]; onOpen: (id: string) => void }) {

View File

@@ -66,7 +66,7 @@ button { font: inherit; }
.expert-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.expert-chips span { display: inline-flex; align-items: center; gap: 4px; padding: 4px 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); font-size: 11px; }.expert-chips i { display: grid; place-items: center; width: 17px; height: 17px; border-radius: 50%; font-style: normal; }
.stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 13px; }.stat-card { display: grid; gap: 3px; padding: 11px; border: 1px solid var(--line); border-radius: 11px; background: var(--surface); }.stat-card strong { font-size: 22px; line-height: 1; }.stat-card span { color: var(--muted); font-size: 11px; }.stat-card.blue strong { color: #165dff; }.stat-card.orange strong { color: #ff7d00; }.stat-card.green strong { color: #00b42a; }.stat-card.purple strong { color: #722ed1; }
.quick-workspace-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 18px; }.quick-workspace-actions .arco-select { grid-column: 1 / -1; width: 100%; }.hidden-file { display: none; }.all-projects-home .task-page { margin-top: 4px; }.latest-files { padding: 13px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }.project-card-grid { display: grid; gap: 9px; }.project-explore-card { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; padding: 13px; border: 1px solid var(--line); border-radius: 11px; color: var(--text); background: var(--surface); text-align: left; cursor: pointer; }.project-explore-card:hover { border-color: #9fc9ff; background: #f7faff; }.project-explore-card > i { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: #fff; font-style: normal; font-weight: 700; font-size: 11px; }.project-explore-card span { display: grid; min-width: 0; gap: 3px; }.project-explore-card strong, .project-explore-card small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.project-explore-card strong { font-size: 13px; }.project-explore-card small { color: var(--muted); font-size: 11px; }
.quick-workspace-actions { display: flex; gap: 8px; margin-bottom: 18px; }.all-projects-home .task-page { margin-top: 4px; }.latest-files { padding: 13px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }.project-card-grid { display: grid; gap: 9px; }.project-explore-card { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; align-items: center; gap: 10px; width: 100%; padding: 13px; border: 1px solid var(--line); border-radius: 11px; color: var(--text); background: var(--surface); text-align: left; cursor: pointer; }.project-explore-card:hover { border-color: #9fc9ff; background: #f7faff; }.project-explore-card > i { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: #fff; font-style: normal; font-weight: 700; font-size: 11px; }.project-explore-card span { display: grid; min-width: 0; gap: 3px; }.project-explore-card strong, .project-explore-card small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.project-explore-card strong { font-size: 13px; }.project-explore-card small { color: var(--muted); font-size: 11px; }
.login-page { display: grid; min-height: 100vh; place-items: center; padding: 20px; background: #edf2f9; }.login-card { display: grid; width: min(100%, 360px); gap: 12px; padding: 28px; border: 1px solid #e5e6eb; border-radius: 16px; background: #fff; box-shadow: 0 18px 48px rgba(31, 35, 41, .12); }.login-card > img { width: 34px; }.login-card .arco-typography { margin: 0; }.login-card label { display: grid; gap: 5px; color: #4e5969; font-size: 12px; }
.theme-dark .project-strip, .theme-dark .app-header, .theme-dark .task-section, .theme-dark .detail-row, .theme-dark .tag-filter button { color: var(--text); background: var(--surface); }.theme-dark .project-pill { color: var(--muted); }.theme-dark .project-pill.active, .theme-dark .channel-rail button.active { color: #8ac7ff; background: #1d4d77; }.theme-dark .task-row:hover { background: #293847; border-color: #477da7; }.theme-dark .section-label span { color: #8ac7ff; background: #1d4d77; }