Files
Agent-System-Frontend/src/documentCockpit.ts
T
weichen 17996cf4dd Add document cockpit workspace consuming rdm.cockpit.* (I2)
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
2026-10-16 09:03:48 +00:00

171 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 文档驾驶舱 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(' · ')
}