diff --git a/apps/desktop/design-qa.md b/apps/desktop/design-qa.md index e795300..cecd8ee 100644 --- a/apps/desktop/design-qa.md +++ b/apps/desktop/design-qa.md @@ -4,41 +4,43 @@ - Source visual truth: `C:\Users\david\AppData\Local\Temp\codex-clipboard-72c4b101-58e8-44b0-81b4-01abc35a29e6.png` - Implementation screenshot: `test-results/app-home.png` -- Full-view viewport: 420 × 820 CSS px at device scale factor 1; implementation image is 420 × 820 px. -- Source image: 144 × 343 px. It shows only a narrow icon rail, whereas the implementation contains the requested complete desktop workbench. -- State: logged-in light theme, default “全部” project scope, one pending task and one completed task. -- Interaction checks: default task board, task edit modal,资料 panel, AI panel, and profile dropdown. Console errors: none. +- 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. +- 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 -- Full implementation: `test-results/app-home.png` -- Focused icon-rail comparison: `test-results/design-qa-rail-comparison.png` - - Left: the source’s 75 × 343 px rail crop. - - Right: the implementation’s 58 × 820 px right rail, normalized to 75 × 343 px. +- Full current implementation: `test-results/app-home.png` +- Earlier focused icon-rail comparison: `test-results/design-qa-rail-comparison.png` -The focused comparison is intentionally rail-only because the source does not provide a full workbench layout. The implementation places the rail on the right as requested, rather than copying the source’s left placement. +The source rail is used only for the compact icon treatment. The latest requested behavior intentionally hides that rail in the all-projects dashboard and displays it only after selecting a specific project. ## Findings No actionable P0, P1, or P2 findings. -- Fonts and typography: the application uses the existing Inter / Microsoft YaHei stack. Small rail labels remain legible at the 420 px target width and task titles retain stronger hierarchy. -- Spacing and layout rhythm: the 48 px header, 46 px project strip, scrollable content column, and 58 px rail leave the task content unobscured. No horizontal overflow was observed. -- Colors and visual tokens: neutral rail, blue selection, green completion, and muted metadata provide equivalent compact navigation contrast to the source while preserving the product’s existing Arco blue token. -- Image and icon fidelity: the existing product icon is retained; all navigation controls use the installed Arco icon system. No placeholder or hand-drawn visual assets were introduced. -- Copy and content: project scope, task state, profile menu, and submenus use the requested Chinese product language. +- Fonts and typography: the existing Inter / Microsoft YaHei stack preserves clear hierarchy across compact project tabs, four statistics, task cards, and latest-file metadata. +- 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 收集箱. + +## 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. ## Follow-up polish -- [P3] The source has four tall unlabeled icon slots; the implementation has six compact labeled slots because it must expose the requested project views. If the list grows further, group secondary views under “更多”. +- [P3] If project count becomes large, the compact project picker can add a search field; the current list is appropriate for the MVP project volume. ## Implementation checklist -- [x] Align desktop API fields and request payloads with `web_v1`. -- [x] Add theme toggle and user dropdown with profile editing and logout. -- [x] Add project scope strip with 全部, previous/next, and create controls. -- [x] Move project submenu navigation to the right rail. -- [x] Make pending and completed tasks the default editable content. -- [x] Verify primary panels and interaction states. +- [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] Hide right navigation for all-projects and explore views. +- [x] Remove 收集箱 and rename 周期计划 to 计划任务. +- [x] Verify key interaction paths and responsive composition. final result: passed diff --git a/apps/desktop/scripts/visual-check.mjs b/apps/desktop/scripts/visual-check.mjs index 249ce82..fdb7432 100644 --- a/apps/desktop/scripts/visual-check.mjs +++ b/apps/desktop/scripts/visual-check.mjs @@ -42,14 +42,20 @@ await page.waitForSelector('.task-row') await page.screenshot({ path: 'test-results/app-home.png', fullPage: true }) const home = await page.evaluate(() => ({ width: document.querySelector('.app-shell')?.getBoundingClientRect().width, - selectedAll: document.querySelector('.project-pill.active span')?.textContent, + selectedAll: document.querySelector('.project-current span')?.textContent, navCount: document.querySelectorAll('.channel-rail button').length, + statCount: document.querySelectorAll('.stat-card').length, pending: document.querySelectorAll('.task-row:not(.done)').length, completed: document.querySelectorAll('.task-row.done').length, })) await page.locator('.task-row').first().click() const taskModal = await page.getByText('编辑任务').count() await page.getByRole('button', { name: '取消' }).click() +await page.getByRole('button', { name: '探索' }).click() +const exploreProjects = await page.locator('.project-explore-card').count() +await page.locator('.project-current').click() +await page.getByRole('option', { name: '森林项目' }).click() +await page.waitForSelector('.channel-rail') await page.locator('.channel-rail button[title="资料"]').click() const documents = await page.locator('.detail-row').count() await page.locator('.channel-rail button[title="AI"]').click() @@ -63,13 +69,15 @@ await browser.close(); await server.close() const failures = [] 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 !== 6) failures.push(`expected six right rail menus, got ${home.navCount}`) +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 (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}`) if (experts !== 1) failures.push(`expected one AI expert, got ${experts}`) if (profileMenu !== 1) failures.push('profile menu did not open') if (errors.length) failures.push(`console errors: ${errors.join('; ')}`) -console.log(JSON.stringify({ home, documents, experts, taskModal, profileMenu, errors }, null, 2)) +console.log(JSON.stringify({ home, documents, experts, taskModal, exploreProjects, profileMenu, errors }, null, 2)) if (failures.length) throw new Error(failures.join('\n')) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index aba0f5d..a85b048 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,29 +1,30 @@ -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 { 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 { - IconApps, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconDown, IconEmail, IconFile, + IconApps, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconCompass, IconDown, IconFile, IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRight, IconRobot, IconSun, IconUser, } from '@arco-design/web-react/icon' 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' const { Text, Title } = Typography const SESSION_KEY = 'senlin-app-session' -type View = 'overview' | 'tasks' | 'inbox' | 'documents' | 'ai' | 'cron' +type View = 'overview' | 'tasks' | 'documents' | 'ai' | 'cron' +type WorkspaceMode = 'workbench' | 'explore' type SavedSession = ApiSession & { user?: CurrentUser } type TaskWithProject = WorkspaceTask & { projectName: string } const navItems: Array<{ id: View; label: string; icon: React.ReactNode }> = [ { id: 'overview', label: '概况', icon: }, { id: 'tasks', label: '计划', icon: }, - { id: 'inbox', label: '收集箱', icon: }, { id: 'documents', label: '资料', icon: }, { id: 'ai', label: 'AI', icon: }, - { id: 'cron', label: '周期计划', icon: }, + { id: 'cron', label: '计划任务', icon: }, ] export function App() { @@ -33,6 +34,7 @@ export function App() { const [projects, setProjects] = useState([]) const [workspaces, setWorkspaces] = useState>({}) const [projectId, setProjectId] = useState('') + const [workspaceMode, setWorkspaceMode] = useState('workbench') const [view, setView] = useState('overview') const [dark, setDark] = useState(false) const [loading, setLoading] = useState(Boolean(stored)) @@ -90,7 +92,7 @@ export function App() { if (!session) return { setSession(next); setUser(next.user); persistSession(next) }} /> return ( -
+
森林AI
@@ -100,21 +102,23 @@ export function App() {
- -
- {projects.map((project) => )} -
+ + +
{error ? setError('')} /> : null} -
{visibleWorkspaces.length ? : }
+
{workspaceMode === 'explore' ? { setProjectId(id); setWorkspaceMode('workbench') }} /> : !projectId ? setProjectModalOpen(true)} onUpload={async (project, file) => { await uploadDocument(session, project, file); await loadWorkspaces() }} /> : visibleWorkspaces.length ? : }
- : null} workspace.tags.map((tag) => tag.name))} onClose={() => setTaskEditor(null)} onSave={async (draft) => { if (!taskEditor) return @@ -140,7 +144,6 @@ export function App() { function Content({ view, title, workspaces, tasks, onEditTask, onLoadAI, experts }: { view: View; title: string; workspaces: Workspace[]; tasks: TaskWithProject[]; onEditTask: (task: TaskWithProject) => void; onLoadAI: () => void; experts: Expert[] }) { if (view === 'tasks' || view === 'overview') return - if (view === 'inbox') return if (view === 'documents') return if (view === 'ai') return return @@ -164,9 +167,23 @@ function TaskRow({ task, onClick }: { task: TaskWithProject; onClick: () => void return } -function InboxPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) { - const items = workspaces.flatMap((workspace) => workspace.inbox.map((item) => ({ ...item, projectName: workspace.project.name }))) - return {items.map((item) =>
{item.title}

{item.summary || item.source}

{item.projectName}
)}{!items.length && }
+ +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 }) { + const fileInput = useRef(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]) + 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
工作台全部项目
{statItems.map((item) =>
{item.value}{item.label}
)}
{ 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) } }} />
最新文件{latestFiles.length}
{latestFiles.map((file) =>
{file.name}

{file.extension || file.mimeType || file.kind} · {formatDate(file.updatedAt)}

{file.projectName}
)}{!latestFiles.length && }
+} + +function ExplorePanel({ projects, onOpen }: { projects: Project[]; onOpen: (id: string) => void }) { + return
探索项目空间
{projects.length} 个项目
{projects.map((project) => )}{!projects.length && }
} function DocumentPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) { @@ -182,7 +199,7 @@ function AIPanel({ title, workspaces, experts, onLoad }: { title: string; worksp function CronPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) { const plans = workspaces.flatMap((workspace) => workspace.cronPlans.map((item) => ({ ...item, projectName: workspace.project.name }))) - return {plans.map((item) =>
{item.title}

{item.schedule} · {item.enabled ? '已启用' : '已停用'}

{item.projectName}
)}{!plans.length && }
+ return {plans.map((item) =>
{item.title}

{item.schedule} · {item.enabled ? '已启用' : '已停用'}

{item.projectName}
)}{!plans.length && }
} function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { return
{subtitle}{title}
{children}
} diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts index 767209f..1e1188e 100644 --- a/apps/desktop/src/api.ts +++ b/apps/desktop/src/api.ts @@ -111,6 +111,14 @@ export function updateTask(session: ApiSession, projectId: string, taskId: strin return apiRequest(`/api/v1/projects/${projectId}/tasks/${taskId}`, { method: 'PATCH', token: session.token, body: input }) } +export function uploadDocument(session: ApiSession, projectId: string, file: File, parentId = '') { + useSessionBase(session) + const body = new FormData() + body.append('file', file) + if (parentId) body.append('parentId', parentId) + return apiRequest(`/api/v1/projects/${projectId}/documents/uploads`, { method: 'POST', token: session.token, body }) +} + export function listAIExperts(session: ApiSession) { useSessionBase(session) return apiRequest('/api/v1/ai-experts', { token: session.token }) diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index 78a4d63..e4def1f 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -20,9 +20,11 @@ button { font: inherit; } .project-pill:hover, .project-pill.active { color: #165dff; background: #e8f3ff; border-color: #d3e7ff; } .project-pill i { display: grid; place-items: center; width: 19px; height: 19px; border-radius: 6px; color: #fff; font-style: normal; font-size: 9px; font-weight: 700; } .project-pill span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 600; } +.project-select { min-width: 0; flex: 1; }.project-select .arco-select-view { border: 0; padding: 0; background: transparent; }.project-select .arco-select-view-value { display: none; }.project-current { width: 100%; max-width: none; justify-content: flex-start; }.project-current > svg { margin-left: auto; color: var(--muted); } .app-alert { grid-column: 1 / 2; z-index: 2; margin: 8px 12px -46px; align-self: start; } .app-main { grid-column: 1; grid-row: 3; min-width: 0; overflow: auto; padding: 18px 15px 24px; } +.app-shell.no-rail .app-main { grid-column: 1 / -1; } .app-main > .arco-spin { min-height: 100%; } .content-page { max-width: 650px; margin: 0 auto; } .content-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; margin-bottom: 14px; } @@ -63,8 +65,10 @@ button { font: inherit; } .detail-row strong { font-size: 13px; }.detail-row p { margin-top: 3px; color: var(--muted); font-size: 11px; } .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; } .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; } -@media (max-width: 360px) { .profile-trigger > span { display: none; }.project-pill { max-width: 90px; }.app-main { padding: 13px 9px 20px; }.task-meta .arco-tag { max-width: 64px; overflow: hidden; }.content-heading h4 { font-size: 18px; } } +@media (max-width: 360px) { .profile-trigger > span { display: none; }.project-pill { max-width: 90px; }.project-strip { gap: 2px; padding-inline: 5px; }.app-main { padding: 13px 9px 20px; }.task-meta .arco-tag { max-width: 64px; overflow: hidden; }.content-heading h4 { font-size: 18px; }.stat-grid { grid-template-columns: repeat(2, 1fr); } }