// 文档驾驶舱 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 } 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 = { empty: stats.emptyNodeCount, unverified: stats.unverifiedSourceCount, conflict: stats.conflictCount, pending: stats.pendingChangeCount, } const v = map[key] // 原因表按后端 DTO 字段名(statReasons 键=字段名),展示键需映射。 const reasonKey: Record = { 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(' · ') }