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
343 lines
13 KiB
TypeScript
343 lines
13 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(' · ')
|
||
}
|
||
|
||
// ---- 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<T> { 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<string, unknown>
|
||
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<string, { body?: CockpitContentBlock[]; versions?: CockpitVersionSummary[] } | undefined>,
|
||
): { 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')})`
|
||
}
|