// 文档驾驶舱 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(' · ') } // ---- I3:阅读器与右栏纯逻辑 ---- export interface CockpitVersionSummary { seq: number version: string actor?: string at: string basisCount: number content?: string redacted?: boolean } export interface CockpitContentBlock { kind: string; text?: string; reason?: string } export interface CockpitSourceSummary { ref: { kind: string; id: string } label: string capturedAt?: string confidence: string verification: string visibility: string redactedReason?: string } export interface CockpitHistorySummary { seq: number at: string op: string actor?: string summary: string operationId?: string } export interface CockpitImpactSummary { ref: { kind: string; id: string } edgeType: string edgeState: string direction: string layer: string } export interface CockpitAssertion { node: CockpitNodeRef; claim: string; visibility: string; redactedReason?: string } export interface CockpitConflictSummary { id: string node: CockpitNodeRef state: string left: CockpitAssertion right: CockpitAssertion detectedAt: string resolutionRef?: string } export interface CockpitPage { meta?: CockpitMeta; items: T[]; nextCursor?: string; hasMore: boolean; truncated?: boolean } export const PANEL_TABS = [ { id: 'sources', label: '内容来源' }, { id: 'history', label: '修改历史' }, { id: 'impacts', label: '影响对象' }, { id: 'conflicts', label: '冲突与风险' }, ] as const export type PanelTabId = (typeof PANEL_TABS)[number]['id'] export type ViewMode = 'panorama' | 'reader' export type Density = 'compact' | 'full' export interface CockpitPrefs { lastView: ViewMode density: Density rightPanelTab: PanelTabId } /** 偏好按 用户+项目 隔离(设计 §7.3);仅本地存储,不构成授权。 */ export function prefsKey(actor: string, projectId: string): string { return `cockpit-prefs:${actor}:${projectId}` } /** 解析偏好:枚举外的脏值逐项回落默认(panorama+compact+sources)。 */ export function mergePrefs(raw: unknown): CockpitPrefs { const base: CockpitPrefs = { lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' } if (!raw || typeof raw !== 'object') return base const r = raw as Record const out = { ...base } if (r.lastView === 'panorama' || r.lastView === 'reader') out.lastView = r.lastView if (r.density === 'compact' || r.density === 'full') out.density = r.density if (PANEL_TABS.some(t => t.id === r.rightPanelTab)) out.rightPanelTab = r.rightPanelTab as PanelTabId return out } export function loadPrefs(actor: string, projectId: string): CockpitPrefs { try { const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(prefsKey(actor, projectId)) : null return mergePrefs(raw ? JSON.parse(raw) : null) } catch { return mergePrefs(null) } } export function savePrefs(actor: string, projectId: string, prefs: CockpitPrefs): void { try { if (typeof localStorage !== 'undefined') localStorage.setItem(prefsKey(actor, projectId), JSON.stringify(prefs)) } catch { /* 存储不可用时偏好静默降级为会话内 */ } } /** 版本摘要:当前版本 + 前一版本(视图规格 §5)。versions 为最新在前;受限版本无 content。 */ export function versionSummary(versions: CockpitVersionSummary[] | undefined): { current?: CockpitVersionSummary previous?: CockpitVersionSummary redacted: boolean } { if (!versions || versions.length === 0) return { redacted: false } const current = versions[0] const previous = versions.length > 1 ? versions[1] : undefined return { current, previous, redacted: !!current.redacted } } /** 历史面板合并:只接受严格更新(seq 更大且未见)的条目;过期与重复都计入 dropped(DV-R05/契约 §8)。 */ export function mergeHistoryStrict(existing: CockpitHistorySummary[], incoming: CockpitHistorySummary[]): { items: CockpitHistorySummary[] dropped: number } { const seen = new Set(existing.map(e => e.seq)) const maxSeq = existing.length > 0 ? Math.max(...seen) : -1 const items = [...existing] let dropped = 0 for (const e of incoming) { if (e.seq <= maxSeq || seen.has(e.seq)) { dropped++ continue } seen.add(e.seq) items.push(e) } return { items, dropped } } /** 阅读器装配:把文档的章节详情按树顺序组装为连续正文;受限章节给占位说明而非空白。 */ export function assembleReader( sections: CockpitTreeNode[], details: Record, ): { ref: CockpitNodeRef; title: string; contentState: string; blocks: CockpitContentBlock[]; versions?: CockpitVersionSummary[] }[] { return sections.map(sec => { const d = details[sec.ref.id] const readable = !!d?.body && d.body.length > 0 && d.body[0].kind === 'text' const restricted = !!d?.body && d.body.length > 0 && d.body[0].kind === 'restricted' const blocks: CockpitContentBlock[] = readable ? (d!.body as CockpitContentBlock[]) : restricted ? [{ kind: 'restricted', reason: d?.body?.[0]?.reason || 'section-duty-or-authorship' }] : [{ kind: 'restricted', reason: 'not_loaded' }] return { ref: sec.ref, title: sec.title, contentState: sec.state.content, blocks, versions: d?.versions } }) } /** 行级差异(阅读器 Collapsible 的完整对比):按行对齐的朴素 LCS 免实现—— * 以行为单位做集合比对标记 +/=/-,足够呈现「完整差异」而不冒充精确补丁。 */ export function diffLines(current: string, previous: string): { flag: '+' | '-' | '='; text: string }[] { const cur = current.split('\n') const prev = new Set(previous.split('\n')) const curSet = new Set(cur) const out: { flag: '+' | '-' | '='; text: string }[] = [] for (const line of cur) out.push({ flag: prev.has(line) ? '=' : '+', text: line }) for (const line of previous.split('\n')) if (!curSet.has(line)) out.push({ flag: '-', text: line }) return out } /** 历史条目的自然语言时间线文本(右栏 §6)。 */ export function historyLine(h: CockpitHistorySummary): string { const who = h.actor ? `${h.actor} ` : '' return `${h.summary}(${who}${new Date(h.at).toLocaleString('zh-CN')})` }