From 86770e35fb9d1d822f640ca388fd10e28f685027 Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 16 Oct 2026 12:21:24 +0000 Subject: [PATCH] Add cockpit reader view and evidence panel (I3) 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 --- src/components/CockpitReader.tsx | 254 +++++++++++++++++++++++++++++ src/components/DocumentCockpit.tsx | 57 ++++++- src/documentCockpit.test.ts | 88 ++++++++++ src/documentCockpit.ts | 172 +++++++++++++++++++ src/styles.css | 30 ++++ 5 files changed, 593 insertions(+), 8 deletions(-) create mode 100644 src/components/CockpitReader.tsx diff --git a/src/components/CockpitReader.tsx b/src/components/CockpitReader.tsx new file mode 100644 index 0000000..6e94f55 --- /dev/null +++ b/src/components/CockpitReader.tsx @@ -0,0 +1,254 @@ +// 文档驾驶舱 I3:阅读器(连续正文 + 版本摘要/差异折叠)与右侧依据/影响面板。 +// 消费 I1/I3 命令面 rdm.cockpit.node(+versions)/node.history(Dev 仓 docs/superpowers +// specs 2026-10-16 view-spec §5–§6)。不新增第四栏:右栏是选中节点的同一依据面, +// 全景与阅读器共享 selectedNodeRef;面板刷新原位进行,不抢占滚动位置(DV-R05/§5)。 +// 折叠只用 @ui Collapsible;颜色/字号只消费 @ui/tokens.css。 +import { useCallback, useEffect, useRef, useState } from 'react' +import { api } from '../api' +import { Collapsible, EmptyState } from '@ui' +import { + assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary, + PANEL_TABS, type CockpitContentBlock, type CockpitHistorySummary, + type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode, + type CockpitVersionSummary, type Density, type PanelTabId, +} from '../documentCockpit' + +export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string } + +interface NodeDetailShape { + meta: CockpitMeta + node: CockpitTreeNode + body?: CockpitContentBlock[] + children: CockpitTreeNode[] + sources: { items: any[] } + history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string } + impacts: { items: any[] } + conflicts: { items: any[] } + versions?: CockpitVersionSummary[] +} + +function useNodeDetail(pid: string, sel?: CockpitNodeRef) { + const [detail, setDetail] = useState(null) + const [err, setErr] = useState('') + const gen = useRef(0) + useEffect(() => { + const g = ++gen.current + setDetail(null); setErr('') + if (!sel) return + api.rdmCommand('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id }) + .then(d => { if (g === gen.current) setDetail(d) }) + .catch(e => { if (g === gen.current) setErr(e.message) }) + return () => { gen.current++ } + }, [pid, sel?.kind, sel?.id]) + return { detail, err } +} + +/** 版本块:当前版本 + 前一版本摘要;完整差异(行级标记)折叠。受限只给元数据。 */ +export function VersionBlock({ versions, density }: { versions?: CockpitVersionSummary[]; density: Density }) { + if (!versions || versions.length === 0) return null + const { current, previous, redacted } = versionSummary(versions) + if (redacted || !current || current.content === undefined) { + return
版本受限(仅元数据可见)
+ } + const prev = previous && previous.content !== undefined ? previous : undefined + return ( + 版本 {current.version}{previous ? `(前一版 ${previous.version})` : '(首版)'}与完整差异} + > +
+ {prev ? ( + <> +
当前 {current.version} · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}
+
+              {diffLines(current.content || '', prev.content || '').map((l, i) => (
+                
{l.flag} {l.text}
+ ))} +
+
前一版 {prev.version} · {prev.actor || '未知'} · {new Date(prev.at).toLocaleString('zh-CN')}
+ + ) : ( +
首版 · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}
+ )} +
+
+ ) +} + +/** 阅读器中栏:选中章节=该节连续正文+版本块;选中文档=全章节连续装配(每节详情按需拉取)。 */ +export function CockpitReader({ pid, selection, sections, density, onBack }: { + pid: string + selection: ReaderSelection + sections: CockpitTreeNode[] + density: Density + onBack: () => void +}) { + const { detail, err } = useNodeDetail(pid, selection.ref) + const isSection = selection.ref.kind === 'section' + const [sectionDetails, setSectionDetails] = useState>({}) + const gen = useRef(0) + // 文档阅读器:按树顺序装配全部章节详情(上限 20;失败章节给受限占位不拖垮整篇)。 + useEffect(() => { + if (isSection || selection.ref.kind !== 'document') return + const g = ++gen.current + const list = sections.slice(0, 20) + setSectionDetails({}) + for (const sec of list) { + api.rdmCommand('rdm.cockpit.node', { projectId: pid, kind: 'section', id: sec.ref.id }) + .then(d => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: d })) }) + .catch(() => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: undefined })) }) + } + return () => { gen.current++ } + }, [pid, isSection, selection.ref.id, sections]) + const anchorRef = useRef(null) + useEffect(() => { anchorRef.current?.scrollIntoView({ block: 'start' }) }, [selection.ref.id]) + if (err) return 阅读器加载失败:{err} + return ( +
+
+ + {detail?.node?.title || (isSection ? '章节' : '文档')} +
+ {isSection && detail && ( + <> + {renderBody(detail.body, detail.meta)} + + + )} + {!isSection && ( + <> + {sections.length === 0 &&
该文档暂无章节
} + {assembleReader(sections, sectionDetails).map(sec => ( +
+
+ {sec.title} + {sec.blocks[0]?.kind === 'restricted' + ? 内容受限({sec.blocks[0].reason || '权限'}) + : sec.contentState === 'empty' && 空} +
+ {sec.blocks.map((b, i) => b.kind === 'text' + ?

{b.text}

+ :

内容受限——标题与状态可见,正文不展示

)} + +
+ ))} + + )} +
+ ) +} + +function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta) { + if (!body || body.length === 0) return
暂无正文
+ if (body[0].kind === 'restricted') { + return
内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示
+ } + return ( + <> + {body.map((b, i) =>

{b.text}

)} + {meta.truncated &&
详情已截断:{meta.truncationReasons?.join(';')}
} + + ) +} + +/** 右栏:依据/历史/影响/冲突四标签(同一选中节点;刷新原位,不抢占滚动)。 */ +export function CockpitPanel({ pid, selection, tab, onTab, density }: { + pid: string + selection: ReaderSelection + tab: PanelTabId + onTab: (t: PanelTabId) => void + density: Density +}) { + const { detail, err } = useNodeDetail(pid, selection.ref) + const [historyItems, setHistoryItems] = useState([]) + const [historyMore, setHistoryMore] = useState(false) + const [historyCursor, setHistoryCursor] = useState(undefined) + useEffect(() => { + setHistoryItems([]); setHistoryMore(false); setHistoryCursor(undefined) + }, [selection.ref.kind, selection.ref.id]) + useEffect(() => { + if (!detail?.history) return + // 严格合并:只接受 seq 更新的条目,乱序/重复丢弃;不重置已渲染内容。 + setHistoryItems(prev => mergeHistoryStrict(prev, detail.history.items).items) + setHistoryMore(detail.history.hasMore) + setHistoryCursor(detail.history.nextCursor) + }, [detail]) + const loadMoreHistory = useCallback(() => { + if (!historyCursor) return + api.rdmCommand('rdm.cockpit.node.history', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id, cursor: historyCursor, limit: 20 }) + .then(page => { + setHistoryItems(prev => mergeHistoryStrict(prev, page.items).items) + setHistoryMore(page.hasMore) + setHistoryCursor(page.nextCursor) + }) + .catch(() => { /* 保留已渲染历史;可再次点击重试 */ }) + }, [pid, selection.ref.kind, selection.ref.id, historyCursor]) + const node = detail?.node + return ( +
+
+ {PANEL_TABS.map(t => ( + + ))} +
+ {err &&
依据面板加载失败:{err}
} + {!err && !detail &&
加载依据…
} + {detail && tab === 'sources' && ( +
+
来源 {node?.state.sourceCount ?? detail.sources.items.length} 条
+ {detail.sources.items.length === 0 &&
无来源——章节正文未挂依据切片
} + {detail.sources.items.map((s: any) => ( +
+ {s.ref.kind} + {s.label} + {s.confidence && 置信度 {s.confidence}} + {s.redactedReason + ? 受限({s.redactedReason}) + : s.verification && s.verification !== 'not_applicable' && {s.verification}} +
+ ))} +
+ )} + {detail && tab === 'history' && ( +
+
已提交事件(最新在前)
+ {historyItems.length === 0 &&
暂无历史
} + {historyItems.map(h =>
{historyLine(h)}
)} + {historyMore && } +
+ )} + {detail && tab === 'impacts' && ( +
+
影响 {node?.state.impactCount ?? detail.impacts.items.length} 项(一跳)
+ {detail.impacts.items.length === 0 &&
无一跳影响对象
} + {detail.impacts.items.map((r: any, i: number) => ( +
+ {r.direction === 'incoming' ? '←' : '→'} + {r.ref.kind}:{r.ref.id} + {r.edgeType} + {r.edgeState} +
+ ))} +
+ )} + {detail && tab === 'conflicts' && ( +
+
冲突 {node?.state.conflictCount ?? 0} 项
+ {detail.conflicts.items.length === 0 &&
无开放冲突
} + {detail.conflicts.items.map(c => ( +
+ {c.state === 'open' ? '开放' : c.state} +
甲方:{c.left.visibility === 'full' ? c.left.claim : `受限(${c.left.redactedReason || '权限'})`}
+
乙方:{c.right.visibility === 'full' ? c.right.claim : `受限(${c.right.redactedReason || '权限'})`}
+
{new Date(c.detectedAt).toLocaleString('zh-CN')}
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/DocumentCockpit.tsx b/src/components/DocumentCockpit.tsx index 5b8d850..1730f21 100644 --- a/src/components/DocumentCockpit.tsx +++ b/src/components/DocumentCockpit.tsx @@ -10,10 +10,13 @@ import { store, useStore } from '../store' import { EmptyState, SectionLabel } from '@ui' import { classifyRdmError } from '../rdmErrors' import { - contextKey, nodeSummaryLine, sameSnapshot, selectionSurvives, statDisplay, - STAT_FILTERS, toggleFilter, treeBadges, - type CockpitMeta, type CockpitNodeRef, type CockpitOverview, type CockpitTreeNode, type CockpitTreePage, + contextKey, loadPrefs, nodeSummaryLine, sameSnapshot, savePrefs, + selectionSurvives, statDisplay, STAT_FILTERS, toggleFilter, treeBadges, + type CockpitMeta, type CockpitNodeRef, type CockpitOverview, + type CockpitTreeNode, type CockpitTreePage, + type Density, type PanelTabId, type ViewMode, } from '../documentCockpit' +import { CockpitPanel, CockpitReader } from './CockpitReader' interface ProjectView { id: string; name: string; canAudit?: boolean } @@ -34,6 +37,9 @@ export function DocumentCockpit() { const [selected, setSelected] = useState(null) const [error, setError] = useState('') const [loadErr, setLoadErr] = useState('') + const [viewMode, setViewMode] = useState('panorama') + const [density, setDensity] = useState('compact') + const [panelTab, setPanelTab] = useState('sources') const gen = useRef(0) const reload = useCallback((projectId: string, f: string) => { @@ -70,6 +76,23 @@ export function DocumentCockpit() { reload(pid, filter) }, [pid, filter, reload]) + // 个人视图偏好(设计 §7.3):按 登录世代+项目 隔离,仅本地;恢复最近视图。 + useEffect(() => { + if (!pid) return + const prefs = loadPrefs(`epoch-${identity}`, pid) + setViewMode(prefs.lastView) + setDensity(prefs.density) + setPanelTab(prefs.rightPanelTab) + }, [identity, pid]) + const applyPrefs = useCallback((patch: { lastView?: ViewMode; density?: Density; rightPanelTab?: PanelTabId }) => { + if (!pid) return + setViewMode(v => patch.lastView ?? v) + setDensity(d => patch.density ?? d) + setPanelTab(t => patch.rightPanelTab ?? t) + const prefs = loadPrefs(`epoch-${identity}`, pid) + savePrefs(`epoch-${identity}`, pid, { ...prefs, ...patch }) + }, [identity, pid]) + // 展开文档:按需加载章节子页(键集分页;旧项目/旧筛选的响应丢弃; // 加载失败只影响该分支——行内保留重试,不拖垮其它面板)。 const loadChildren = useCallback((docId: string, cursor?: string) => { @@ -106,6 +129,11 @@ export function DocumentCockpit() { 筛选:{STAT_FILTERS.find(s => s.filter === filter)?.label || filter} ✕ } {overview ? `快照 ${overview.meta.snapshotRevision}` : '加载中…'} +
+ + + +
{splitNotice && ( @@ -135,7 +163,7 @@ export function DocumentCockpit() {
{overview.stats.updatedAt ? new Date(overview.stats.updatedAt).toLocaleString('zh-CN') : '—'}最近更新
)} -
+
结构树 {!overview &&
加载结构…
} @@ -160,12 +188,23 @@ export function DocumentCockpit() { {overview && !selected && ( )} - {overview && selected && ( + {overview && selected && viewMode === 'panorama' && ( setSelected(null)} /> + onClear={() => setSelected(null)} + onOpenReader={() => applyPrefs({ lastView: 'reader' })} /> + )} + {overview && selected && viewMode === 'reader' && ( + applyPrefs({ lastView: 'panorama' })} /> )}
+ {overview && selected && ( +
+ applyPrefs({ rightPanelTab: t })} density={density} /> +
+ )}
) @@ -243,10 +282,11 @@ function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSel ) } -function PanoramaNode({ pid, selection, overview, childPage, onClear }: { +function PanoramaNode({ pid, selection, overview, childPage, onClear, onOpenReader }: { pid: string; selection: Selection; overview: CockpitOverview childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta } onClear: () => void + onOpenReader: () => void }) { const [detail, setDetail] = useState(null) const [err, setErr] = useState('') @@ -267,6 +307,7 @@ function PanoramaNode({ pid, selection, overview, childPage, onClear }: { {title} {node && {nodeSummaryLine(node)}} + {err && } {detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && ( @@ -300,7 +341,7 @@ function PanoramaNode({ pid, selection, overview, childPage, onClear }: { )} {node && node.ref.kind === 'section' && ( -
连续正文阅读与依据/影响面板在阅读器视图提供(I3)——本页展示状态摘要
+
点「在阅读器中打开」查看连续正文与版本差异;右侧为该节点的依据与影响面板
)} {detail?.meta?.truncated && (
详情已截断:{detail.meta.truncationReasons?.join(';')}
diff --git a/src/documentCockpit.test.ts b/src/documentCockpit.test.ts index 9321900..2998282 100644 --- a/src/documentCockpit.test.ts +++ b/src/documentCockpit.test.ts @@ -124,3 +124,91 @@ describe('树徽标与状态行', () => { expect(line).toContain('层级止于章节') }) }) + +// ---- I3:阅读器与右栏纯逻辑 ---- +import { + assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary, + prefsKey, mergePrefs, loadPrefs, + type CockpitHistorySummary, type CockpitVersionSummary, +} from './documentCockpit' + +describe('偏好:按用户+项目隔离,枚举外脏值回落默认', () => { + it('prefsKey 区分用户与项目', () => { + expect(prefsKey('a', 'p1')).not.toBe(prefsKey('a', 'p2')) + expect(prefsKey('a', 'p1')).not.toBe(prefsKey('b', 'p1')) + }) + it('mergePrefs 逐项校验', () => { + expect(mergePrefs(null)).toEqual({ lastView: 'panorama', density: 'compact', rightPanelTab: 'sources' }) + expect(mergePrefs({ lastView: 'reader', density: 'full', rightPanelTab: 'conflicts' })).toEqual({ + lastView: 'reader', density: 'full', rightPanelTab: 'conflicts', + }) + const junk = mergePrefs({ lastView: 'galaxy', density: 42, rightPanelTab: 'nope' }) + expect(junk.lastView).toBe('panorama') + expect(junk.density).toBe('compact') + expect(junk.rightPanelTab).toBe('sources') + }) + it('loadPrefs 在无 localStorage 环境安全回落', () => { + expect(loadPrefs('a', 'p1').lastView).toBe('panorama') + }) +}) + +describe('版本摘要与行级差异', () => { + const v = (seq: number, content?: string, redacted?: boolean): CockpitVersionSummary => ({ + seq, version: `v${seq}`, at: '2026-10-16T00:00:00Z', basisCount: 0, content, redacted, + }) + it('当前+前一版;受限整体降级', () => { + const s = versionSummary([v(3, '三'), v(2, '二'), v(1, '一')]) + expect(s.current?.version).toBe('v3') + expect(s.previous?.version).toBe('v2') + expect(s.redacted).toBe(false) + const r = versionSummary([v(3, undefined, true)]) + expect(r.redacted).toBe(true) + expect(versionSummary(undefined).current).toBeUndefined() + }) + it('diffLines 标记新增/删除/保留', () => { + const d = diffLines('共同\n新增行', '共同\n删除行') + expect(d).toContainEqual({ flag: '=', text: '共同' }) + expect(d).toContainEqual({ flag: '+', text: '新增行' }) + expect(d).toContainEqual({ flag: '-', text: '删除行' }) + }) +}) + +describe('历史严格合并:乱序与重复丢弃(DV-R05)', () => { + const h = (seq: number): CockpitHistorySummary => ({ seq, at: '', op: 'x', summary: `s${seq}` }) + it('只接受 seq 更大的条目', () => { + const r = mergeHistoryStrict([h(10), h(9)], [h(11), h(8)]) + expect(r.items.map(i => i.seq)).toEqual([10, 9, 11]) + expect(r.dropped).toBe(1) + }) + it('重复 seq 丢弃且计入 dropped', () => { + const r = mergeHistoryStrict([h(10)], [h(10), h(11)]) + expect(r.items.map(i => i.seq)).toEqual([10, 11]) + expect(r.dropped).toBe(1) + }) + it('空历史接受全部', () => { + expect(mergeHistoryStrict([], [h(1), h(2)]).items.map(i => i.seq)).toEqual([1, 2]) + }) + it('historyLine 带操作者与本地时间', () => { + expect(historyLine(h(1))).toContain('s1') + }) +}) + +describe('阅读器装配:受限章节给占位不冒充空内容', () => { + const sec = (id: string, content: string): CockpitTreeNode => ({ + ref: { kind: 'section', id }, title: id, path: [], childCount: 0, + state: { content, verification: 'not_applicable', sourceCount: 0, conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true }, + visibility: 'full', + }) + it('正文/受限/未加载三分态', () => { + const out = assembleReader( + [sec('a', 'complete'), sec('b', 'complete'), sec('c', 'empty')], + { + a: { body: [{ kind: 'text', text: '正文A' }] }, + b: { body: [{ kind: 'restricted', reason: 'section-duty-or-authorship' }] }, + }, + ) + expect(out[0].blocks).toEqual([{ kind: 'text', text: '正文A' }]) + expect(out[1].blocks[0].kind).toBe('restricted') + expect(out[2].blocks[0].reason).toBe('not_loaded') + }) +}) diff --git a/src/documentCockpit.ts b/src/documentCockpit.ts index e0e6964..6509d05 100644 --- a/src/documentCockpit.ts +++ b/src/documentCockpit.ts @@ -168,3 +168,175 @@ export function nodeSummaryLine(n: CockpitTreeNode): string { 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')})` +} diff --git a/src/styles.css b/src/styles.css index 32acb2c..4610ecd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -691,3 +691,33 @@ button.cockpit-tree-more { cursor: pointer; text-decoration: underline; } .cockpit-childtable { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); } .cockpit-childtable th, .cockpit-childtable td { border-bottom: 1px solid var(--border); padding: 4px 6px; text-align: left; color: var(--text); } @media (max-width: 760px) { .cockpit-columns { grid-template-columns: 1fr; } .cockpit-tree { max-height: 40vh; } } + +/* ---- 驾驶舱 I3:阅读器与右栏(令牌驱动) ---- */ +.cockpit-columns-3 { grid-template-columns: minmax(200px, 260px) 1fr minmax(240px, 300px); } +.cockpit-right { min-height: 0; overflow-y: auto; border-left: 1px solid var(--border); padding-left: 12px; } +.cockpit-viewtoggle { display: flex; gap: 0; } +.cockpit-viewbtn { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); background: var(--bg-panel); color: var(--text); cursor: pointer; } +.cockpit-viewbtn-active { background: var(--text); color: var(--bg-panel); } +.cockpit-reader-para { white-space: pre-wrap; font-size: var(--fs-ui-base, var(--fs-ui-sm)); line-height: 1.6; color: var(--text); margin: 6px 0; } +.cockpit-density-full .cockpit-reader-para { line-height: 1.9; margin: 10px 0; } +.cockpit-reader-section { border-top: 1px solid var(--border); padding: 8px 0; } +.cockpit-reader-sechead { display: flex; align-items: baseline; gap: 8px; } +.cockpit-reader-sectitle { font-weight: 600; color: var(--text); font-size: var(--fs-ui-base, var(--fs-ui-sm)); } +.cockpit-versions { font-size: var(--fs-ui-sm); } +.cockpit-density-full .cockpit-versions { line-height: 1.7; } +.cockpit-versions-toggle { font-size: var(--fs-ui-sm); color: var(--text); text-decoration: underline; cursor: pointer; } +.cockpit-versions-head { color: var(--text-muted, var(--text)); padding: 4px 0; } +.cockpit-diff { white-space: pre-wrap; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--fs-ui-sm); } +.cockpit-diff-add { color: var(--text); } +.cockpit-diff-del { color: var(--text-muted, var(--text)); text-decoration: line-through; } +.cockpit-diff-same { color: var(--text-muted, var(--text)); } +.cockpit-panel-tabs { display: flex; gap: 4px; padding-bottom: 8px; } +.cockpit-panel-tab { border: 1px solid var(--border); background: none; padding: 3px 8px; font-size: var(--fs-ui-sm); color: var(--text); cursor: pointer; border-radius: var(--radius-sm); } +.cockpit-panel-tab-active { background: var(--bg-panel); border-color: var(--text); } +.cockpit-panel-body { display: flex; flex-direction: column; gap: 6px; } +.cockpit-panel-count { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); } +.cockpit-panel-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border); padding: 4px 0; } +.cockpit-panel-kind { color: var(--text-muted, var(--text)); } +.cockpit-panel-label { color: var(--text); } +.cockpit-panel-conflict { flex-direction: column; } +.cockpit-conflict-side { color: var(--text); }