diff --git a/src/components/DocumentCockpit.tsx b/src/components/DocumentCockpit.tsx new file mode 100644 index 0000000..5b8d850 --- /dev/null +++ b/src/components/DocumentCockpit.tsx @@ -0,0 +1,310 @@ +// 文档驾驶舱 I2(rollout I2):顶部统计带 + 左侧结构树 + 中央结构全景。 +// 消费 I1 只读命令面 rdm.cockpit.*(Dev 仓 docs/superpowers/specs 2026-10-16 +// view-spec §1–§4;四项取舍冻结见 rollout §7——首屏只显示章节概览与统计, +// 段落/正文摘要在选中与阅读器内按权限展示,本增量不含阅读器(I3))。 +// 异步所有者:generation + contextKey(项目+筛选);不匹配的旧响应直接丢弃。 +// 令牌驱动(DESIGN.md):颜色/字号只消费 @ui/tokens.css;折叠用 Collapsible。 +import { useCallback, useEffect, useRef, useState } from 'react' +import { api } from '../api' +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, +} from '../documentCockpit' + +interface ProjectView { id: string; name: string; canAudit?: boolean } + +function CockpitErrorState({ raw }: { raw: string }) { + const c = classifyRdmError(raw) + return {c.message} +} + +interface Selection { ref: CockpitNodeRef; parentDocId?: string } + +export function DocumentCockpit() { + const identity = useStore(() => store.getIdentityEpoch()) + const [projects, setProjects] = useState(null) + const [pid, setPid] = useState('') + const [filter, setFilter] = useState('') + const [overview, setOverview] = useState(null) + const [treePages, setTreePages] = useState>({}) + const [selected, setSelected] = useState(null) + const [error, setError] = useState('') + const [loadErr, setLoadErr] = useState('') + const gen = useRef(0) + + const reload = useCallback((projectId: string, f: string) => { + if (!projectId) return + const g = ++gen.current + const ctx = contextKey(projectId, f) + setLoadErr('') + api.rdmCommand('rdm.cockpit.overview', { projectId }) + .then(ov => { + if (g !== gen.current || ctx !== contextKey(pid, filter)) return // 旧响应:项目/筛选已切换,丢弃 + setOverview(ov) + setTreePages({}) // 树页随新快照整体重建 + setSelected(sel => { + const kept = selectionSurvives(sel?.ref || null, (ov.documents || []).map(d => d.ref.id), sel?.parentDocId) + return kept ? { ref: kept, parentDocId: sel?.parentDocId } : null + }) + }) + .catch(e => { if (g === gen.current && ctx === contextKey(pid, filter)) setLoadErr(e.message) }) + }, [pid, filter]) + + // 项目清单(rdm.project.list:成员可见项目,canAudit 与否不影响文档读取入口)。 + useEffect(() => { + let c = false + setProjects(null); setPid(''); setOverview(null); setSelected(null); setFilter(''); setError('') + api.rdmCommand('rdm.project.list') + .then(list => { if (!c) setProjects(Array.isArray(list) ? list : []) }) + .catch(e => { if (!c) setError(e.message) }) + return () => { c = true } + }, [identity]) + + // 项目/筛选变化 → 重新取概览(统计与根文档来自同一快照)。 + useEffect(() => { + if (!pid) return + reload(pid, filter) + }, [pid, filter, reload]) + + // 展开文档:按需加载章节子页(键集分页;旧项目/旧筛选的响应丢弃; + // 加载失败只影响该分支——行内保留重试,不拖垮其它面板)。 + const loadChildren = useCallback((docId: string, cursor?: string) => { + if (!pid) return + const g = gen.current + const ctx = contextKey(pid, filter) + api.rdmCommand('rdm.cockpit.tree', { projectId: pid, parent: docId, cursor, limit: 50, filter }) + .then(page => { + if (g !== gen.current || ctx !== contextKey(pid, filter)) return + setTreePages(prev => { + const old = cursor ? prev[docId] : { items: [] as CockpitTreeNode[], hasMore: false } + const items = cursor ? [...(old?.items || []), ...page.items] : page.items + return { ...prev, [docId]: { items, nextCursor: page.nextCursor, hasMore: page.hasMore, meta: page.meta } } + }) + }) + .catch(() => { /* 行内重试入口承担 */ }) + }, [pid, filter]) + + if (error) return + if (!projects) return
加载中…
+ if (projects.length === 0) return 暂无可见研发项目——项目成员身份由项目负责人授予 + + const docIds = (overview?.documents || []).map(d => d.ref.id) + const splitNotice = !!overview && Object.values(treePages).some(p => !sameSnapshot(overview.meta, p.meta)) + const selectDoc = (id: string) => setSelected({ ref: { kind: 'document', id } }) + const selectSection = (docId: string, sec: CockpitTreeNode) => setSelected({ ref: sec.ref, parentDocId: docId }) + return ( +
+
+ + {filter && } + {overview ? `快照 ${overview.meta.snapshotRevision}` : '加载中…'} + +
+ {splitNotice && ( +
统计与树快照不一致——请刷新以对齐最新快照
+ )} + {loadErr &&
概览读取失败:{loadErr}(保留上一快照,可重试)
} + {overview && ( +
+
+ {statDisplay(overview.stats, 'completion').value} + 完成度{statDisplay(overview.stats, 'completion').hint ? `(${statDisplay(overview.stats, 'completion').hint})` : ''} +
+
{statDisplay(overview.stats, 'documents').value}文档
+ {STAT_FILTERS.map(s => { + const d = statDisplay(overview.stats, s.key) + const active = filter === s.filter + return ( + + ) + })} +
—待确认(未纳入投影)
+
{overview.stats.updatedAt ? new Date(overview.stats.updatedAt).toLocaleString('zh-CN') : '—'}最近更新
+
+ )} +
+
+ 结构树 + {!overview &&
加载结构…
} + {overview && docIds.length === 0 &&
暂无受控文档
} + {overview && overview.meta.truncated && ( +
文档清单已截断({overview.meta.truncationReasons?.join(';') || '超出上限'})
+ )} + {overview && overview.documents.map(d => ( + (n.ref.kind === 'document' ? selectDoc(n.ref.id) : selectSection(d.ref.id, n))} + onExpand={() => { if (treePages[d.ref.id] === undefined) loadChildren(d.ref.id) }} + onLoadMore={() => { const p = treePages[d.ref.id]; if (p?.nextCursor) loadChildren(d.ref.id, p.nextCursor) }} + onRetryChild={() => { const p = treePages[d.ref.id]; loadChildren(d.ref.id, p?.nextCursor) }} + /> + ))} +
+
+ {!overview &&
加载概览…
} + {overview && !selected && ( + + )} + {overview && selected && ( + setSelected(null)} /> + )} +
+
+
+ ) +} + +function TreeRow({ node, depth, expanded, childPage, selectedId, onSelectNode, onExpand, onLoadMore, onRetryChild }: { + node: CockpitTreeNode; depth: number + expanded: boolean + childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta } + selectedId?: string + onSelectNode: (n: CockpitTreeNode) => void + onExpand: () => void + onLoadMore: () => void + onRetryChild: () => void +}) { + const badges = treeBadges(node) + const selected = selectedId === node.ref.id + return ( +
+
+ {node.ref.kind === 'document' && ( + + )} + + {badges.map(b => {b})} + {node.lastChangedAt && {new Date(node.lastChangedAt).toLocaleDateString('zh-CN')}} +
+ {expanded && !childPage && ( + + )} + {expanded && childPage && ( +
+ {childPage.items.length === 0 &&
无章节
} + {childPage.items.map(sec => ( + + ))} + {childPage.hasMore && childPage.items.length > 0 && ( + + )} +
+ )} +
+ ) +} + +function PanoramaHome({ overview, onSelect }: { overview: CockpitOverview; onSelect: (id: string) => void }) { + return ( +
+ {overview.project.title} · 文档概览 + {overview.documents.length === 0 && 暂无受控文档——文档由模板实例化或导入后进入驾驶舱} +
+ {overview.documents.map(d => ( + + ))} +
+ 最近变化 + {overview.recentChanges.length === 0 &&
暂无可见变化
} +
+ {overview.recentChanges.map((c, i) => ( +
+ {c.summary} · {c.title}{c.actor ? ` · ${c.actor}` : ''} + {new Date(c.changedAt).toLocaleString('zh-CN')} +
+ ))} +
+
+ ) +} + +function PanoramaNode({ pid, selection, overview, childPage, onClear }: { + pid: string; selection: Selection; overview: CockpitOverview + childPage?: { items: CockpitTreeNode[]; nextCursor?: string; hasMore: boolean; meta?: CockpitMeta } + onClear: () => void +}) { + const [detail, setDetail] = useState(null) + const [err, setErr] = useState('') + const gen = useRef(0) + useEffect(() => { + const g = ++gen.current + setDetail(null); setErr('') + api.rdmCommand('rdm.cockpit.node', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id }) + .then(d => { if (g === gen.current) setDetail(d) }) + .catch(e => { if (g === gen.current) setErr(e.message) }) + return () => { gen.current++ } + }, [pid, selection.ref.kind, selection.ref.id]) + const node = (detail?.node || null) as CockpitTreeNode | null + const title = node?.title || selection.ref.id + return ( +
+
+ + {title} + {node && {nodeSummaryLine(node)}} +
+ {err && } + {detail?.body?.length === 1 && detail.body[0].kind === 'restricted' && ( +
内容受限({detail.body[0].reason || '权限'})——标题与状态可见,正文不展示
+ )} + {detail && detail.meta && !sameSnapshot(detail.meta, overview.meta) && ( +
节点详情与统计快照不同({detail.meta.snapshotRevision} vs {overview.meta.snapshotRevision})——刷新对齐
+ )} + {node && node.ref.kind === 'document' && ( + <> + 章节状态 + {!childPage &&
章节按需加载——在左侧树展开
} + {childPage && childPage.items.length === 0 &&
无章节
} + {childPage && childPage.items.length > 0 && ( + + + + {childPage.items.map(sec => ( + + + + + + + + + ))} + +
章节内容核实来源冲突最近更新
{sec.title}{{ complete: '完整', partial: '部分', empty: '空', inaccessible: '受限', unavailable: '不可读' }[sec.state.content] || sec.state.content}{sec.state.verification === 'not_applicable' ? '—' : sec.state.verification}{sec.state.sourceCount}{sec.state.conflictCount}{sec.lastChangedAt ? new Date(sec.lastChangedAt).toLocaleDateString('zh-CN') : '—'}
+ )} + + )} + {node && node.ref.kind === 'section' && ( +
连续正文阅读与依据/影响面板在阅读器视图提供(I3)——本页展示状态摘要
+ )} + {detail?.meta?.truncated && ( +
详情已截断:{detail.meta.truncationReasons?.join(';')}
+ )} +
+ ) +} diff --git a/src/components/Workspaces.tsx b/src/components/Workspaces.tsx index 2a3458c..9993be8 100644 --- a/src/components/Workspaces.tsx +++ b/src/components/Workspaces.tsx @@ -8,12 +8,13 @@ import { docBridge } from './Workbench' import { AgentRow } from './Workbench' import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces' import { RdmAuditWorkspace } from './RdmAuditWorkspace' +import { DocumentCockpit } from './DocumentCockpit' /** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 * 追溯审计按命令面能力显隐(增量 2):store.rdmAuditAvailable 为 true 才入列。 */ export function workspaceEntries(): WorkspaceEntry[] { const s = store.getState() - return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable }) + return buildWorkspaceEntries(s.serverWorkspaces, { rdmAudit: s.rdmAuditAvailable, rdmCockpit: s.rdmCockpitAvailable }) } export function Workspaces() { @@ -36,6 +37,11 @@ export function Workspaces() { } + if (active === 'rdm-cockpit') { + return + + + } if (active.startsWith('plugin:')) { const panel = pluginPanels.find(p => `plugin:${p.id}` === active) return ({ + schemaVersion: 'document-cockpit.v1', projectId: 'p1', snapshotRevision: rev, + asOfSeq: seq, generatedAt: '2026-10-16T00:00:00Z', truncated: false, +}) + +describe('cockpitReady', () => { + const all = COCKPIT_COMMANDS.map(id => ({ id, enabled: true })) + it('七命令齐备且 enabled 才就绪', () => { + expect(cockpitReady(null)).toBe(false) + expect(cockpitReady([])).toBe(false) + expect(cockpitReady(all.map(c => ({ ...c, enabled: false })))).toBe(false) + expect(cockpitReady(all.slice(0, 6))).toBe(false) + expect(cockpitReady(all)).toBe(true) + }) +}) + +describe('统计呈现:真 0 与不可算不得互相冒充', () => { + const stats: CockpitStats = { + documentCount: 0, + completion: { known: 3, total: 5, state: 'known' }, + emptyNodeCount: 0, + unverifiedSourceCount: 2, + conflictCount: null as unknown as number, + pendingChangeCount: null, + updatedAt: '', + statReasons: { conflictCount: 'upstream_error', pendingChangeCount: 'not_projected_v1' }, + } + it('真 0 显示 0', () => { + expect(statDisplay(stats, 'empty').value).toBe('0') + expect(statDisplay(stats, 'documents').value).toBe('0') + }) + it('null 显示 — 并带原因', () => { + const d = statDisplay(stats, 'conflict') + expect(d.value).toBe('—') + expect(d.hint).toBe('upstream_error') + }) + it('未纳入投影的统计显式标注原因', () => { + const d = statDisplay(stats, 'pending') + expect(d.value).toBe('—') + expect(d.hint).toBe('not_projected_v1') + }) + it('完成度三分态:known/partial/unavailable', () => { + expect(statDisplay(stats, 'completion').value).toBe('3/5') + expect(statDisplay({ ...stats, completion: { known: 1, total: 5, state: 'partial' } }, 'completion').hint).toBe('部分计算') + const u = statDisplay({ ...stats, completion: { known: 0, total: 0, state: 'unavailable' } }, 'completion') + expect(u.value).toBe('—') + }) +}) + +describe('筛选切换', () => { + it('点击切换、再点清除;统计键与筛选值一一对应', () => { + expect(toggleFilter('', 'state:empty')).toBe('state:empty') + expect(toggleFilter('state:empty', 'state:empty')).toBe('') + expect(toggleFilter('state:empty', 'state:conflict')).toBe('state:conflict') + expect(STAT_FILTERS.map(s => s.filter)).toEqual(['state:empty', 'state:unverified', 'state:conflict']) + }) +}) + +describe('快照一致性与更新提示', () => { + it('同 revision 同 seq 视为同快照', () => { + expect(sameSnapshot(meta('wm:5', 5), meta('wm:5', 5))).toBe(true) + expect(sameSnapshot(meta('wm:5', 5), meta('wm:6', 6))).toBe(false) + expect(sameSnapshot(undefined, meta('wm:5', 5))).toBe(false) + }) + it('展示快照落后时提示刷新,一致时不提示', () => { + expect(updateNotice(meta('wm:5', 5), meta('wm:6', 6))).toContain('wm:6') + expect(updateNotice(meta('wm:6', 6), meta('wm:6', 6))).toBeNull() + expect(updateNotice(null, meta('wm:6', 6))).toBeNull() + }) +}) + +describe('旧响应丢弃的上下文键', () => { + it('项目与筛选任一变化都改变键', () => { + expect(contextKey('p1', '')).not.toBe(contextKey('p2', '')) + expect(contextKey('p1', '')).not.toBe(contextKey('p1', 'state:empty')) + expect(contextKey('p1', 'state:empty')).toBe(contextKey('p1', 'state:empty')) + }) +}) + +describe('刷新不重置选择', () => { + it('文档仍在根清单则保留', () => { + const kept = selectionSurvives({ kind: 'document', id: 'd1' }, ['d1', 'd2']) + expect(kept).toEqual({ kind: 'document', id: 'd1' }) + }) + it('文档被筛掉则清空', () => { + expect(selectionSurvives({ kind: 'document', id: 'd9' }, ['d1', 'd2'])).toBeNull() + }) + it('章节选择随父文档存活', () => { + expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d1'], 'd1')).toEqual({ kind: 'section', id: 's1' }) + expect(selectionSurvives({ kind: 'section', id: 's1' }, ['d2'], 'd1')).toBeNull() + expect(selectionSurvives(null, ['d1'])).toBeNull() + }) +}) + +describe('树徽标与状态行', () => { + const node = (over: Partial & { visibility?: string }): CockpitTreeNode => ({ + ref: { kind: 'section', id: 's1' }, title: '章节', path: [], childCount: 0, + visibility: over.visibility || 'full', + state: { + content: 'complete', verification: 'not_applicable', sourceCount: 0, + conflictCount: 0, impactCount: 0, missingChildCount: 0, structuralGap: true, ...over, + }, + } as CockpitTreeNode) + it('诚实徽标:空/受限/未核实/冲突计数', () => { + expect(treeBadges(node({}))).toEqual([]) + expect(treeBadges(node({ content: 'empty' }))).toContain('空') + expect(treeBadges(node({ verification: 'unverified' }))).toContain('未核实') + expect(treeBadges(node({ conflictCount: 2 }))).toContain('冲突 2') + expect(treeBadges(node({ visibility: 'metadata_only' }))).toContain('仅元数据') + }) + it('结构缺口显式标注(冻结事实:doc→section 两级)', () => { + const line = nodeSummaryLine(node({ content: 'empty' })) + expect(line).toContain('内容为空') + expect(line).toContain('层级止于章节') + }) +}) diff --git a/src/documentCockpit.ts b/src/documentCockpit.ts new file mode 100644 index 0000000..e0e6964 --- /dev/null +++ b/src/documentCockpit.ts @@ -0,0 +1,170 @@ +// 文档驾驶舱 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(' · ') +} diff --git a/src/store.ts b/src/store.ts index 2162ef5..996585e 100644 --- a/src/store.ts +++ b/src/store.ts @@ -11,7 +11,7 @@ import { canFlushOutbox, deriveShellPhase } from './runstate' import { flashElement } from './scrollsync' import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme' import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey' -import { rdmAuditReady, rdmExportReady } from './workspaces' +import { cockpitReady, rdmAuditReady, rdmExportReady } from './workspaces' import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement' type FontSizeChoice = 13 | 14 | 16 | 18 @@ -124,6 +124,7 @@ interface AppState { serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[] /** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */ rdmAuditAvailable: boolean + rdmCockpitAvailable: boolean /** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */ rdmExportAvailable: boolean /** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */ @@ -199,6 +200,7 @@ let state: AppState = { plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], rdmAuditAvailable: false, + rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, adminSections: [], @@ -442,7 +444,7 @@ export const store = { plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [], - rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, + rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, previewTabs: {}, previewActive: {}, previewOpenIntent: 0 }) @@ -1197,6 +1199,7 @@ export const store = { if (epoch !== identityEpoch) return set({ rdmAuditAvailable: rdmAuditReady(cmds), + rdmCockpitAvailable: cockpitReady(cmds), rdmExportAvailable: rdmExportReady(cmds), rdmRolesAvailable: rdmRolesReady(cmds), rdmMembersAvailable: rdmMembersReady(cmds), @@ -1205,7 +1208,7 @@ export const store = { }) } catch { if (epoch !== identityEpoch) return - set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, + set({ rdmAuditAvailable: false, rdmCockpitAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false }) } try { diff --git a/src/styles.css b/src/styles.css index b52e525..32acb2c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -648,3 +648,46 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; } .duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; } .duty-cell select { font-size: var(--fs-ui-sm); color: var(--text); background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); } + +/* ---- 文档驾驶舱(I2;令牌驱动,词汇沿用 rdm-audit 卡片体系) ---- */ +.cockpit { display: flex; flex-direction: column; min-height: 320px; } +.cockpit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; } +.cockpit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); } +.cockpit-filter-chip, .cockpit-refresh { height: 28px; padding: 0 10px; font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); color: var(--text); cursor: pointer; } +.cockpit-snap { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); } +.cockpit-banner { border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 6px 10px; margin-bottom: 8px; font-size: var(--fs-ui-sm); } +.cockpit-banner-err { border-color: var(--border); } +.cockpit-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; margin-bottom: 12px; } +.cockpit-stat { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; text-align: center; font: inherit; } +button.cockpit-stat { cursor: pointer; } +.cockpit-stat-active { border-color: var(--text); } +.cockpit-stat-unavail { color: var(--text-muted, var(--text)); } +.cockpit-stat-num { display: block; font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); } +.cockpit-stat-label { display: block; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); } +.cockpit-columns { display: grid; grid-template-columns: minmax(220px, 300px) 1fr; gap: 14px; } +.cockpit-tree { min-height: 0; overflow-y: auto; } +.cockpit-tree-row { display: flex; align-items: center; gap: 6px; padding: 3px 4px; border-radius: var(--radius-sm); } +.cockpit-tree-row-active { background: var(--bg-panel); } +.cockpit-tree-caret { border: none; background: none; cursor: pointer; padding: 0 2px; font-size: var(--fs-ui-sm); color: var(--text); } +.cockpit-tree-title { border: none; background: none; padding: 0; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; } +.cockpit-badge { font-size: var(--fs-ui-sm); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0 4px; color: var(--text-muted, var(--text)); } +.cockpit-tree-time { margin-left: auto; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); } +.cockpit-tree-empty, .cockpit-tree-more { border: none; background: none; font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 2px 4px; } +button.cockpit-tree-more { cursor: pointer; text-decoration: underline; } +.cockpit-truncated { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); padding: 4px 0; } +.cockpit-center { min-height: 0; overflow-y: auto; } +.cockpit-doclist { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; } +.cockpit-docrow { display: flex; align-items: baseline; gap: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-panel); padding: 8px 10px; cursor: pointer; font: inherit; text-align: left; } +.cockpit-docrow-title { font-size: var(--fs-ui-base, var(--fs-ui-sm)); color: var(--text); } +.cockpit-docrow-meta { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); } +.cockpit-changes { display: flex; flex-direction: column; gap: 4px; } +.cockpit-changerow { display: flex; justify-content: space-between; gap: 10px; font-size: var(--fs-ui-sm); } +.cockpit-changesum { color: var(--text); } +.cockpit-changetime { color: var(--text-muted, var(--text)); } +.cockpit-nodehead { display: flex; align-items: baseline; gap: 10px; padding-bottom: 8px; } +.cockpit-back { border: none; background: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); padding: 0; text-decoration: underline; } +.cockpit-nodetitle { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); } +.cockpit-nodesum { font-size: var(--fs-ui-sm); color: var(--text-muted, var(--text)); } +.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; } } diff --git a/src/workspaces.ts b/src/workspaces.ts index 8e99a45..0e9f0d5 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -54,9 +54,27 @@ export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null return RDM_AUDIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true)) } +/** 文档驾驶舱消费的全部 rdm.cockpit.* 命令(I1 只读面;rollout I2)。 */ +export const COCKPIT_COMMANDS = [ + 'rdm.cockpit.overview', + 'rdm.cockpit.tree', + 'rdm.cockpit.node', + 'rdm.cockpit.node.sources', + 'rdm.cockpit.node.history', + 'rdm.cockpit.node.impacts', + 'rdm.cockpit.node.conflicts', +] as const + +/** 七命令齐备且 enabled 才展示驾驶舱入口(与 rdmAuditReady 同源语义)。 */ +export function cockpitReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean { + if (!Array.isArray(cmds)) return false + return COCKPIT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true)) +} + const BUILTINS: Omit[] = [ { key: 'agents', title: '代理', icon: '🤖' }, { key: 'rdm-audit', title: '追溯审计', icon: '🔍' }, + { key: 'rdm-cockpit', title: '文档驾驶舱', icon: '📋' }, ] function shortcutNum(raw?: string): number | null { @@ -70,6 +88,8 @@ function shortcutNum(raw?: string): number | null { interface BuildWorkspaceOptions { /** rdm.inspect.* 命令面探测通过(store.rdmAuditAvailable)。默认 false:能力未知/缺失不展示审计入口。 */ rdmAudit?: boolean + /** rdm.cockpit.* 命令面探测通过(store.rdmCockpitAvailable)。默认 false:能力未知/缺失不展示驾驶舱入口。 */ + rdmCockpit?: boolean } export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWorkspaceOptions = {}): WorkspaceEntry[] { @@ -101,6 +121,7 @@ export function buildWorkspaceEntries(server: WorkspaceSource[], opts: BuildWork } ranked.push({ rank: takeFree(1), entry: BUILTINS[0] }) if (opts.rdmAudit === true) ranked.push({ rank: takeFree(2), entry: BUILTINS[1] }) + if (opts.rdmCockpit === true) ranked.push({ rank: takeFree(3), entry: BUILTINS[2] }) for (const entry of unranked) ranked.push({ rank: takeFree(9), entry }) return ranked .filter(item => item.rank <= 9)