Why: implement the reviewed I2 increment - statistics band, structure tree with lazy section pages, panorama center (project overview + node summary), shared selection/filter, snapshot-consistency banner and honest states, per the frozen contract (Dev docs/superpowers 2026-10-16, rollout section 7). Evidence: vitest 314/314 green (14 new pure-logic tests covering capability gate, true-zero vs unavailable stats, filter toggle, snapshot equality and update notice, context key, selection survival, honest badges); tsc -b and vite production build green. Impact: new workspace entry (rdm-cockpit, gated on the seven cockpit commands); no changes to existing surfaces; reader view and right panel land in I3. Refs: rollout I2
171 lines
6.6 KiB
TypeScript
171 lines
6.6 KiB
TypeScript
// 文档驾驶舱 I2 纯逻辑层(rollout I2;契约:Dev 仓 docs/superpowers/specs
|
||
// 2026-10-16-document-visualization-read-model-contract.md @ 37b272ba,
|
||
// 四项取舍冻结见 plans/…-rollout.md §7)。只放可测判定:筛选切换、统计呈现
|
||
//(真 0 vs 不可算)、快照一致性与更新提示、上下文键与选择保留。组件只做取数编排与渲染。
|
||
|
||
// ---- 契约类型(I1 后端 DTO 的前端精简形态) ----
|
||
|
||
export interface CockpitNodeRef { kind: string; id: string; version?: string }
|
||
|
||
export interface CockpitMeta {
|
||
schemaVersion: string
|
||
projectId: string
|
||
snapshotRevision: string
|
||
asOfSeq: number
|
||
generatedAt: string
|
||
truncated: boolean
|
||
truncationReasons?: string[]
|
||
}
|
||
|
||
export interface CockpitNodeState {
|
||
content: string // complete|partial|empty|inaccessible|unavailable
|
||
verification: string // verified|mixed|unverified|disputed|not_applicable
|
||
sourceCount: number
|
||
conflictCount: number
|
||
impactCount: number
|
||
missingChildCount: number
|
||
structuralGap: boolean
|
||
}
|
||
|
||
export interface CockpitTreeNode {
|
||
ref: CockpitNodeRef
|
||
title: string
|
||
path: string[]
|
||
parent?: CockpitNodeRef
|
||
childCount: number
|
||
state: CockpitNodeState
|
||
lastChangedAt?: string
|
||
lastChangedBy?: string
|
||
visibility: string // full|redacted|metadata_only
|
||
}
|
||
|
||
export interface CockpitStats {
|
||
documentCount: number
|
||
completion: { known: number; total: number; state: string }
|
||
emptyNodeCount: number
|
||
unverifiedSourceCount: number
|
||
conflictCount: number
|
||
pendingChangeCount: number | null
|
||
updatedAt?: string
|
||
statReasons: Record<string, string>
|
||
}
|
||
|
||
export interface CockpitChangeSummary {
|
||
node: CockpitNodeRef
|
||
title: string
|
||
changedAt: string
|
||
actor?: string
|
||
kind: string
|
||
summary: string
|
||
version?: string
|
||
}
|
||
|
||
export interface CockpitOverview {
|
||
meta: CockpitMeta
|
||
project: { title: string }
|
||
stats: CockpitStats
|
||
documents: CockpitTreeNode[]
|
||
recentChanges: CockpitChangeSummary[]
|
||
}
|
||
|
||
export interface CockpitTreePage {
|
||
meta: CockpitMeta
|
||
items: CockpitTreeNode[]
|
||
nextCursor?: string
|
||
hasMore: boolean
|
||
}
|
||
|
||
// ---- 统计带:点击即切换共享筛选(视图规格 §2;未知筛选由后端 400 拒绝) ----
|
||
|
||
export const STAT_FILTERS: { key: string; label: string; filter: string }[] = [
|
||
{ key: 'empty', label: '空缺节点', filter: 'state:empty' },
|
||
{ key: 'unverified', label: '未核实来源', filter: 'state:unverified' },
|
||
{ key: 'conflict', label: '冲突', filter: 'state:conflict' },
|
||
]
|
||
|
||
/** 点击统计卡:同筛选再次点击=清除(只定位,不执行写入)。 */
|
||
export function toggleFilter(current: string, filter: string): string {
|
||
return current === filter ? '' : filter
|
||
}
|
||
|
||
/** 统计呈现:null(不可算)显示 — 加原因;真 0 显示 0。两侧都不得互相冒充(契约 §2/§6)。 */
|
||
export function statDisplay(stats: CockpitStats, key: string): { value: string; hint?: string } {
|
||
const reasons = stats.statReasons || {}
|
||
if (key === 'documents') return { value: String(stats.documentCount) }
|
||
if (key === 'completion') {
|
||
const c = stats.completion
|
||
if (c.state === 'unavailable') return { value: '—', hint: reasons['completion'] || '无法计算' }
|
||
if (c.state === 'partial') return { value: `${c.known}/${c.total}`, hint: '部分计算' }
|
||
return { value: `${c.known}/${c.total}` }
|
||
}
|
||
const map: Record<string, number | null | undefined> = {
|
||
empty: stats.emptyNodeCount,
|
||
unverified: stats.unverifiedSourceCount,
|
||
conflict: stats.conflictCount,
|
||
pending: stats.pendingChangeCount,
|
||
}
|
||
const v = map[key]
|
||
// 原因表按后端 DTO 字段名(statReasons 键=字段名),展示键需映射。
|
||
const reasonKey: Record<string, string> = {
|
||
empty: 'emptyNodeCount', unverified: 'unverifiedSourceCount',
|
||
conflict: 'conflictCount', pending: 'pendingChangeCount',
|
||
}
|
||
if (v === null || v === undefined) return { value: '—', hint: reasons[reasonKey[key] || key] || '暂不可计算' }
|
||
return { value: String(v) }
|
||
}
|
||
|
||
// ---- 快照一致性(DV-R05 前端侧):统计与树徽标同快照才同屏可信 ----
|
||
|
||
export function sameSnapshot(a?: CockpitMeta, b?: CockpitMeta): boolean {
|
||
if (!a || !b) return false
|
||
return a.snapshotRevision === b.snapshotRevision && a.asOfSeq === b.asOfSeq
|
||
}
|
||
|
||
/** 更新提示:展示中快照落后于最新已见快照时提示刷新(不抢占滚动,视图规格 §5)。 */
|
||
export function updateNotice(viewed: CockpitMeta | null, latest: CockpitMeta | null): string | null {
|
||
if (!viewed || !latest) return null
|
||
if (viewed.snapshotRevision === latest.snapshotRevision && viewed.asOfSeq === latest.asOfSeq) return null
|
||
return `内容已更新(快照 ${latest.snapshotRevision})——刷新查看最新状态`
|
||
}
|
||
|
||
/** 上下文键:项目+筛选标识一轮取数上下文;不匹配的旧响应直接丢弃(契约 §8)。 */
|
||
export function contextKey(projectId: string, filter: string): string {
|
||
return `${projectId}\u0001${filter}`
|
||
}
|
||
|
||
/** 刷新不重置选择:文档仍在根清单(或选中章节的父文档仍在)则保留,否则清空。 */
|
||
export function selectionSurvives(
|
||
selected: CockpitNodeRef | null,
|
||
rootDocIds: string[],
|
||
parentDocId?: string,
|
||
): CockpitNodeRef | null {
|
||
if (!selected) return null
|
||
if (selected.kind === 'document') return rootDocIds.includes(selected.id) ? selected : null
|
||
if (parentDocId && rootDocIds.includes(parentDocId)) return selected
|
||
return null
|
||
}
|
||
|
||
/** 树行徽标(诚实状态:空/部分/受限/未核实/冲突计数;不虚构成功)。 */
|
||
export function treeBadges(n: CockpitTreeNode): string[] {
|
||
const out: string[] = []
|
||
if (n.state.content === 'empty') out.push('空')
|
||
else if (n.state.content === 'partial') out.push('部分')
|
||
else if (n.state.content === 'inaccessible') out.push('受限')
|
||
if (n.visibility === 'metadata_only') out.push('仅元数据')
|
||
if (n.state.verification === 'unverified') out.push('未核实')
|
||
if (n.state.conflictCount > 0) out.push(`冲突 ${n.state.conflictCount}`)
|
||
return out
|
||
}
|
||
|
||
/** 节点状态一句话总结(全景 §4.2)。 */
|
||
export function nodeSummaryLine(n: CockpitTreeNode): string {
|
||
const parts: string[] = []
|
||
const content = { complete: '内容完整', partial: '内容部分', empty: '内容为空', inaccessible: '内容受限', unavailable: '暂不可读' }[n.state.content] || n.state.content
|
||
parts.push(content)
|
||
if (n.state.structuralGap) parts.push('层级止于章节')
|
||
if (n.state.sourceCount > 0) parts.push(`来源 ${n.state.sourceCount}`)
|
||
if (n.state.conflictCount > 0) parts.push(`冲突 ${n.state.conflictCount}`)
|
||
if (n.state.impactCount > 0) parts.push(`影响 ${n.state.impactCount}`)
|
||
return parts.join(' · ')
|
||
}
|