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
352 lines
19 KiB
TypeScript
352 lines
19 KiB
TypeScript
// 文档驾驶舱 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>
|
||
)
|
||
}
|