Files
Agent-System-Frontend/src/components/DocumentCockpit.tsx
T
weichen 86770e35fb Add cockpit reader view and evidence panel (I3)
Why: implement the reviewed I3 increment - continuous body reading with
per-section status lines, version summary with collapsed line-diff (backend
versions projection from RDM PR #420), right evidence panel with
sources/history/impacts/conflicts tabs, panorama-reader positioning on the
shared selection, per user+project view preferences (panorama/reader,
density, panel tab), strict-seq history merging and honest restricted states.

Evidence: 10 new pure-logic tests (prefs validation, version summary and
diff marks, strict history merge drops, reader assembly three-state); vitest
324/324 green; tsc and vite build green; tokens-only styles.

Impact: cockpit-only; no fourth column, no standalone graph (relations stay
in the right panel); reader entry is explicit (panorama button / view toggle).

Refs: rollout I3
2026-10-16 12:21:24 +00:00

352 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 文档驾驶舱 I2(rollout I2):顶部统计带 + 左侧结构树 + 中央结构全景。
// 消费 I1 只读命令面 rdm.cockpit.*(Dev 仓 docs/superpowers/specs 2026-10-16
// view-spec §1–§4;四项取舍冻结见 rollout §7——首屏只显示章节概览与统计,
// 段落/正文摘要在选中与阅读器内按权限展示,本增量不含阅读器(I3))。
// 异步所有者:generation + contextKey(项目+筛选);不匹配的旧响应直接丢弃。
// 令牌驱动(DESIGN.md):颜色/字号只消费 @ui/tokens.css;折叠用 Collapsible。
import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore } from '../store'
import { EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import {
contextKey, loadPrefs, nodeSummaryLine, sameSnapshot, savePrefs,
selectionSurvives, statDisplay, STAT_FILTERS, toggleFilter, treeBadges,
type CockpitMeta, type CockpitNodeRef, type CockpitOverview,
type CockpitTreeNode, type CockpitTreePage,
type Density, type PanelTabId, type ViewMode,
} from '../documentCockpit'
import { CockpitPanel, CockpitReader } from './CockpitReader'
interface ProjectView { id: string; name: string; canAudit?: boolean }
function CockpitErrorState({ raw }: { raw: string }) {
const c = classifyRdmError(raw)
return <EmptyState icon="alert">{c.message}</EmptyState>
}
interface Selection { ref: CockpitNodeRef; parentDocId?: string }
export function DocumentCockpit() {
const identity = useStore(() => store.getIdentityEpoch())
const [projects, setProjects] = useState<ProjectView[] | null>(null)
const [pid, setPid] = useState('')
const [filter, setFilter] = useState('')
const [overview, setOverview] = useState<CockpitOverview | null>(null)
const [treePages, setTreePages] = useState<Record<string, { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }>>({})
const [selected, setSelected] = useState<Selection | null>(null)
const [error, setError] = useState('')
const [loadErr, setLoadErr] = useState('')
const [viewMode, setViewMode] = useState<ViewMode>('panorama')
const [density, setDensity] = useState<Density>('compact')
const [panelTab, setPanelTab] = useState<PanelTabId>('sources')
const gen = useRef(0)
const reload = useCallback((projectId: string, f: string) => {
if (!projectId) return
const g = ++gen.current
const ctx = contextKey(projectId, f)
setLoadErr('')
api.rdmCommand<CockpitOverview>('rdm.cockpit.overview', { projectId })
.then(ov => {
if (g !== gen.current || ctx !== contextKey(pid, filter)) return // 旧响应:项目/筛选已切换,丢弃
setOverview(ov)
setTreePages({}) // 树页随新快照整体重建
setSelected(sel => {
const kept = selectionSurvives(sel?.ref || null, (ov.documents || []).map(d => d.ref.id), sel?.parentDocId)
return kept ? { ref: kept, parentDocId: sel?.parentDocId } : null
})
})
.catch(e => { if (g === gen.current && ctx === contextKey(pid, filter)) setLoadErr(e.message) })
}, [pid, filter])
// 项目清单(rdm.project.list:成员可见项目,canAudit 与否不影响文档读取入口)。
useEffect(() => {
let c = false
setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('')
api.rdmCommand<ProjectView[]>('rdm.project.list')
.then(list => { if (!c) setProjects(Array.isArray(list) ? list : []) })
.catch(e => { if (!c) setError(e.message) })
return () => { c = true }
}, [identity])
// 项目/筛选变化 → 重新取概览(统计与根文档来自同一快照)。
useEffect(() => {
if (!pid) return
reload(pid, filter)
}, [pid, filter, reload])
// 个人视图偏好(设计 §7.3):按 登录世代+项目 隔离,仅本地;恢复最近视图。
useEffect(() => {
if (!pid) return
const prefs = loadPrefs(`epoch-${identity}`, pid)
setViewMode(prefs.lastView)
setDensity(prefs.density)
setPanelTab(prefs.rightPanelTab)
}, [identity, pid])
const applyPrefs = useCallback((patch: { lastView?: ViewMode; density?: Density; rightPanelTab?: PanelTabId }) => {
if (!pid) return
setViewMode(v => patch.lastView ?? v)
setDensity(d => patch.density ?? d)
setPanelTab(t => patch.rightPanelTab ?? t)
const prefs = loadPrefs(`epoch-${identity}`, pid)
savePrefs(`epoch-${identity}`, pid, { ...prefs, ...patch })
}, [identity, pid])
// 展开文档:按需加载章节子页(键集分页;旧项目/旧筛选的响应丢弃;
// 加载失败只影响该分支——行内保留重试,不拖垮其它面板)。
const loadChildren = useCallback((docId: string, cursor?: string) => {
if (!pid) return
const g = gen.current
const ctx = contextKey(pid, filter)
api.rdmCommand<CockpitTreePage>('rdm.cockpit.tree', { projectId: pid, parent: docId, cursor, limit: 50, filter })
.then(page => {
if (g !== gen.current || ctx !== contextKey(pid, filter)) return
setTreePages(prev => {
const old = cursor ? prev[docId] : { items: [] as CockpitTreeNode[], hasMore: false }
const items = cursor ? [...(old?.items || []), ...page.items] : page.items
return { ...prev, [docId]: { items, nextCursor: page.nextCursor, hasMore: page.hasMore, meta: page.meta } }
})
})
.catch(() => { /* 行内重试入口承担 */ })
}, [pid, filter])
if (error) return <CockpitErrorState raw={error} />
if (!projects) return <div className="empty-note">加载中…</div>
if (projects.length === 0) return <EmptyState icon="folder">暂无可见研发项目——项目成员身份由项目负责人授予</EmptyState>
const docIds = (overview?.documents || []).map(d => d.ref.id)
const splitNotice = !!overview && Object.values(treePages).some(p => !sameSnapshot(overview.meta, p.meta))
const selectDoc = (id: string) => setSelected({ ref: { kind: 'document', id } })
const selectSection = (docId: string, sec: CockpitTreeNode) => setSelected({ ref: sec.ref, parentDocId: docId })
return (
<div className="cockpit">
<div className="cockpit-header">
<select className="ui-input cockpit-project" value={pid} onChange={e => { setPid(e.target.value); setSelected(null); setFilter('') }} aria-label="选择项目">
{projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
{filter && <button type="button" className="cockpit-filter-chip" onClick={() => setFilter('')}>
筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕
</button>}
<span className="cockpit-snap">{overview ? `快照 ${overview.meta.snapshotRevision}` : '加载中…'}</span>
<div className="cockpit-viewtoggle" role="group" aria-label="视图切换">
<button type="button" className={`cockpit-viewbtn${viewMode === 'panorama' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'panorama' })}>全景</button>
<button type="button" className={`cockpit-viewbtn${viewMode === 'reader' ? ' cockpit-viewbtn-active' : ''}`} onClick={() => applyPrefs({ lastView: 'reader' })}>阅读器</button>
<button type="button" className="cockpit-viewbtn" onClick={() => applyPrefs({ density: density === 'compact' ? 'full' : 'compact' })} title="切换密度">{density === 'compact' ? '紧凑' : '宽松'}</button>
</div>
<button type="button" className="cockpit-refresh" onClick={() => reload(pid, filter)} disabled={!pid}>刷新</button>
</div>
{splitNotice && (
<div className="cockpit-banner" role="status">统计与树快照不一致——请刷新以对齐最新快照</div>
)}
{loadErr && <div className="cockpit-banner cockpit-banner-err" role="alert">概览读取失败:{loadErr}(保留上一快照,可重试)</div>}
{overview && (
<div className="cockpit-stats" role="group" aria-label="项目统计">
<div className="cockpit-stat">
<span className="cockpit-stat-num">{statDisplay(overview.stats, 'completion').value}</span>
<span className="cockpit-stat-label">完成度{statDisplay(overview.stats, 'completion').hint ? `(${statDisplay(overview.stats, 'completion').hint})` : ''}</span>
</div>
<div className="cockpit-stat"><span className="cockpit-stat-num">{statDisplay(overview.stats, 'documents').value}</span><span className="cockpit-stat-label">文档</span></div>
{STAT_FILTERS.map(s => {
const d = statDisplay(overview.stats, s.key)
const active = filter === s.filter
return (
<button key={s.key} type="button" className={`cockpit-stat${active ? ' cockpit-stat-active' : ''}`}
onClick={() => setFilter(f => toggleFilter(f, s.filter))}
title={d.hint ? `${s.label}:${d.hint}` : `筛选:${s.label}`}>
<span className="cockpit-stat-num">{d.value}</span>
<span className="cockpit-stat-label">{s.label}{d.hint ? `(${d.hint})` : ''}</span>
</button>
)
})}
<div className="cockpit-stat cockpit-stat-unavail"><span className="cockpit-stat-num">—</span><span className="cockpit-stat-label">待确认(未纳入投影)</span></div>
<div className="cockpit-stat"><span className="cockpit-stat-num">{overview.stats.updatedAt ? new Date(overview.stats.updatedAt).toLocaleString('zh-CN') : '—'}</span><span className="cockpit-stat-label">最近更新</span></div>
</div>
)}
<div className={`cockpit-columns${selected ? ' cockpit-columns-3' : ''}`}>
<div className="cockpit-tree" role="tree" aria-label="文档结构树">
<SectionLabel>结构树</SectionLabel>
{!overview && <div className="empty-note">加载结构…</div>}
{overview && docIds.length === 0 && <div className="empty-note">暂无受控文档</div>}
{overview && overview.meta.truncated && (
<div className="cockpit-truncated">文档清单已截断({overview.meta.truncationReasons?.join(';') || '超出上限'})</div>
)}
{overview && overview.documents.map(d => (
<TreeRow key={d.ref.id} node={d} depth={0}
expanded={treePages[d.ref.id] !== undefined}
childPage={treePages[d.ref.id]}
selectedId={selected?.ref.id}
onSelectNode={n => (n.ref.kind === 'document' ? selectDoc(n.ref.id) : selectSection(d.ref.id, n))}
onExpand={() => { if (treePages[d.ref.id] === undefined) loadChildren(d.ref.id) }}
onLoadMore={() => { const p = treePages[d.ref.id]; if (p?.nextCursor) loadChildren(d.ref.id, p.nextCursor) }}
onRetryChild={() => { const p = treePages[d.ref.id]; loadChildren(d.ref.id, p?.nextCursor) }}
/>
))}
</div>
<div className="cockpit-center">
{!overview && <div className="empty-note">加载概览…</div>}
{overview && !selected && (
<PanoramaHome overview={overview} onSelect={selectDoc} />
)}
{overview && selected && viewMode === 'panorama' && (
<PanoramaNode pid={pid} selection={selected} overview={overview}
childPage={selected.ref.kind === 'document' ? treePages[selected.ref.id] : undefined}
onClear={() => setSelected(null)}
onOpenReader={() => applyPrefs({ lastView: 'reader' })} />
)}
{overview && selected && viewMode === 'reader' && (
<CockpitReader pid={pid} selection={selected}
sections={selected.ref.kind === 'document' ? (treePages[selected.ref.id]?.items || []) : []}
density={density} onBack={() => applyPrefs({ lastView: 'panorama' })} />
)}
</div>
{overview && selected && (
<div className="cockpit-right">
<CockpitPanel pid={pid} selection={selected} tab={panelTab} onTab={t => applyPrefs({ rightPanelTab: t })} density={density} />
</div>
)}
</div>
</div>
)
}
function TreeRow({ node, depth, expanded, childPage, selectedId, onSelectNode, onExpand, onLoadMore, onRetryChild }: {
node: CockpitTreeNode; depth: number
expanded: boolean
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
selectedId?: string
onSelectNode: (n: CockpitTreeNode) => void
onExpand: () => void
onLoadMore: () => void
onRetryChild: () => void
}) {
const badges = treeBadges(node)
const selected = selectedId === node.ref.id
return (
<div className="cockpit-tree-branch">
<div className={`cockpit-tree-row${selected ? ' cockpit-tree-row-active' : ''}`} style={{ paddingLeft: depth * 14 }} role="treeitem" aria-selected={selected}>
{node.ref.kind === 'document' && (
<button type="button" className="cockpit-tree-caret" aria-label={expanded ? '折叠' : '展开'}
aria-expanded={expanded} onClick={e => { e.stopPropagation(); if (!expanded) onExpand() }}>
{expanded ? '▾' : '▸'}
</button>
)}
<button type="button" className="cockpit-tree-title" onClick={() => onSelectNode(node)}>{node.title}</button>
{badges.map(b => <span key={b} className="cockpit-badge">{b}</span>)}
{node.lastChangedAt && <span className="cockpit-tree-time">{new Date(node.lastChangedAt).toLocaleDateString('zh-CN')}</span>}
</div>
{expanded && !childPage && (
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onRetryChild}>章节加载失败,重试</button>
)}
{expanded && childPage && (
<div>
{childPage.items.length === 0 && <div className="cockpit-tree-empty" style={{ paddingLeft: (depth + 1) * 14 }}>无章节</div>}
{childPage.items.map(sec => (
<TreeRow key={sec.ref.id} node={sec} depth={depth + 1} expanded={false} childPage={undefined}
selectedId={selectedId} onSelectNode={onSelectNode}
onExpand={onExpand} onLoadMore={onLoadMore} onRetryChild={onRetryChild} />
))}
{childPage.hasMore && childPage.items.length > 0 && (
<button type="button" className="cockpit-tree-more" style={{ paddingLeft: (depth + 1) * 14 }} onClick={onLoadMore}>加载更多章节</button>
)}
</div>
)}
</div>
)
}
function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSelect: (id: string) => void }) {
return (
<div>
<SectionLabel>{overview.project.title} · 文档概览</SectionLabel>
{overview.documents.length === 0 && <EmptyState icon="folder">暂无受控文档——文档由模板实例化或导入后进入驾驶舱</EmptyState>}
<div className="cockpit-doclist">
{overview.documents.map(d => (
<button key={d.ref.id} type="button" className="cockpit-docrow" onClick={() => onSelect(d.ref.id)}>
<span className="cockpit-docrow-title">{d.title}</span>
<span className="cockpit-docrow-meta">{nodeSummaryLine(d)}</span>
</button>
))}
</div>
<SectionLabel>最近变化</SectionLabel>
{overview.recentChanges.length === 0 && <div className="empty-note">暂无可见变化</div>}
<div className="cockpit-changes">
{overview.recentChanges.map((c, i) => (
<div key={`${c.node.id}-${i}`} className="cockpit-changerow">
<span className="cockpit-changesum">{c.summary} · {c.title}{c.actor ? ` · ${c.actor}` : ''}</span>
<span className="cockpit-changetime">{new Date(c.changedAt).toLocaleString('zh-CN')}</span>
</div>
))}
</div>
</div>
)
}
function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenReader }: {
pid: string; selection: Selection; overview: CockpitOverview
childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta }
onClear: () => void
onOpenReader: () => void
}) {
const [detail, setDetail] = useState<any>(null)
const [err, setErr] = useState('')
const gen = useRef(0)
useEffect(() => {
const g = ++gen.current
setDetail(null); setErr('')
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id })
.then(d => { if (g === gen.current) setDetail(d) })
.catch(e => { if (g === gen.current) setErr(e.message) })
return () => { gen.current++ }
}, [pid, selection.ref.kind, selection.ref.id])
const node = (detail?.node || null) as CockpitTreeNode | null
const title = node?.title || selection.ref.id
return (
<div>
<div className="cockpit-nodehead">
<button type="button" className="cockpit-back" onClick={onClear}>← 返回概览</button>
<span className="cockpit-nodetitle">{title}</span>
{node && <span className="cockpit-nodesum">{nodeSummaryLine(node)}</span>}
<button type="button" className="cockpit-filter-chip" onClick={onOpenReader}>在阅读器中打开</button>
</div>
{err && <CockpitErrorState raw={err} />}
{detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && (
<div className="empty-note">内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
)}
{detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && (
<div className="cockpit-truncated">节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐</div>
)}
{node && node.ref.kind === 'document' && (
<>
<SectionLabel>章节状态</SectionLabel>
{!childPage && <div className="empty-note">章节按需加载——在左侧树展开</div>}
{childPage && childPage.items.length === 0 && <div className="empty-note">无章节</div>}
{childPage && childPage.items.length > 0 && (
<table className="cockpit-childtable">
<thead><tr><th>章节</th><th>内容</th><th>核实</th><th>来源</th><th>冲突</th><th>最近更新</th></tr></thead>
<tbody>
{childPage.items.map(sec => (
<tr key={sec.ref.id}>
<td>{sec.title}</td>
<td>{{ complete: '完整', partial: '部分', empty: '空', inaccessible: '受限', unavailable: '不可读' }[sec.state.content] || sec.state.content}</td>
<td>{sec.state.verification === 'not_applicable' ? '—' : sec.state.verification}</td>
<td>{sec.state.sourceCount}</td>
<td>{sec.state.conflictCount}</td>
<td>{sec.lastChangedAt ? new Date(sec.lastChangedAt).toLocaleDateString('zh-CN') : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
{node && node.ref.kind === 'section' && (
<div className="empty-note">点「在阅读器中打开」查看连续正文与版本差异;右侧为该节点的依据与影响面板</div>
)}
{detail?.meta?.truncated && (
<div className="cockpit-truncated">详情已截断:{detail.meta.truncationReasons?.join(';')}</div>
)}
</div>
)
}