feat(desktop): align explore view with web reader

This commit is contained in:
2026-07-25 22:59:35 +08:00
parent ac674d71e7
commit 4ffd51a831
5 changed files with 60 additions and 11 deletions

View File

@@ -7,11 +7,12 @@
- 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 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.
- Primary interactions tested: default all-projects homepage, task editor modal, dataset-backed explore view, project picker, project-only right navigation, document panel, AI panel, and profile dropdown. Console errors: none.
## Evidence
- Full current implementation: `test-results/app-home.png`
- Explore reading flow: `test-results/app-explore.png`
- Earlier focused icon-rail comparison: `test-results/design-qa-rail-comparison.png`
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.
@@ -25,10 +26,12 @@ No actionable P0, P1, or P2 findings.
- 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; its homepage shows only pending tasks and latest files. The project-only menu contains 概况、计划、资料、AI、计划任务 and no longer exposes 收集箱.
- Explore parity: the desktop view now uses the `web_v1` dataset endpoints and mirrors its core source-selection, item-list, and detail-reading workflow in a narrow reading layout. With 全部项目 selected, the “沉淀到项目” action is intentionally disabled until a concrete project is selected.
## Comparison history
- 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.
- Explore pass: replaced the project-card placeholder with the dataset source and item reader. Captured `test-results/app-explore.png`; two sources and the selected article detail render without console errors.
## Follow-up polish
@@ -41,6 +44,7 @@ No actionable P0, P1, or P2 findings.
- [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] Align top-level 探索 with the `web_v1` dataset reading flow.
- [x] Verify key interaction paths and responsive composition.
final result: passed

View File

@@ -23,6 +23,8 @@ const workspace = {
cronPlans: [{ id: 'cron-1', projectId, title: '每周复盘', schedule: '0 17 * * 5', nextRun: null, enabled: true, lastResult: '', owner: '张明' }],
}
const expert = { id: 'expert-1', slug: 'product-manager', category: 'product', categoryName: '产品', name: '产品经理', description: '负责需求分析', emoji: '📘', color: '#165DFF' }
const datasetSource = { id: 'source-1', name: '行业资讯', kind: 'rss', url: 'https://example.com/feed', iconUrl: '', description: '行业动态', enabled: true, builtIn: false, lastSyncedAt: null, itemCount: 1, createdAt: '2026-07-22T10:00:00Z', updatedAt: '2026-07-22T10:00:00Z' }
const datasetItem = { id: 'item-1', sourceId: 'source-1', title: 'AI 工作台趋势', summary: '新的协作方式正在改变项目管理。', content: '这是来自数据源的探索内容,可在 Web 工作台继续管理数据源和同步任务。', url: '', imageUrl: '', status: 'unread', starred: false, publishedAt: '2026-07-22T10:00:00Z', createdAt: '2026-07-22T10:00:00Z', updatedAt: '2026-07-22T10:00:00Z' }
await page.route('http://localhost:9150/api/v1/**', async (route) => {
const request = route.request(); const path = new URL(request.url()).pathname
@@ -31,6 +33,8 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
if (path === '/api/v1/projects') return route.fulfill({ json: [workspace.project] })
if (path === `/api/v1/projects/${projectId}/workspace`) return route.fulfill({ json: workspace })
if (path === '/api/v1/ai-experts') return route.fulfill({ json: [expert] })
if (path === '/api/v1/dataset-sources') return route.fulfill({ json: [datasetSource] })
if (path === '/api/v1/dataset-items') return route.fulfill({ json: { items: [datasetItem], total: 1, limit: 50, offset: 0 } })
if (path.includes('/tasks/') && request.method() === 'PATCH') return route.fulfill({ json: {} })
return route.fulfill({ status: 404, json: { error: { code: 'not_found', message: '未配置的检查接口' } } })
})
@@ -52,7 +56,10 @@ 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.waitForSelector('.explore-detail')
const exploreSources = await page.locator('.explore-source-strip button').count()
const exploreDetail = await page.locator('.explore-detail').count()
await page.screenshot({ path: 'test-results/app-explore.png', fullPage: true })
await page.locator('.project-current').click()
await page.getByRole('option', { name: '森林项目' }).click()
await page.waitForSelector('.channel-rail')
@@ -74,10 +81,10 @@ if (home.navCount !== 0) failures.push(`all-projects view must hide the right ra
if (home.statCount !== 4) failures.push(`expected four summary cards, got ${home.statCount}`)
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 (exploreSources !== 2 || exploreDetail !== 1) failures.push(`explore reader did not render: ${JSON.stringify({ exploreSources, exploreDetail })}`)
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, exploreProjects, profileMenu, errors }, null, 2))
console.log(JSON.stringify({ home, documents, experts, taskModal, exploreSources, exploreDetail, profileMenu, errors }, null, 2))
if (failures.length) throw new Error(failures.join('\n'))

View File

@@ -1,14 +1,14 @@
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,
IconApps, IconBook, IconCheckCircle, IconCheckCircleFill, IconClockCircle, IconCompass, IconDown, IconFile,
IconFolder, IconLeft, IconList, IconMoon, IconPlus, IconRefresh, IconRight, IconRobot,
IconSun, IconUser,
} from '@arco-design/web-react/icon'
import {
ApiError, createProject, fetchProjectWorkspace, fetchProjects, getCurrentUser,
listAIExperts, login, setApiBaseUrl, updateCurrentUser, updateTask,
type ApiSession, type CurrentUser, type Expert, type Project, type Workspace, type WorkspaceTask,
listAIExperts, listDatasetItems, listDatasetSources, login, setApiBaseUrl, updateCurrentUser, updateTask,
type ApiSession, type CurrentUser, type DatasetItem, type DatasetSource, type Expert, type Project, type Workspace, type WorkspaceTask,
} from './api'
const { Text, Title } = Typography
@@ -113,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)} /> : 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 session={session} projectName={activeProject?.name ?? ''} /> : !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>)}
@@ -177,8 +177,30 @@ function AllProjectsHome({ projects, workspaces, tasks, onEditTask, onCreateProj
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 }) {
return <section className="content-page explore-page"><div className="content-heading"><div><Text type="secondary"></Text><Title heading={4}></Title></div><Tag color="arcoblue">{projects.length} </Tag></div><div className="project-card-grid">{projects.map((project) => <button key={project.id} className="project-explore-card" onClick={() => onOpen(project.id)}><i style={{ background: project.background || '#165DFF' }}>{projectLabel(project)}</i><span><strong>{project.name}</strong><small>{project.description || project.identifier}</small></span><IconRight /></button>)}{!projects.length && <Empty description="还没有项目" />}</div></section>
function ExplorePanel({ session, projectName }: { session: ApiSession; projectName: string }) {
const [sources, setSources] = useState<DatasetSource[]>([])
const [items, setItems] = useState<DatasetItem[]>([])
const [sourceId, setSourceId] = useState('all')
const [selectedId, setSelectedId] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => { void refreshSources() }, [session])
useEffect(() => { void refreshItems() }, [session, sourceId])
async function refreshSources() {
setLoading(true); setError('')
try { setSources(await listDatasetSources(session)) } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) }
}
async function refreshItems() {
setLoading(true); setError('')
try { const page = await listDatasetItems(session, sourceId === 'all' ? undefined : sourceId); setItems(page.items); setSelectedId((current) => page.items.some((item) => item.id === current) ? current : page.items[0]?.id ?? '') } catch (requestError) { setError(errorMessage(requestError)) } finally { setLoading(false) }
}
const allCount = sources.reduce((total, source) => total + source.itemCount, 0)
const selected = items.find((item) => item.id === selectedId) ?? items[0]
const selectedSource = sources.find((source) => source.id === selected?.sourceId)
return <section className="content-page explore-page"><div className="content-heading"><div><Title heading={4}></Title><Text type="secondary"></Text></div><Button type="text" shape="circle" aria-label="刷新探索内容" loading={loading} icon={<IconRefresh />} onClick={() => { void refreshSources(); void refreshItems() }} /></div>{error ? <Alert type="error" content={error} /> : null}<div className="explore-source-strip"><button className={sourceId === 'all' ? 'active' : ''} onClick={() => setSourceId('all')}><IconCompass /><span></span><small>{allCount}</small></button>{sources.map((source) => <button className={sourceId === source.id ? 'active' : ''} key={source.id} onClick={() => setSourceId(source.id)}>{source.iconUrl ? <img src={source.iconUrl} alt="" /> : <IconBook />}<span>{source.name}</span><small>{source.itemCount}</small></button>)}</div><div className="explore-reader">{items.length ? <><div className="explore-item-list">{items.map((item) => <button className={item.id === selected?.id ? 'active' : ''} key={item.id} onClick={() => setSelectedId(item.id)}><span><b>{item.title}</b><small>{item.summary || '暂无摘要'}</small></span><i>{item.starred ? '★' : formatDate(item.publishedAt || item.createdAt)}</i></button>)}</div><article className="explore-detail"><div className="explore-detail-meta"><Tag color="arcoblue">{selectedSource?.name || '全部'}</Tag><small>{formatDate(selected?.publishedAt || selected?.createdAt || '')}</small></div><Title heading={5}>{selected?.title}</Title>{selected?.imageUrl ? <img src={selected.imageUrl} alt="" /> : null}<p>{selected?.content || selected?.summary || '暂无正文'}</p><div className="explore-detail-actions"><Button type="text" icon={<IconBook />} disabled={!projectName}>{projectName || '项目'}</Button>{selected?.url ? <Button type="text" href={selected.url} target="_blank"></Button> : null}</div></article></> : <Empty description="暂无探索内容,请先在 Web 工作台添加或同步数据源" />}</div></section>
}
function DocumentPanel({ title, workspaces }: { title: string; workspaces: Workspace[] }) {

View File

@@ -57,6 +57,9 @@ export type Expert = { id: string; slug: string; category: string; categoryName:
export type AISession = { id: string; projectId: string; title: string; context: string; status: string; expert: Expert | null; createdAt: string; updatedAt: string }
export type CreateTaskInput = { title: string; description?: string; status?: string; dueAt?: string; tag?: string }
export type UpdateTaskInput = { title: string; description?: string; status?: string; completed: boolean; nextProjectId?: string; tag?: string }
export type DatasetSource = { id: string; name: string; kind: 'manual' | 'link' | 'rss'; url: string; iconUrl: string; description: string; enabled: boolean; builtIn: boolean; lastSyncedAt: string | null; itemCount: number; createdAt: string; updatedAt: string }
export type DatasetItem = { id: string; sourceId: string; title: string; summary: string; content: string; url: string; imageUrl: string; status: 'unread' | 'read' | 'archived'; starred: boolean; publishedAt: string | null; createdAt: string; updatedAt: string }
export type DatasetItemPage = { items: DatasetItem[]; total: number; limit: number; offset: number }
export class ApiError extends Error {
constructor(public status: number, public code: string, message: string) {
@@ -134,6 +137,18 @@ export function createAISession(session: ApiSession, projectId: string, input: {
return apiRequest<AISession>(`/api/v1/projects/${projectId}/ai-sessions`, { method: 'POST', token: session.token, body: input })
}
export function listDatasetSources(session: ApiSession) {
useSessionBase(session)
return apiRequest<DatasetSource[]>('/api/v1/dataset-sources', { token: session.token })
}
export function listDatasetItems(session: ApiSession, sourceId?: string, offset = 0, limit = 50) {
useSessionBase(session)
const params = new URLSearchParams({ offset: String(offset), limit: String(limit) })
if (sourceId) params.set('sourceId', sourceId)
return apiRequest<DatasetItemPage>(`/api/v1/dataset-items?${params}`, { token: session.token })
}
export async function checkServerConnection(server: string, signal?: AbortSignal) {
try {
const response = await fetch(`${normalizeBaseUrl(server)}/api/v1/status`, { signal })

View File

@@ -67,6 +67,7 @@ button { font: inherit; }
.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: 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; }
.explore-source-strip { display: flex; gap: 7px; margin-bottom: 12px; overflow-x: auto; padding-bottom: 2px; }.explore-source-strip button { display: grid; grid-template-columns: 18px auto; grid-template-rows: auto auto; column-gap: 5px; align-items: center; min-width: 82px; padding: 8px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); background: var(--surface); text-align: left; cursor: pointer; }.explore-source-strip button.active { border-color: #9bc7ff; background: #edf6ff; color: #165dff; }.explore-source-strip svg, .explore-source-strip img { grid-row: 1 / 3; width: 18px; height: 18px; border-radius: 5px; object-fit: cover; }.explore-source-strip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; font-weight: 600; }.explore-source-strip small { color: var(--muted); font-size: 10px; }.explore-reader { display: grid; gap: 10px; }.explore-item-list { display: grid; gap: 6px; }.explore-item-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; width: 100%; padding: 10px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); background: var(--surface); text-align: left; cursor: pointer; }.explore-item-list button.active { border-color: #9bc7ff; background: #f0f7ff; }.explore-item-list span { display: grid; min-width: 0; gap: 4px; }.explore-item-list b, .explore-item-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.explore-item-list b { font-size: 13px; }.explore-item-list small { color: var(--muted); font-size: 11px; }.explore-item-list i { align-self: center; color: #86909c; font-style: normal; font-size: 10px; }.explore-detail { padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); }.explore-detail .arco-typography { margin-top: 9px; }.explore-detail-meta { display: flex; align-items: center; justify-content: space-between; }.explore-detail-meta small { color: var(--muted); font-size: 11px; }.explore-detail > img { width: 100%; max-height: 180px; margin: 4px 0 10px; border-radius: 9px; object-fit: cover; }.explore-detail p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.7; white-space: pre-wrap; }.explore-detail-actions { display: flex; gap: 4px; margin-top: 8px; }
.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; }