From 814643f27796bff013d483487e4085e33f7c63ff Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 17:04:06 +0000 Subject: [PATCH 01/20] Present RDM audit records in business language Why: Project members need to understand who changed what, why, and with what impact without reading operation codes or raw audit payloads. Evidence: A RED presentation test first failed on the missing humanization layer; 276 frontend tests passed, design guard 10 checks passed, knip stayed at baseline 43, and npm run build passed. Impact: Humanize tree, relation, state, and operation vocabulary; render structured reason, impact, source, and outcome fields; raw event detail is no longer shown in the audit timeline. Refs: TT-U01, TT-U02, TT-U03, TT-U04; two trees one graph stage-five plan --- src/components/RdmAuditWorkspace.tsx | 81 ++++++++++------ src/rdmAuditPresentation.test.ts | 57 ++++++++++++ src/rdmAuditPresentation.ts | 134 +++++++++++++++++++++++++++ src/styles.css | 19 +++- 4 files changed, 260 insertions(+), 31 deletions(-) create mode 100644 src/rdmAuditPresentation.test.ts create mode 100644 src/rdmAuditPresentation.ts diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index a3b2e88..b2e152a 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -5,6 +5,10 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { api } from '../api' import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui' import { classifyRdmError } from '../rdmErrors' +import { + describeAuditEvent, humanizeKind, humanizeRelation, humanizeState, + type AuditEventViewInput, +} from '../rdmAuditPresentation' import { store, useStore } from '../store' import { exportNeedsDraft } from '../workspaces' @@ -17,7 +21,7 @@ interface ListShell { items: T[]; nextCursor?: string; hasMore: boolean interface SummaryData { projectId: string; counts: Record; watermark: number; lastOpAt: string; coverage?: CoverageItem[] } interface TreeItem { kind: string; id: string; title: string; state?: string } interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number } -interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string } +interface EventDTO extends AuditEventViewInput {} interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] } interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] } interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean } @@ -32,6 +36,18 @@ const TABS = [ ] as const type TabId = (typeof TABS)[number]['id'] +const OPERATION_FILTERS = [ + ['', '全部操作'], + ['doc.instantiate', '创建文档实例'], + ['doc.section.add', '新增文档章节'], + ['note.add', '新增项目知识'], + ['relation.add', '建立追溯关系'], + ['proposal.create', '提出结构变更'], + ['proposal.apply', '应用结构变更'], + ['risk.create', '发现项目风险'], + ['risk.answer', '回答风险澄清'], +] as const + /** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断; * 「无可审计项目」由调用处独立空态承担,不走本组件。 */ function RdmErrorState({ raw }: { raw: string }) { @@ -347,9 +363,9 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { {item.kind === 'doc' && children[item.id] && (
@@ -357,9 +373,9 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { {children[item.id].map(sub => ( ))}
@@ -440,7 +456,7 @@ function TraceTab({ pid }: { pid: string }) {
{sel.title} - {sel.kind}:{sel.id} + {humanizeKind(sel.kind)} · {sel.id}
{err && } {!relations && !err &&
加载中…
} @@ -464,12 +480,13 @@ function TraceTab({ pid }: { pid: string }) { function RelationRow({ r }: { r: RelationEntry }) { const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to) + const readable = humanizeRelation(r) return (
- {r.layer} - {r.type || r.kind || '关联'} - {other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''} - {r.state} + {readable.layer} + {readable.type} + {humanizeKind(other?.kind)}“{other?.title || other?.id || '未知对象'}”{other?.ver ? `(版本 ${other.ver})` : ''} + {readable.state}
) } @@ -521,8 +538,9 @@ function TimelineTab({ pid }: { pid: string }) {
setFilterActor(e.target.value)} /> - setFilterOp(e.target.value)} /> + @@ -573,27 +591,38 @@ function TimelineTab({ pid }: { pid: string }) { } function OpGroupView({ group }: { group: OpGroup }) { - const [open, setOpen] = useState(false) + const first = group.events[0] ? describeAuditEvent(group.events[0]) : null return (
- - {open && group.events.map(ev => )} + + {first?.subject || group.actor} + {first?.action || '项目操作'} + {new Date(group.at).toLocaleString('zh-CN')} + 包含 {group.events.length} 项记录 + + )}> + {group.events.map(ev => )} +
) } function EventRow({ ev }: { ev: EventDTO }) { + const view = describeAuditEvent(ev) return (
- {new Date(ev.at).toLocaleTimeString('zh-CN')} - {ev.op} - {ev.actor} - {ev.detail && {ev.detail.slice(0, 80)}} - {ev.disclosure !== 'ok' && {ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}} +
+ {new Date(ev.at).toLocaleString('zh-CN')} + {view.action} + {view.outcome} +
+
{view.subject}处理了{view.object}
+
+
原因
{view.reason}
+
影响
{view.impact}
+
依据
{view.source}
+
) } @@ -604,8 +633,8 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) { return (
{detail.title || detail.id}
-
{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}
- {detail.state &&
状态:{detail.state}
} +
{humanizeKind(detail.kind)} · 对象编号 {detail.id}{detail.ver ? ` · 版本 ${detail.ver}` : ''}
+ {detail.state &&
状态:{humanizeState(detail.state)}
} {detail.labels &&
标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}
} {detail.versions && detail.versions.length > 0 && (
diff --git a/src/rdmAuditPresentation.test.ts b/src/rdmAuditPresentation.test.ts new file mode 100644 index 0000000..fbcc6ce --- /dev/null +++ b/src/rdmAuditPresentation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { describeAuditEvent, humanizeKind, humanizeRelation, humanizeState } from './rdmAuditPresentation' + +describe('RDM audit presentation', () => { + it('turns a structured document event into a readable action and impact', () => { + expect(describeAuditEvent({ + seq: 18, + at: '2026-10-15T08:00:00Z', + op: 'doc.instantiate', + actor: 'alice', + source: 'template:dhf-proposal@v2', + target: { kind: 'doc', id: 'doc-1', title: '项目建议书' }, + detail: '{"raw":"must not be shown"}', + operationId: 'op-1', + disclosure: 'ok', + result: 'committed', + impact: '新增 12 个递归节点', + reason: '从已发布模板建立项目实例', + })).toEqual({ + action: '创建文档实例', + subject: 'Alice', + object: '文档“项目建议书”', + outcome: '已生效', + reason: '从已发布模板建立项目实例', + impact: '新增 12 个递归节点', + source: '模板:DHF 项目建议书(版本 v2)', + }) + }) + + it('does not expose unknown operation codes or raw JSON as user-facing text', () => { + const view = describeAuditEvent({ + seq: 19, + at: '2026-10-15T08:01:00Z', + op: 'internal.weird_opcode', + actor: 'user-42', + source: 'host', + target: { kind: 'node', id: 'node-secret' }, + detail: '{"secret":"raw payload"}', + operationId: 'op-2', + disclosure: 'restricted', + }) + expect(view.action).toBe('更新项目内容') + expect(JSON.stringify(view)).not.toContain('internal.weird_opcode') + expect(JSON.stringify(view)).not.toContain('raw payload') + expect(view.outcome).toBe('部分信息受限') + }) + + it('humanizes tree, relationship, and lifecycle vocabulary', () => { + expect(humanizeKind('section')).toBe('章节') + expect(humanizeState('candidate')).toBe('待确认') + expect(humanizeRelation({ layer: 'compliance', type: 'verifies', state: 'active' })).toEqual({ + layer: '合规关系', + type: '验证', + state: '已生效', + }) + }) +}) diff --git a/src/rdmAuditPresentation.ts b/src/rdmAuditPresentation.ts new file mode 100644 index 0000000..3c15f3c --- /dev/null +++ b/src/rdmAuditPresentation.ts @@ -0,0 +1,134 @@ +export interface AuditEventViewInput { + seq: number + at: string + op: string + actor: string + source: string + target: { kind: string; id: string; title?: string } + detail?: string + operationId: string + disclosure: string + summary?: string + result?: string + reason?: string + impact?: string +} + +interface AuditEventPresentation { + action: string + subject: string + object: string + outcome: string + reason: string + impact: string + source: string +} + +const KIND_LABELS: Record = { + doc: '文档', section: '章节', node: '节点', note: '知识', slice: '资料切片', + requirement: '要求', risk: '风险', proposal: '变更提案', relation: '追溯关系', + template: '模板', project: '项目', conclusion: '结论', issue: '问题', +} + +const STATE_LABELS: Record = { + active: '已生效', candidate: '待确认', pending: '待处理', baselined: '已建立基线', + approved: '已批准', rejected: '已拒绝', revoked: '已撤销', superseded: '已被取代', + failed: '失败', committed: '已生效', accepted: '已接受', cancelled: '已取消', + unknown: '结果未知', restricted: '部分信息受限', +} + +const OP_LABELS: Record = { + 'doc.instantiate': '创建文档实例', + 'doc.section.add': '新增文档章节', + 'doc.section.update': '更新文档章节', + 'doc.node.add': '新增文档节点', + 'doc.node.update': '更新文档节点', + 'doc.approve': '批准文档版本', + 'note.add': '新增项目知识', + 'note.update': '更新项目知识', + 'relation.add': '建立追溯关系', + 'relation.invalidate': '标记关系待复核', + 'proposal.create': '提出结构变更', + 'proposal.apply': '应用结构变更', + 'proposal.reject': '拒绝结构变更', + 'risk.create': '发现项目风险', + 'risk.answer': '回答风险澄清', + 'risk.resolve': '完成风险处理', + 'req.add': '新增项目要求', + 'req.update': '更新项目要求', +} + +const RELATION_LABELS: Record = { + verifies: '验证', supports: '支持', derives: '派生自', impacts: '影响', + traces: '追溯到', replaces: '取代', conflicts: '存在冲突', references: '引用', +} + +const LAYER_LABELS: Record = { + compliance: '合规关系', knowledge: '知识关系', document: '文档关系', project: '项目关系', +} + +export function humanizeKind(kind: string): string { + return KIND_LABELS[kind] || '项目对象' +} + +export function humanizeState(state: string): string { + return STATE_LABELS[state] || '状态已记录' +} + +function humanizeOperation(op: string): string { + if (OP_LABELS[op]) return OP_LABELS[op] + if (op.startsWith('doc.')) return '更新文档内容' + if (op.startsWith('note.') || op.startsWith('knowledge.')) return '更新项目知识' + if (op.startsWith('relation.') || op.startsWith('trace.')) return '更新追溯关系' + if (op.startsWith('risk.')) return '处理项目风险' + if (op.startsWith('proposal.')) return '处理结构变更' + return '更新项目内容' +} + +function humanizeActor(actor: string): string { + if (!actor) return '未知成员' + if (/^[a-z][a-z0-9._-]*$/i.test(actor) && !actor.includes('-') && !actor.includes('_')) { + return actor.charAt(0).toUpperCase() + actor.slice(1) + } + return actor +} + +function humanizeSource(source: string): string { + if (!source) return '当时未记录来源' + const template = /^template:([^@]+)@(.+)$/.exec(source) + if (template) { + const title = template[1] === 'dhf-proposal' ? 'DHF 项目建议书' : template[1] + return `模板:${title}(版本 ${template[2]})` + } + if (source === 'host') return '系统受信操作记录' + if (source.startsWith('message:')) return '项目对话' + if (source.startsWith('artifact:')) return '项目文件' + return '已登记项目来源' +} + +function humanizeOutcome(input: AuditEventViewInput): string { + if (input.disclosure !== 'ok') return input.disclosure === 'model-candidate' ? '模型候选,尚未确认' : '部分信息受限' + return humanizeState(input.result || 'committed') +} + +export function describeAuditEvent(input: AuditEventViewInput): AuditEventPresentation { + const targetTitle = input.target?.title ? `“${input.target.title}”` : '' + return { + action: input.summary || humanizeOperation(input.op), + subject: humanizeActor(input.actor), + object: `${humanizeKind(input.target?.kind)}${targetTitle}`, + outcome: humanizeOutcome(input), + reason: input.reason || '未记录具体原因', + impact: input.impact || '影响范围尚未记录', + source: humanizeSource(input.source), + } +} + +export function humanizeRelation(input: { layer?: string; type?: string; kind?: string; state?: string }) { + const type = input.type || input.kind || '' + return { + layer: LAYER_LABELS[input.layer || ''] || '项目关系', + type: RELATION_LABELS[type] || '关联', + state: humanizeState(input.state || ''), + } +} diff --git a/src/styles.css b/src/styles.css index 54016a1..9aa1e01 100644 --- a/src/styles.css +++ b/src/styles.css @@ -625,12 +625,17 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; } .rdm-audit-opgroup-head:hover { background: var(--bg-hover); } .rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); } +.rdm-audit-opgroup-action { color: var(--text-dim); } .rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); } -.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); } -.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; } -.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; } -.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; } -.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); } +.rdm-audit-event { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); } +.rdm-audit-event-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-dim); } +.rdm-audit-event-action { color: var(--text); } +.rdm-audit-event-summary { color: var(--text); } +.rdm-audit-event-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; } +.rdm-audit-event-fields > div { min-width: 0; padding: 8px; background: var(--bg-raised); border-radius: var(--radius-sm); } +.rdm-audit-event-fields dt { color: var(--text-dim); font-size: var(--fs-ui-xs); } +.rdm-audit-event-fields dd { margin: 3px 0 0; color: var(--text); overflow-wrap: anywhere; } .rdm-audit-coverage { margin-top: 12px; } .rdm-audit-export { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; } .rdm-audit-export-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; } @@ -644,3 +649,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .rdm-audit-err { display: flex; flex-direction: column; gap: 8px; } .rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); } .rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; } +@media (max-width: 760px) { + .rdm-audit-event-fields { grid-template-columns: 1fr; } + .rdm-audit-trace-cols { grid-template-columns: 1fr; } +} From b52f9654a02a0fd6a49ef012d70e69df5c83c4bc Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 17:08:38 +0000 Subject: [PATCH 02/20] Render both project trees recursively Why: Project document and knowledge structures can contain nested nodes whose children must remain navigable beyond the first section level. Evidence: A RED test first showed child-bearing sections were not expandable; presentation and design-guard tests, knip baseline 43, and npm run build now pass. Impact: Recursively render any node marked with children while preserving current document fallback and readable node states. Refs: TT-N01, TT-N02, TT-D01, TT-U01; two trees one graph vertical slice --- src/components/RdmAuditWorkspace.tsx | 62 +++++++++++++++------------- src/rdmAuditPresentation.test.ts | 8 +++- src/rdmAuditPresentation.ts | 4 ++ 3 files changed, 44 insertions(+), 30 deletions(-) diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index b2e152a..b446b7d 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -6,7 +6,7 @@ import { api } from '../api' import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui' import { classifyRdmError } from '../rdmErrors' import { - describeAuditEvent, humanizeKind, humanizeRelation, humanizeState, + describeAuditEvent, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable, type AuditEventViewInput, } from '../rdmAuditPresentation' import { store, useStore } from '../store' @@ -19,7 +19,7 @@ interface Capabilities { actor: string; projects: InspectProject[]; crossProject interface CoverageItem { face: string; reason: string } interface ListShell { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] } interface SummaryData { projectId: string; counts: Record; watermark: number; lastOpAt: string; coverage?: CoverageItem[] } -interface TreeItem { kind: string; id: string; title: string; state?: string } +interface TreeItem { kind: string; id: string; title: string; state?: string; hasChildren?: boolean } interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number } interface EventDTO extends AuditEventViewInput {} interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] } @@ -331,19 +331,20 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { .catch(e => { if (gen === genRef.current) setErr(e.message) }) } - const toggleDoc = (item: TreeItem) => { + const toggleNode = (item: TreeItem) => { if (children[item.id]) { setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next }) return } const gen = genRef.current - api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' }) + api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind, parent: item.id, cursor: '' }) .then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) }) .catch(e => { if (gen === genRef.current) setErr(e.message) }) } const selectNode = (item: TreeItem) => { - if (item.kind === 'doc') { toggleDoc(item); return } + if (isTreeItemExpandable(item)) toggleNode(item) + if (item.kind === 'doc') return setSelected(item); setDetail(null) const gen = genRef.current api.rdmCommand('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id }) @@ -358,30 +359,7 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { return (
- {shell.items.map(item => ( -
- - {item.kind === 'doc' && children[item.id] && ( -
- {children[item.id].length === 0 &&
无章节
} - {children[item.id].map(sub => ( - - ))} -
- )} -
- ))} + {shell.items.map(item => )} {shell.hasMore && } {shell.truncated &&
已截断(深度或数量上限)
}
@@ -395,6 +373,32 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { ) } +function TreeNodeRow({ item, selectedId, children, onSelect }: { + item: TreeItem + selectedId?: string + children: Record + onSelect: (item: TreeItem) => void +}) { + const expandable = isTreeItemExpandable(item) + const nested = children[item.id] + return ( +
+ + {nested && ( +
+ {nested.length === 0 &&
无下级节点
} + {nested.map(child => )} +
+ )} +
+ ) +} + // ---- 关联追溯 ---- function TraceTab({ pid }: { pid: string }) { diff --git a/src/rdmAuditPresentation.test.ts b/src/rdmAuditPresentation.test.ts index fbcc6ce..8616370 100644 --- a/src/rdmAuditPresentation.test.ts +++ b/src/rdmAuditPresentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { describeAuditEvent, humanizeKind, humanizeRelation, humanizeState } from './rdmAuditPresentation' +import { describeAuditEvent, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable } from './rdmAuditPresentation' describe('RDM audit presentation', () => { it('turns a structured document event into a readable action and impact', () => { @@ -54,4 +54,10 @@ describe('RDM audit presentation', () => { state: '已生效', }) }) + + it('allows recursive sections to expand when the server reports children', () => { + expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-1', title: '文档' })).toBe(true) + expect(isTreeItemExpandable({ kind: 'section', id: 'sec-1', title: '章节', hasChildren: true })).toBe(true) + expect(isTreeItemExpandable({ kind: 'paragraph', id: 'p-1', title: '段落', hasChildren: false })).toBe(false) + }) }) diff --git a/src/rdmAuditPresentation.ts b/src/rdmAuditPresentation.ts index 3c15f3c..2618676 100644 --- a/src/rdmAuditPresentation.ts +++ b/src/rdmAuditPresentation.ts @@ -132,3 +132,7 @@ export function humanizeRelation(input: { layer?: string; type?: string; kind?: state: humanizeState(input.state || ''), } } + +export function isTreeItemExpandable(item: { kind: string; hasChildren?: boolean; id?: string; title?: string }): boolean { + return item.kind === 'doc' || item.hasChildren === true +} From c9b55eb502b47beaea2183bc1f15f2c2916ab7fb Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 17:38:42 +0000 Subject: [PATCH 03/20] Name untitled template nodes clearly Why: Recursive template nodes without titles were rendered as repeated generic project objects in the audit tree. Evidence: Focused presentation tests pass, including a regression for stable readable fallback names. Impact: Template-derived paragraphs, tables, rows, cells, fields, and attachments remain distinguishable in the document tree. Refs: rdm-two-trees-latest-templates --- src/components/RdmAuditWorkspace.tsx | 17 +++++++++++++---- src/rdmAuditPresentation.test.ts | 16 +++++++++++++++- src/rdmAuditPresentation.ts | 24 ++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index b446b7d..44860f5 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -6,7 +6,7 @@ import { api } from '../api' import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui' import { classifyRdmError } from '../rdmErrors' import { - describeAuditEvent, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable, + describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable, type AuditEventViewInput, } from '../rdmAuditPresentation' import { store, useStore } from '../store' @@ -19,7 +19,15 @@ interface Capabilities { actor: string; projects: InspectProject[]; crossProject interface CoverageItem { face: string; reason: string } interface ListShell { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] } interface SummaryData { projectId: string; counts: Record; watermark: number; lastOpAt: string; coverage?: CoverageItem[] } -interface TreeItem { kind: string; id: string; title: string; state?: string; hasChildren?: boolean } +interface TreeItem { + kind: string + id: string + title: string + state?: string + hasChildren?: boolean + order?: number + templateBlockId?: string +} interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number } interface EventDTO extends AuditEventViewInput {} interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] } @@ -381,12 +389,13 @@ function TreeNodeRow({ item, selectedId, children, onSelect }: { }) { const expandable = isTreeItemExpandable(item) const nested = children[item.id] + const readable = describeTreeItem(item) return (
{nested && ( diff --git a/src/rdmAuditPresentation.test.ts b/src/rdmAuditPresentation.test.ts index 8616370..0b7ac6a 100644 --- a/src/rdmAuditPresentation.test.ts +++ b/src/rdmAuditPresentation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { describeAuditEvent, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable } from './rdmAuditPresentation' +import { describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable } from './rdmAuditPresentation' describe('RDM audit presentation', () => { it('turns a structured document event into a readable action and impact', () => { @@ -60,4 +60,18 @@ describe('RDM audit presentation', () => { expect(isTreeItemExpandable({ kind: 'section', id: 'sec-1', title: '章节', hasChildren: true })).toBe(true) expect(isTreeItemExpandable({ kind: 'paragraph', id: 'p-1', title: '段落', hasChildren: false })).toBe(false) }) + + it('gives untitled template nodes a distinct readable name', () => { + expect(describeTreeItem({ + kind: 'paragraph', + id: 'dn-1', + title: '', + order: 6, + templateBlockId: 'dhf.dx.a7850d1881.3.placeholder', + })).toEqual({ kind: '段落', title: '模板内容 7(3)' }) + expect(describeTreeItem({ kind: 'table', id: 'dn-2', title: '', order: 2 })).toEqual({ + kind: '表格', + title: '模板内容 3', + }) + }) }) diff --git a/src/rdmAuditPresentation.ts b/src/rdmAuditPresentation.ts index 2618676..2c2093e 100644 --- a/src/rdmAuditPresentation.ts +++ b/src/rdmAuditPresentation.ts @@ -26,6 +26,8 @@ interface AuditEventPresentation { const KIND_LABELS: Record = { doc: '文档', section: '章节', node: '节点', note: '知识', slice: '资料切片', + paragraph: '段落', table: '表格', row: '表格行', cell: '表格单元格', + field: '填写字段', attachment: '附件', requirement: '要求', risk: '风险', proposal: '变更提案', relation: '追溯关系', template: '模板', project: '项目', conclusion: '结论', issue: '问题', } @@ -71,6 +73,28 @@ export function humanizeKind(kind: string): string { return KIND_LABELS[kind] || '项目对象' } +export function describeTreeItem(item: { + kind: string + id: string + title?: string + order?: number + templateBlockId?: string +}): { kind: string; title: string } { + const title = item.title?.trim() + if (title) return { kind: humanizeKind(item.kind), title } + + const position = typeof item.order === 'number' ? item.order + 1 : undefined + const parts = (item.templateBlockId || '').split('.').filter(Boolean) + const semantic = new Set(['placeholder', 'example', 'fixed']) + while (parts.length > 0 && semantic.has(parts[parts.length - 1])) parts.pop() + const hint = parts.at(-1) + const numbered = position ? `模板内容 ${position}` : '未命名模板内容' + return { + kind: humanizeKind(item.kind), + title: hint ? `${numbered}(${hint})` : numbered, + } +} + export function humanizeState(state: string): string { return STATE_LABELS[state] || '状态已记录' } From b921bef6a65caf78b88ce7c951381f3cf7ed3a38 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 17:42:45 +0000 Subject: [PATCH 04/20] Show the opposite trace endpoint Why: The trace view selected an endpoint by lexical ID order and could present the current section as its own source. Evidence: Focused presentation tests cover both selected directions of a source-to-section relationship. Impact: Source and impact columns now identify the object opposite the selected node. Refs: rdm-two-trees-latest-templates --- src/components/RdmAuditWorkspace.tsx | 9 +++++---- src/rdmAuditPresentation.test.ts | 14 +++++++++++++- src/rdmAuditPresentation.ts | 9 +++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index 44860f5..c4871b9 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -7,6 +7,7 @@ import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui' import { classifyRdmError } from '../rdmErrors' import { describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable, + relationOtherEndpoint, type AuditEventViewInput, } from '../rdmAuditPresentation' import { store, useStore } from '../store' @@ -478,11 +479,11 @@ function TraceTab({ pid }: { pid: string }) {
来源依据({sources.length}) - {sources.length === 0 ?
无来源
: sources.map((r, i) => )} + {sources.length === 0 ?
无来源
: sources.map((r, i) => )}
影响对象({impacts.length}) - {impacts.length === 0 ?
无影响
: impacts.map((r, i) => )} + {impacts.length === 0 ?
无影响
: impacts.map((r, i) => )}
)} @@ -491,8 +492,8 @@ function TraceTab({ pid }: { pid: string }) { ) } -function RelationRow({ r }: { r: RelationEntry }) { - const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to) +function RelationRow({ r, selected }: { r: RelationEntry; selected: { kind: string; id: string } }) { + const other = relationOtherEndpoint(r, selected) const readable = humanizeRelation(r) return (
diff --git a/src/rdmAuditPresentation.test.ts b/src/rdmAuditPresentation.test.ts index 0b7ac6a..d616921 100644 --- a/src/rdmAuditPresentation.test.ts +++ b/src/rdmAuditPresentation.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable } from './rdmAuditPresentation' +import { + describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState, + isTreeItemExpandable, relationOtherEndpoint, +} from './rdmAuditPresentation' describe('RDM audit presentation', () => { it('turns a structured document event into a readable action and impact', () => { @@ -74,4 +77,13 @@ describe('RDM audit presentation', () => { title: '模板内容 3', }) }) + + it('shows the endpoint opposite the selected object in a trace relationship', () => { + const relation = { + from: { kind: 'slice', id: 'slc-6' }, + to: { kind: 'section', id: 'sec-2' }, + } + expect(relationOtherEndpoint(relation, { kind: 'section', id: 'sec-2' })).toEqual(relation.from) + expect(relationOtherEndpoint(relation, { kind: 'slice', id: 'slc-6' })).toEqual(relation.to) + }) }) diff --git a/src/rdmAuditPresentation.ts b/src/rdmAuditPresentation.ts index 2c2093e..fd7da48 100644 --- a/src/rdmAuditPresentation.ts +++ b/src/rdmAuditPresentation.ts @@ -157,6 +157,15 @@ export function humanizeRelation(input: { layer?: string; type?: string; kind?: } } +export function relationOtherEndpoint( + relation: { from?: T; to?: T }, + selected: { kind: string; id: string }, +): T | undefined { + if (relation.to?.kind === selected.kind && relation.to.id === selected.id) return relation.from + if (relation.from?.kind === selected.kind && relation.from.id === selected.id) return relation.to + return relation.from || relation.to +} + export function isTreeItemExpandable(item: { kind: string; hasChildren?: boolean; id?: string; title?: string }): boolean { return item.kind === 'doc' || item.hasChildren === true } From 505aba36c1daafdef1b9348040483bb75d9a00e9 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 17:43:23 +0000 Subject: [PATCH 05/20] Reuse trace endpoint context in details Why: Object details render the same relationship row and must identify the endpoint relative to that object. Evidence: Type checking identified the missing selected-object context after the endpoint fix. Impact: Relationship rendering is consistent in the trace view and object details. Refs: rdm-two-trees-latest-templates --- src/components/RdmAuditWorkspace.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index c4871b9..cdf2504 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -668,7 +668,7 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) { {detail.relations && detail.relations.length > 0 && (
一跳关系({detail.relations.length}) - {detail.relations.map((r, i) => )} + {detail.relations.map((r, i) => )}
)} {detail.recentEvents && detail.recentEvents.length > 0 && ( From ad503c9535b79912a0f6d473208116309c6818b8 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 06:19:33 +0000 Subject: [PATCH 06/20] Complete recursive audit tree navigation Why: Review found unsupported node detail calls, discarded child pages, shallow trace selection, stale detail responses, invalid operation filters, and missing audit identity. Evidence: 35 frontend test files with 285 tests pass; production build, knip baseline, and diff check pass. Impact: Both trees paginate recursively, trace selection covers every depth, and audit rows preserve valid operation, object, and source semantics. Refs: rdm-two-trees-latest-templates --- src/components/RdmAuditWorkspace.tsx | 112 +++++++++++++++------------ src/rdmAuditPresentation.test.ts | 32 +++++++- src/rdmAuditPresentation.ts | 55 +++++++------ src/rdmAuditTree.test.ts | 42 ++++++++++ src/rdmAuditTree.ts | 59 ++++++++++++++ 5 files changed, 229 insertions(+), 71 deletions(-) create mode 100644 src/rdmAuditTree.test.ts create mode 100644 src/rdmAuditTree.ts diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index cdf2504..0483550 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -10,6 +10,10 @@ import { relationOtherEndpoint, type AuditEventViewInput, } from '../rdmAuditPresentation' +import { + collectRecursiveTree, isTreeItemInspectable, mergeTreePage, + type AuditTreeItem, type AuditTreePage, +} from '../rdmAuditTree' import { store, useStore } from '../store' import { exportNeedsDraft } from '../workspaces' @@ -20,15 +24,7 @@ interface Capabilities { actor: string; projects: InspectProject[]; crossProject interface CoverageItem { face: string; reason: string } interface ListShell { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] } interface SummaryData { projectId: string; counts: Record; watermark: number; lastOpAt: string; coverage?: CoverageItem[] } -interface TreeItem { - kind: string - id: string - title: string - state?: string - hasChildren?: boolean - order?: number - templateBlockId?: string -} +type TreeItem = AuditTreeItem interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number } interface EventDTO extends AuditEventViewInput {} interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] } @@ -48,13 +44,14 @@ type TabId = (typeof TABS)[number]['id'] const OPERATION_FILTERS = [ ['', '全部操作'], ['doc.instantiate', '创建文档实例'], - ['doc.section.add', '新增文档章节'], - ['note.add', '新增项目知识'], - ['relation.add', '建立追溯关系'], - ['proposal.create', '提出结构变更'], - ['proposal.apply', '应用结构变更'], - ['risk.create', '发现项目风险'], - ['risk.answer', '回答风险澄清'], + ['section.add', '新增文档章节'], + ['slice.ingest', '新增项目资料'], + ['edge.candidate', '提出追溯候选'], + ['edge.confirm', '确认追溯关系'], + ['change.requested', '提出变更申请'], + ['change.applied', '应用变更'], + ['open-item.add', '登记项目问题'], + ['open-item.close', '关闭项目问题'], ] as const /** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断; @@ -319,14 +316,15 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { const [err, setErr] = useState('') const [selected, setSelected] = useState(null) const [detail, setDetail] = useState(null) - // docs 树是层级结构(rdm.inspect.tree parent= 才返回章节):文档节点 - // 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。 - const [children, setChildren] = useState>({}) + const [detailErr, setDetailErr] = useState('') + const [children, setChildren] = useState>({}) const genRef = useRef(0) + const detailGenRef = useRef(0) useEffect(() => { const gen = ++genRef.current - setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({}) + detailGenRef.current++ + setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({}) api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' }) .then(s => { if (gen === genRef.current) setShell(s) }) .catch(e => { if (gen === genRef.current) setErr(e.message) }) @@ -347,18 +345,35 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { } const gen = genRef.current api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind, parent: item.id, cursor: '' }) - .then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) }) + .then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s })) }) + .catch(e => { if (gen === genRef.current) setErr(e.message) }) + } + + const loadMoreChildren = (item: TreeItem) => { + const current = children[item.id] + if (!current?.nextCursor) return + const gen = genRef.current + api.rdmCommand>('rdm.inspect.tree', { + projectId: pid, kind, parent: item.id, cursor: current.nextCursor, + }) + .then(s => { + if (gen !== genRef.current) return + setChildren(prev => ({ ...prev, [item.id]: mergeTreePage(prev[item.id], s) })) + }) .catch(e => { if (gen === genRef.current) setErr(e.message) }) } const selectNode = (item: TreeItem) => { if (isTreeItemExpandable(item)) toggleNode(item) - if (item.kind === 'doc') return - setSelected(item); setDetail(null) - const gen = genRef.current + const detailGen = ++detailGenRef.current + if (!isTreeItemInspectable(item)) { + setSelected(null); setDetail(null); setDetailErr('') + return + } + setSelected(item); setDetail(null); setDetailErr('') api.rdmCommand('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id }) - .then(d => { if (gen === genRef.current) setDetail(d) }) - .catch(e => { if (gen === genRef.current) setErr(e.message) }) + .then(d => { if (detailGen === detailGenRef.current) setDetail(d) }) + .catch(e => { if (detailGen === detailGenRef.current) setDetailErr(e.message) }) } if (err) return @@ -368,28 +383,30 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { return (
- {shell.items.map(item => )} + {shell.items.map(item => )} {shell.hasMore && } {shell.truncated &&
已截断(深度或数量上限)
}
{selected && (
对象详情 - {!detail ?
加载中…
: } + {detailErr ? : !detail ?
加载中…
: }
)}
) } -function TreeNodeRow({ item, selectedId, children, onSelect }: { +function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore }: { item: TreeItem selectedId?: string - children: Record + children: Record onSelect: (item: TreeItem) => void + onLoadMore: (item: TreeItem) => void }) { const expandable = isTreeItemExpandable(item) - const nested = children[item.id] + const nestedPage = children[item.id] + const nested = nestedPage?.items const readable = describeTreeItem(item) return (
@@ -402,7 +419,9 @@ function TreeNodeRow({ item, selectedId, children, onSelect }: { {nested && (
{nested.length === 0 &&
无下级节点
} - {nested.map(child => )} + {nested.map(child => )} + {nestedPage.hasMore && } + {nestedPage.truncated &&
该节点的下级内容已截断
}
)}
@@ -427,21 +446,17 @@ function TraceTab({ pid }: { pid: string }) { .catch(e => { if (gen === genRef.current) setErr(e.message) }) }, [pid, sel]) - // 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下 - // (parent=),按文档聚合后合并展示。 - const [sections, setSections] = useState([]) + const [treeItems, setTreeItems] = useState([]) + const [pickErr, setPickErr] = useState('') useEffect(() => { if (sel) return let c = false ;(async () => { - const top = await api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind: 'docs' }) - const secs: TreeItem[] = [] - for (const d of top.items.filter(i => i.kind === 'doc')) { - const sub = await api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: d.id }) - secs.push(...sub.items.filter(i => i.kind === 'section')) - } - if (!c) setSections(secs) - })().catch(() => {}) + setTreeItems([]); setPickErr('') + const items = await collectRecursiveTree((parent, cursor) => + api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent, cursor })) + if (!c) setTreeItems(items.filter(item => item.kind !== 'doc')) + })().catch(e => { if (!c) setPickErr(e instanceof Error ? e.message : String(e)) }) return () => { c = true } }, [pid, sel]) @@ -449,13 +464,14 @@ function TraceTab({ pid }: { pid: string }) { return (
选择对象查看来源与影响 - {sections.length === 0 ? 暂无章节 : ( + {pickErr ? : treeItems.length === 0 ? 暂无可追溯对象 : (
- {sections.map(s => ( - - ))} + })}
)}
diff --git a/src/rdmAuditPresentation.test.ts b/src/rdmAuditPresentation.test.ts index d616921..488242b 100644 --- a/src/rdmAuditPresentation.test.ts +++ b/src/rdmAuditPresentation.test.ts @@ -46,6 +46,35 @@ describe('RDM audit presentation', () => { expect(JSON.stringify(view)).not.toContain('internal.weird_opcode') expect(JSON.stringify(view)).not.toContain('raw payload') expect(view.outcome).toBe('部分信息受限') + expect(view.object).toBe('节点(node-secret)') + expect(view.source).toBe('系统受信操作记录') + }) + + it('preserves domain provenance and object identity in readable audit rows', () => { + expect(describeAuditEvent({ + seq: 20, + at: '2026-10-15T08:02:00Z', + op: 'edge.candidate', + actor: 'model-runner', + source: 'model', + target: { kind: 'edge', id: 'edg-7' }, + operationId: 'op-3', + disclosure: 'model-candidate', + })).toMatchObject({ + action: '提出追溯关系候选', + object: '追溯边(edg-7)', + source: '模型分析候选', + }) + expect(describeAuditEvent({ + seq: 21, + at: '2026-10-15T08:03:00Z', + op: 'section.add', + actor: 'alice', + source: 'human', + target: { kind: 'section', id: 'sec-2' }, + operationId: 'op-4', + disclosure: 'ok', + }).source).toBe('项目成员操作') }) it('humanizes tree, relationship, and lifecycle vocabulary', () => { @@ -59,7 +88,8 @@ describe('RDM audit presentation', () => { }) it('allows recursive sections to expand when the server reports children', () => { - expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-1', title: '文档' })).toBe(true) + expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-1', title: '文档', hasChildren: true })).toBe(true) + expect(isTreeItemExpandable({ kind: 'doc', id: 'doc-2', title: '空文档', hasChildren: false })).toBe(false) expect(isTreeItemExpandable({ kind: 'section', id: 'sec-1', title: '章节', hasChildren: true })).toBe(true) expect(isTreeItemExpandable({ kind: 'paragraph', id: 'p-1', title: '段落', hasChildren: false })).toBe(false) }) diff --git a/src/rdmAuditPresentation.ts b/src/rdmAuditPresentation.ts index fd7da48..830aa7e 100644 --- a/src/rdmAuditPresentation.ts +++ b/src/rdmAuditPresentation.ts @@ -25,7 +25,7 @@ interface AuditEventPresentation { } const KIND_LABELS: Record = { - doc: '文档', section: '章节', node: '节点', note: '知识', slice: '资料切片', + doc: '文档', section: '章节', node: '节点', note: '知识', slice: '资料切片', spine: '知识主题', edge: '追溯边', paragraph: '段落', table: '表格', row: '表格行', cell: '表格单元格', field: '填写字段', attachment: '附件', requirement: '要求', risk: '风险', proposal: '变更提案', relation: '追溯关系', @@ -41,23 +41,27 @@ const STATE_LABELS: Record = { const OP_LABELS: Record = { 'doc.instantiate': '创建文档实例', - 'doc.section.add': '新增文档章节', - 'doc.section.update': '更新文档章节', - 'doc.node.add': '新增文档节点', - 'doc.node.update': '更新文档节点', - 'doc.approve': '批准文档版本', - 'note.add': '新增项目知识', - 'note.update': '更新项目知识', - 'relation.add': '建立追溯关系', - 'relation.invalidate': '标记关系待复核', - 'proposal.create': '提出结构变更', - 'proposal.apply': '应用结构变更', - 'proposal.reject': '拒绝结构变更', - 'risk.create': '发现项目风险', - 'risk.answer': '回答风险澄清', - 'risk.resolve': '完成风险处理', - 'req.add': '新增项目要求', - 'req.update': '更新项目要求', + 'section.add': '新增文档章节', + 'section.version': '更新文档内容', + 'section.state': '变更章节状态', + 'section.tombstone': '停用文档章节', + 'slice.ingest': '新增项目资料', + 'slice.retag': '更新资料标签', + 'slice.tombstone': '停用项目资料', + 'spine.add': '新增知识主题', + 'mount.add': '挂接项目资料', + 'mount.move': '移动项目资料', + 'mount.remove': '移除资料挂接', + 'edge.candidate': '提出追溯关系候选', + 'edge.confirm': '确认追溯关系', + 'edge.retire': '停用追溯关系', + 'change.requested': '提出变更申请', + 'change.approved': '批准变更申请', + 'change.applied': '应用变更', + 'open-item.add': '登记项目问题', + 'open-item.close': '关闭项目问题', + 'dictionary.revise': '修订项目字典', + 'req.change': '更新项目要求', } const RELATION_LABELS: Record = { @@ -102,8 +106,11 @@ export function humanizeState(state: string): string { function humanizeOperation(op: string): string { if (OP_LABELS[op]) return OP_LABELS[op] if (op.startsWith('doc.')) return '更新文档内容' - if (op.startsWith('note.') || op.startsWith('knowledge.')) return '更新项目知识' - if (op.startsWith('relation.') || op.startsWith('trace.')) return '更新追溯关系' + if (op.startsWith('section.')) return '更新文档内容' + if (op.startsWith('slice.') || op.startsWith('spine.') || op.startsWith('mount.') || op.startsWith('knowledge.')) return '更新项目知识' + if (op.startsWith('edge.') || op.startsWith('relation.') || op.startsWith('trace.')) return '更新追溯关系' + if (op.startsWith('change.')) return '处理项目变更' + if (op.startsWith('open-item.')) return '处理项目问题' if (op.startsWith('risk.')) return '处理项目风险' if (op.startsWith('proposal.')) return '处理结构变更' return '更新项目内容' @@ -125,6 +132,9 @@ function humanizeSource(source: string): string { return `模板:${title}(版本 ${template[2]})` } if (source === 'host') return '系统受信操作记录' + if (source === 'human') return '项目成员操作' + if (source === 'rule') return '项目规则推导' + if (source === 'model') return '模型分析候选' if (source.startsWith('message:')) return '项目对话' if (source.startsWith('artifact:')) return '项目文件' return '已登记项目来源' @@ -137,10 +147,11 @@ function humanizeOutcome(input: AuditEventViewInput): string { export function describeAuditEvent(input: AuditEventViewInput): AuditEventPresentation { const targetTitle = input.target?.title ? `“${input.target.title}”` : '' + const targetIdentity = targetTitle || (input.target?.id ? `(${input.target.id})` : '') return { action: input.summary || humanizeOperation(input.op), subject: humanizeActor(input.actor), - object: `${humanizeKind(input.target?.kind)}${targetTitle}`, + object: `${humanizeKind(input.target?.kind)}${targetIdentity}`, outcome: humanizeOutcome(input), reason: input.reason || '未记录具体原因', impact: input.impact || '影响范围尚未记录', @@ -167,5 +178,5 @@ export function relationOtherEndpoint( } export function isTreeItemExpandable(item: { kind: string; hasChildren?: boolean; id?: string; title?: string }): boolean { - return item.kind === 'doc' || item.hasChildren === true + return item.kind === 'spine' || item.hasChildren === true } diff --git a/src/rdmAuditTree.test.ts b/src/rdmAuditTree.test.ts new file mode 100644 index 0000000..235a3cc --- /dev/null +++ b/src/rdmAuditTree.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { collectRecursiveTree, isTreeItemInspectable, mergeTreePage, type AuditTreePage } from './rdmAuditTree' + +describe('RDM audit tree traversal', () => { + it('keeps child cursors and appends later pages', () => { + const first: AuditTreePage = { + items: [{ kind: 'paragraph', id: 'p-1', title: '一' }], + nextCursor: 'next', hasMore: true, truncated: false, + } + const second: AuditTreePage = { + items: [{ kind: 'paragraph', id: 'p-2', title: '二' }], + hasMore: false, truncated: false, + } + expect(mergeTreePage(first, second)).toEqual({ + items: [...first.items, ...second.items], + hasMore: false, + truncated: false, + }) + }) + + it('collects every page and every server-reported depth for trace selection', async () => { + const pages = new Map([ + ['|', { items: [{ kind: 'doc', id: 'doc-1', title: '文档', hasChildren: true }], nextCursor: 'root-2', hasMore: true, truncated: false }], + ['|root-2', { items: [{ kind: 'doc', id: 'doc-2', title: '文档二', hasChildren: false }], hasMore: false, truncated: false }], + ['doc-1|', { items: [{ kind: 'section', id: 'dn-sec', title: '模板章', templateBlockId: 'tpl.sec', hasChildren: true }], nextCursor: 'child-2', hasMore: true, truncated: false }], + ['doc-1|child-2', { items: [{ kind: 'section', id: 'sec-2', title: '正文', hasChildren: false }], hasMore: false, truncated: false }], + ['dn-sec|', { items: [{ kind: 'paragraph', id: 'p-deep', title: '深层内容', hasChildren: false }], hasMore: false, truncated: false }], + ]) + const items = await collectRecursiveTree(async (parent, cursor) => { + const page = pages.get(`${parent}|${cursor}`) + if (!page) throw new Error(`unexpected page ${parent}|${cursor}`) + return page + }) + expect(items.map(item => item.id)).toEqual(['doc-1', 'doc-2', 'dn-sec', 'sec-2', 'p-deep']) + }) + + it('only requests object details for legacy sections supported by the server', () => { + expect(isTreeItemInspectable({ kind: 'section', id: 'sec-2', title: '正文', templateBlockId: '' })).toBe(true) + expect(isTreeItemInspectable({ kind: 'section', id: 'dn-sec', title: '模板章', templateBlockId: 'tpl.sec' })).toBe(false) + expect(isTreeItemInspectable({ kind: 'paragraph', id: 'p-1', title: '内容' })).toBe(false) + }) +}) diff --git a/src/rdmAuditTree.ts b/src/rdmAuditTree.ts new file mode 100644 index 0000000..a0da08c --- /dev/null +++ b/src/rdmAuditTree.ts @@ -0,0 +1,59 @@ +export interface AuditTreeItem { + kind: string + id: string + title: string + state?: string + hasChildren?: boolean + order?: number + templateBlockId?: string +} + +export interface AuditTreePage { + items: AuditTreeItem[] + nextCursor?: string + hasMore: boolean + truncated: boolean +} + +type AuditTreePageFetcher = (parent: string, cursor: string) => Promise + +export function mergeTreePage(current: AuditTreePage, next: AuditTreePage): AuditTreePage { + return { ...next, items: [...current.items, ...next.items] } +} + +async function collectParentPages(fetchPage: AuditTreePageFetcher, parent: string): Promise { + const items: AuditTreeItem[] = [] + const seenCursors = new Set() + let cursor = '' + for (let pageNo = 0; pageNo < 100; pageNo++) { + if (seenCursors.has(cursor)) throw new Error('树分页游标重复,已停止加载') + seenCursors.add(cursor) + const page = await fetchPage(parent, cursor) + items.push(...page.items) + if (!page.hasMore) return items + if (!page.nextCursor) throw new Error('树分页缺少后续游标') + cursor = page.nextCursor + } + throw new Error('树分页超过安全上限') +} + +export async function collectRecursiveTree(fetchPage: AuditTreePageFetcher): Promise { + const roots = await collectParentPages(fetchPage, '') + const out = [...roots] + const queue = roots.filter(item => item.hasChildren === true) + const seenParents = new Set() + while (queue.length > 0) { + const parent = queue.shift()! + if (seenParents.has(parent.id)) continue + seenParents.add(parent.id) + const children = await collectParentPages(fetchPage, parent.id) + out.push(...children) + if (out.length > 5000) throw new Error('树节点超过安全上限') + queue.push(...children.filter(item => item.hasChildren === true)) + } + return out +} + +export function isTreeItemInspectable(item: AuditTreeItem): boolean { + return item.kind === 'section' && !item.templateBlockId && !item.id.startsWith('dn-') +} From 987be7442f50f6919e043030d7c73ae5c89da6e4 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 06:42:16 +0000 Subject: [PATCH 07/20] Show source and risk operations in readable audit Why: Work-source decomposition, detected risks, human answers, and structure decisions were recorded but absent from the audit timeline. Evidence: Focused presentation/tree tests, production build, and knip baseline pass. Impact: Audit filters and rows now include the complete business-operation feed without raw payloads. Refs: TT-N03 TT-R01 W4b --- src/components/RdmAuditWorkspace.tsx | 9 +++++++-- src/rdmAuditPresentation.ts | 1 + 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index 0483550..d58cfaa 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -22,7 +22,7 @@ import { exportNeedsDraft } from '../workspaces' interface InspectProject { projectId: string; name: string; canAudit: boolean } interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean } interface CoverageItem { face: string; reason: string } -interface ListShell { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] } +interface ListShell { items: T[]; businessAudit?: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] } interface SummaryData { projectId: string; counts: Record; watermark: number; lastOpAt: string; coverage?: CoverageItem[] } type TreeItem = AuditTreeItem interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number } @@ -52,6 +52,10 @@ const OPERATION_FILTERS = [ ['change.applied', '应用变更'], ['open-item.add', '登记项目问题'], ['open-item.close', '关闭项目问题'], + ['rdm.work.observe', '拆解工作来源'], + ['rdm.work.risk', '发现项目风险'], + ['rdm.work.risk.answer', '回答风险澄清'], + ['rdm.structure.decide', '决定结构变更'], ] as const /** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断; @@ -544,7 +548,8 @@ function TimelineTab({ pid }: { pid: string }) { api.rdmCommand>('rdm.inspect.events', params) .then(s => { if (gen !== genRef.current) return - setItems(prev => replace ? s.items : [...prev, ...s.items]) + const page = replace ? [...(s.businessAudit || []), ...s.items] : s.items + setItems(prev => replace ? page : [...prev, ...page]) setCursor(s.nextCursor || '') setHasMore(s.hasMore) }) diff --git a/src/rdmAuditPresentation.ts b/src/rdmAuditPresentation.ts index 830aa7e..4202761 100644 --- a/src/rdmAuditPresentation.ts +++ b/src/rdmAuditPresentation.ts @@ -36,6 +36,7 @@ const STATE_LABELS: Record = { active: '已生效', candidate: '待确认', pending: '待处理', baselined: '已建立基线', approved: '已批准', rejected: '已拒绝', revoked: '已撤销', superseded: '已被取代', failed: '失败', committed: '已生效', accepted: '已接受', cancelled: '已取消', + delivered: '已记录', unknown: '结果未知', restricted: '部分信息受限', } From 95e1948a5fe2d869a8c70bbb3925cdee9a7a8d7d Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 06:52:42 +0000 Subject: [PATCH 08/20] Distinguish retired and stale trace relations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Retired edges and compliance edges awaiting review both fell back to a generic lifecycle label. Evidence: Presentation tests, production build, knip, and diff checks pass. Impact: The trace network now shows 已停用 and 待复核 as distinct human-readable states. Refs: readable trace network --- src/components/RdmAuditWorkspace.tsx | 2 +- src/rdmAuditPresentation.test.ts | 2 ++ src/rdmAuditPresentation.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index d58cfaa..c742cbe 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -520,7 +520,7 @@ function RelationRow({ r, selected }: { r: RelationEntry; selected: { kind: stri {readable.layer} {readable.type} {humanizeKind(other?.kind)}“{other?.title || other?.id || '未知对象'}”{other?.ver ? `(版本 ${other.ver})` : ''} - {readable.state} + {readable.state}
) } diff --git a/src/rdmAuditPresentation.test.ts b/src/rdmAuditPresentation.test.ts index 488242b..e359c4d 100644 --- a/src/rdmAuditPresentation.test.ts +++ b/src/rdmAuditPresentation.test.ts @@ -80,6 +80,8 @@ describe('RDM audit presentation', () => { it('humanizes tree, relationship, and lifecycle vocabulary', () => { expect(humanizeKind('section')).toBe('章节') expect(humanizeState('candidate')).toBe('待确认') + expect(humanizeState('retired')).toBe('已停用') + expect(humanizeState('stale')).toBe('待复核') expect(humanizeRelation({ layer: 'compliance', type: 'verifies', state: 'active' })).toEqual({ layer: '合规关系', type: '验证', diff --git a/src/rdmAuditPresentation.ts b/src/rdmAuditPresentation.ts index 4202761..0370386 100644 --- a/src/rdmAuditPresentation.ts +++ b/src/rdmAuditPresentation.ts @@ -34,6 +34,7 @@ const KIND_LABELS: Record = { const STATE_LABELS: Record = { active: '已生效', candidate: '待确认', pending: '待处理', baselined: '已建立基线', + retired: '已停用', stale: '待复核', approved: '已批准', rejected: '已拒绝', revoked: '已撤销', superseded: '已被取代', failed: '失败', committed: '已生效', accepted: '已接受', cancelled: '已取消', delivered: '已记录', From 85428b46336812e71c034cc7ecdc8e7833e8f87b Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 23:44:39 +0000 Subject: [PATCH 09/20] Add document render workspace with profiles, preview, review and downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: unified renderer Task 11 needs a reader/delivery UI over the RDM document tree: four-level rule selection with controlled-document no-fallback explanation, preview with node-linked diagnostics, a structure canvas and a linear reader sharing one selection and filter, review annotations/revisions, and per-delivery downloads. Evidence: npx vitest run -> 35 files, 338 tests passed (45 new in test/document-render-workspace.test.ts, design-guard green); npm run build -> tsc -b + vite build ok; npx knip -> findings identical to the pre-existing baseline (check-knip 43 <= 43); git diff --check clean. e2e/document-renderer.playwright.ts authored, not executed. Impact: new src/documentRenderPresentation.ts (pure helpers) and src/components/DocumentRenderWorkspace.tsx, opened from a per-document "阅读与交付" action in the audit materials tree; store gains an rdmRender capability probe (fail-closed); audit export reuses the shared base64 file saver; token-only styles appended to styles.css. Async state is owned per hook with request guards plus identity epoch, and the workspace remounts per identity/project/document scope. Refs: unified renderer plan Task 11 Co-authored-by: Cursor --- e2e/document-renderer.playwright.ts | 179 ++++ src/components/DocumentRenderWorkspace.tsx | 896 +++++++++++++++++++++ src/components/RdmAuditWorkspace.tsx | 49 +- src/documentRenderPresentation.ts | 769 ++++++++++++++++++ src/store.ts | 9 +- src/styles.css | 124 +++ test/document-render-workspace.test.ts | 432 ++++++++++ 7 files changed, 2431 insertions(+), 27 deletions(-) create mode 100644 e2e/document-renderer.playwright.ts create mode 100644 src/components/DocumentRenderWorkspace.tsx create mode 100644 src/documentRenderPresentation.ts create mode 100644 test/document-render-workspace.test.ts diff --git a/e2e/document-renderer.playwright.ts b/e2e/document-renderer.playwright.ts new file mode 100644 index 0000000..75c7730 --- /dev/null +++ b/e2e/document-renderer.playwright.ts @@ -0,0 +1,179 @@ +// 统一渲染 Task 11 运行验收:真实产品走通「资料与文档 → 阅读与交付」。 +// 驱动方:Dev 仓 scripts/e2e.sh(同 rdm-audit.playwright.ts 的环境契约); +// PLAYWRIGHT_BASE_URL 未设置时整组跳过。种子数据差异(是否有诊断、是否已有审阅提交) +// 以「可理解状态」分支断言,不伪造服务端内容。 +import { test, expect, type Page } from '@playwright/test' + +const OWNER = process.env.E2E_OWNER || 'e2e-auditor' +const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026' +const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b' +const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目' +const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目' + +test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)') + +async function login(page: Page) { + await page.goto('/') + await expect(page.getByPlaceholder('由管理员分配')).toBeVisible() + await page.getByPlaceholder('由管理员分配').fill(OWNER) + await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD) + await page.getByRole('button', { name: '登录', exact: true }).click() + const gated = await page.getByText('修改密码').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false) + if (gated) { + const pwInputs = page.locator('.lg-card input[type="password"]') + await pwInputs.nth(0).fill(PASSWORD) + await pwInputs.nth(1).fill(PASSWORD2) + await pwInputs.nth(2).fill(PASSWORD2) + await page.getByRole('button', { name: '设置新密码并进入' }).click() + } else { + await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2) + await page.getByRole('button', { name: '登录', exact: true }).click() + } + await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toBeVisible({ timeout: 15_000 }) +} + +async function openDocument(page: Page) { + await page.locator('.tsb-tool', { hasText: '追溯审计' }).click() + await expect(page.locator('.rdm-audit-project')).toBeVisible() + await page.locator('.rdm-audit-project').selectOption({ label: MAIN }) + await page.getByRole('tab', { name: '资料与文档' }).click() + const open = page.locator('.rdm-audit-tree-open').first() + await expect(open).toBeVisible({ timeout: 10_000 }) + await open.click() + await expect(page.locator('.drw')).toBeVisible() + await expect(page.getByRole('tree', { name: '文档结构画布' })).toBeVisible({ timeout: 10_000 }) +} + +const panel = (page: Page, label: string) => + page.locator('.drw-panel').filter({ has: page.locator('.ui-seclabel', { hasText: new RegExp(`^${label}$`) }) }) + +/** 失败文案必须可读:不得把原始 JSON 回执直出到主文案。 */ +async function expectReadableFailures(page: Page) { + for (const msg of await page.locator('.drw-failure-msg').allTextContents()) { + expect(msg.trim().startsWith('{')).toBe(false) + expect(msg).not.toContain('"receipt"') + } +} + +test.describe.serial('统一渲染 Task 11 文档阅读与交付', () => { + test('结构画布与阅读视图共用选中与过滤,切换不丢', async ({ page }) => { + await login(page) + await openDocument(page) + const nodes = page.locator('.drw-canvas-node') + await expect(nodes.first()).toBeVisible() + const count = await nodes.count() + test.skip(count < 2, '种子文档无章节,跳过双视图选中断言') + const target = nodes.nth(1) + const title = (await target.getAttribute('title')) ?? '' + await target.click() + await expect(target).toHaveAttribute('aria-selected', 'true') + await page.getByLabel('过滤节点').fill(title.slice(0, 2)) + await page.getByRole('tab', { name: '阅读视图' }).click() + await expect(page.locator('.drw-reader')).toBeVisible() + await expect(page.locator('.drw-reader-title[aria-current="true"]')).toHaveText(title) + await expect(page.getByLabel('过滤节点')).toHaveValue(title.slice(0, 2)) + await page.getByRole('tab', { name: '结构画布' }).click() + await expect(page.locator(`.drw-canvas-node[aria-selected="true"]`)).toHaveAttribute('title', title) + }) + + test('结构画布可键盘导航并可滚动平移', async ({ page }) => { + await login(page) + await openDocument(page) + const root = page.locator('.drw-canvas-node').first() + await root.focus() + await page.keyboard.press('ArrowDown') + const selected = page.locator('.drw-canvas-node[aria-selected="true"]') + if (await page.locator('.drw-canvas-node').count() > 1) { + await expect(selected).toBeFocused() + await page.keyboard.press('ArrowLeft') + await expect(root).toHaveAttribute('aria-selected', 'true') + } + const canvas = page.getByRole('tree', { name: '文档结构画布' }) + const box = await canvas.boundingBox() + expect(box?.height ?? 0).toBeGreaterThan(100) + }) + + test('规则选择显示来源/版本/状态;不可用项给出原因;拒绝可读', async ({ page }) => { + await login(page) + await openDocument(page) + const rules = panel(page, '渲染规则') + await expect(rules.locator('.drw-profile').or(rules.locator('.drw-failure')).or(rules.getByText('没有可用的渲染规则')).first()) + .toBeVisible({ timeout: 10_000 }) + await expectReadableFailures(page) + for (const option of await rules.locator('.drw-profile').all()) { + await expect(option.locator('.drw-profile-source')).toHaveText(/文档规则|项目规则|模板规则|公司默认规则/) + await expect(option.locator('.drw-profile-ver')).toHaveText(/^v.+/) + if (await option.locator('input[disabled]').count()) await expect(option.locator('.drw-profile-reason')).not.toBeEmpty() + } + if (await rules.locator('.drw-notice').count()) await expect(rules.locator('.drw-notice')).toContainText('不会回退到公司默认规则') + }) + + test('生成预览:状态与诊断可见,点击诊断选中对应节点', async ({ page }) => { + await login(page) + await openDocument(page) + const generate = page.getByRole('button', { name: '生成预览', exact: true }) + await expect(generate).toBeVisible({ timeout: 10_000 }) + test.skip(await generate.isDisabled(), '无可预览规则') + await generate.click() + const preview = panel(page, '预览与提交') + await expect(preview.locator('.drw-receipt').or(preview.locator('.drw-failure')).first()).toBeVisible({ timeout: 30_000 }) + await expectReadableFailures(page) + if (await preview.locator('.drw-failure').count()) { + await expect(preview.getByRole('button', { name: '重试' })).toBeVisible() + return + } + const located = preview.locator('button.drw-diag-node').first() + if (await located.count()) { + const title = await located.getAttribute('title') + await located.click() + await expect(page.locator('.drw-canvas-node[aria-selected="true"], .drw-reader-title[aria-current="true"]').first()) + .toContainText(title ?? '') + } + await expect(page.locator('.drw-delivery').first()).toBeVisible({ timeout: 15_000 }) + }) + + test('交付记录可下载,文件名截断且 title 保留全名', async ({ page }) => { + await login(page) + await openDocument(page) + const download = page.locator('.drw-download').first() + test.skip(!(await download.waitFor({ state: 'visible', timeout: 10_000 }).then(() => true).catch(() => false)), '暂无交付记录') + const file = page.waitForEvent('download') + await download.click() + const saved = await file + expect(saved.suggestedFilename()).toMatch(/\.(docx|md|json|txt)$/) + const name = panel(page, '交付记录').locator('.drw-filename').first() + await expect(name).toHaveAttribute('title', saved.suggestedFilename()) + }) + + test('审阅:批注与修订分组,无法定位的单独成组', async ({ page }) => { + await login(page) + await openDocument(page) + const reviewPanel = panel(page, '审阅') + test.skip(!(await reviewPanel.count()), '服务未提供审阅能力') + await expect(reviewPanel.getByText('暂无审阅提交').or(reviewPanel.locator('.drw-annos')).or(reviewPanel.getByText('暂无批注')).first()) + .toBeVisible({ timeout: 10_000 }) + await expectReadableFailures(page) + await expect(reviewPanel).not.toContainText(' { + await page.setViewportSize({ width: 390, height: 844 }) + await login(page) + await page.evaluate(() => { document.documentElement.dataset.theme = 'dark' }) + await openDocument(page) + const overflow = await page.locator('.drw').evaluate(el => el.scrollWidth - el.clientWidth) + expect(overflow).toBeLessThanOrEqual(1) + await expect(page.getByRole('button', { name: '← 返回文档树' })).toBeVisible() + await page.getByRole('button', { name: '← 返回文档树' }).click() + await expect(page.locator('.rdm-audit-tree-open').first()).toBeVisible() + }) + + test('切换项目后不残留上一项目的文档内容', async ({ page }) => { + await login(page) + await openDocument(page) + await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED }) + await expect(page.locator('.drw')).toHaveCount(0) + await page.getByRole('tab', { name: '资料与文档' }).click() + await expect(page.getByText('暂无文档')).toBeVisible({ timeout: 10_000 }) + }) +}) diff --git a/src/components/DocumentRenderWorkspace.tsx b/src/components/DocumentRenderWorkspace.tsx new file mode 100644 index 0000000..10d851d --- /dev/null +++ b/src/components/DocumentRenderWorkspace.tsx @@ -0,0 +1,896 @@ +// 统一渲染 Task 11:文档阅读与交付工作区(rdm.inspect.* 读结构 + rdm.render.* 生成/审阅/下载)。 +// 结构画布与阅读视图共用同一选中节点与同一过滤状态;每个异步状态只有一个所有者, +// 响应按请求守卫与身份 epoch 校验,过期即丢弃。令牌驱动(DESIGN.md):折叠只用 Collapsible。 +import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react' +import { Btn, Chip, Collapsible, SectionLabel } from '@ui' +import { api } from '../api' +import { store, useStore } from '../store' +import { + DELIVERY_MODE_LABELS, MIND_MAP, REVISION_KIND_LABELS, + annotatePayload, buildDocTree, controlledNotice, createRequestGuard, decodeBase64, defaultProfileKey, + describeRenderFailure, diagnosticCountsByNode, diagnosticsSummaryText, downloadOptions, formatRenderTime, + groupDiagnostics, hasFacts, latestSubmissionId, layoutMindMap, mergeDeliveries, moveSelection, nextOperation, + nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDiagnostics, normalizeDownload, normalizeProfiles, + normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, renderScope, revisePayload, + truncateMiddle, validateReviewText, visibleNodeIds, visibleOrder, + type DocFilter, type DocTree, type DocTreeItem, type DownloadKind, type NodeFacts, type OperationKind, + type RenderDelivery, type RenderDiagnostic, type RenderOperation, type RenderProfiles, type ReviewAnnotation, + type ReviewRevision, +} from '../documentRenderPresentation' + +// ---- 文件保存(交付物与审计导出共用;类型取服务端 contentType,不在客户端重算校验) ---- + +export function saveBase64File(fileName: string, contentType: string, dataBase64: string) { + const blob = new Blob([decodeBase64(dataBase64)], { type: contentType || 'application/octet-stream' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = fileName + a.click() + URL.revokeObjectURL(url) +} + +// ---- 可复用展示件(纯 props;测试直接 SSR) ---- + +export function RenderFailure({ error, id }: { error: unknown; id: string }) { + const f = describeRenderFailure(error) + return ( +
+
{f.message}
+ 原始回执}> + {f.raw} + +
+ ) +} + +function FileName({ name }: { name: string }) { + return {truncateMiddle(name, 36)} +} + +export function ProfileOptionList({ profiles, selectedKey, onSelect }: { + profiles: RenderProfiles + selectedKey: string + onSelect: (key: string) => void +}) { + const views = profileOptionViews(profiles) + const notice = controlledNotice(profiles) + return ( +
+ {profiles.refusal && } + {notice &&
{notice}
} + {views.length === 0 && !profiles.refusal &&
没有可用的渲染规则
} +
+ {views.map(v => ( + + ))} +
+
+ ) +} + +const DIAGNOSTIC_PAGE = 50 + +function DiagnosticGroupBody({ items, nodeTitle, onSelectNode }: { + items: RenderDiagnostic[] + nodeTitle: (id: string) => string | undefined + onSelectNode: (id: string) => void +}) { + const [shown, setShown] = useState(DIAGNOSTIC_PAGE) + return ( +
    + {items.slice(0, shown).map((d, i) => { + const title = d.nodeId ? nodeTitle(d.nodeId) : undefined + return ( +
  • + {d.nodeId && title ? ( + + ) : 未定位节点} + {d.message} + {d.code && {d.code}} + {d.suggestion && 建议:{d.suggestion}} +
  • + ) + })} + {items.length > shown && ( +
  • + )} +
+ ) +} + +export function DiagnosticsList({ diagnostics, nodeTitle, onSelectNode, idPrefix }: { + diagnostics: RenderDiagnostic[] + nodeTitle: (id: string) => string | undefined + onSelectNode: (id: string) => void + idPrefix: string +}) { + const groups = groupDiagnostics(diagnostics) + if (groups.length === 0) return
没有诊断问题
+ return ( +
+ {groups.map((g, i) => ( + {g.label}({g.items.length})}> + + + ))} +
+ ) +} + +function Byline({ name, at }: { name: string; at: string }) { + const time = formatRenderTime(at) + return ( + + {name && {name}} + {time && {time}} + + ) +} + +function AnnotationItem({ a, onReply }: { a: ReviewAnnotation; onReply?: (a: ReviewAnnotation) => void }) { + return ( +
+
+ + {a.resolved && 已解决} + {onReply && } +
+
{a.text}
+ {a.replies.length > 0 && ( +
+ {a.replies.map(r => ( +
+ +
{r.text}
+
+ ))} +
+ )} +
+ ) +} + +export function AnnotationGroups({ byNode, orphaned, nodeTitle, onSelectNode, onReply }: { + byNode: Map + orphaned: ReviewAnnotation[] + nodeTitle: (id: string) => string | undefined + onSelectNode: (id: string) => void + onReply?: (a: ReviewAnnotation) => void +}) { + if (byNode.size === 0 && orphaned.length === 0) return
暂无批注
+ return ( +
+ {[...byNode].map(([nodeId, list]) => ( +
+ + {list.map(a => )} +
+ ))} + {orphaned.length > 0 && ( + 无法定位({orphaned.length})}> +
原位置已变更或不在当前文档中,未挂到任何节点
+ {orphaned.map(a => )} +
+ )} +
+ ) +} + +function RevisionItem({ r }: { r: ReviewRevision }) { + return ( +
+
+ {REVISION_KIND_LABELS[r.kind]} + + {r.decision && {r.decision}} +
+ {r.before &&
{r.before}
} + {r.after &&
{r.after}
} +
+ ) +} + +export function DownloadButtons({ delivery, busyKind, disabled = false, onDownload }: { + delivery: RenderDelivery + busyKind: DownloadKind | null + disabled?: boolean + onDownload: (kind: DownloadKind) => void +}) { + const options = downloadOptions(delivery) + if (options.length === 0) return 暂无可下载文件 + return ( +
+ {options.map(o => ( + + ))} +
+ ) +} + +function NodeFactsView({ facts }: { facts: NodeFacts }) { + if (!hasFacts(facts)) return null + const rows: [string, ReactNode][] = [] + if (facts.sources.length) rows.push(['来源', facts.sources.join('、')]) + if (facts.confidence) rows.push(['置信度', facts.confidence]) + if (facts.conflict) rows.push(['冲突', {facts.conflict}]) + if (facts.author || facts.at) rows.push(['作者', ]) + if (facts.impacts.length) rows.push(['影响', facts.impacts.join('、')]) + if (facts.change?.impact) rows.push(['变更影响', facts.change.impact]) + return ( +
+ {rows.map(([k, v]) => ( +
{k}{v}
+ ))} + {(facts.change?.before || facts.change?.after) && ( +
+ 变更 + + {facts.change.before &&
{facts.change.before}
} + {facts.change.after &&
{facts.change.after}
} +
+
+ )} +
+ ) +} + +// ---- 异步所有者 ---- + +const errText = (e: unknown) => (e instanceof Error ? e.message : String(e)) + +/** 单一资源加载:requestKey 变化或 reload 发起新请求,旧请求的响应一律丢弃。 */ +function useGuardedResource(requestKey: string | null, identity: number, load: () => Promise) { + const guard = useRef(createRequestGuard()) + const loadRef = useRef(load) + loadRef.current = load + const [tick, setTick] = useState(0) + const [state, setState] = useState<{ key: string | null; data: T | null; error: string }>({ key: null, data: null, error: '' }) + useEffect(() => { + const g = guard.current + if (requestKey === null) return + const ticket = g.begin(requestKey) + const live = () => g.current(ticket, requestKey) && identity === store.getIdentityEpoch() + setState(s => (s.key === requestKey ? { ...s, error: '' } : { key: requestKey, data: null, error: '' })) + loadRef.current().then( + data => { if (live()) setState({ key: requestKey, data, error: '' }) }, + e => { if (live()) setState(s => ({ key: requestKey, data: s.key === requestKey ? s.data : null, error: errText(e) })) }, + ) + return () => g.cancel() + }, [requestKey, identity, tick]) + const current = requestKey !== null && state.key === requestKey + return { + data: current ? state.data : null, + error: current ? state.error : '', + reload: () => setTick(t => t + 1), + } +} + +interface OperationState { op: RenderOperation | null; status: 'idle' | 'running' | 'done' | 'failed'; error: string } + +/** 写操作:每次动作新 operationId,重试复用;同一时刻只认最新一次的结果。 */ +function useOperationRunner(identity: number) { + const guard = useRef(createRequestGuard()) + const last = useRef<{ op: RenderOperation; exec: (operationId: string) => Promise; onDone?: (r: unknown) => void } | null>(null) + const [state, setState] = useState({ op: null, status: 'idle', error: '' }) + useEffect(() => { const g = guard.current; return () => g.cancel() }, []) + const start = (op: RenderOperation, exec: (operationId: string) => Promise, onDone?: (r: unknown) => void) => { + last.current = { op, exec, onDone } + const scope = String(identity) + const ticket = guard.current.begin(scope) + const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch() + setState({ op, status: 'running', error: '' }) + exec(op.operationId).then( + result => { if (!live()) return; setState({ op, status: 'done', error: '' }); onDone?.(result) }, + e => { if (live()) setState({ op, status: 'failed', error: errText(e) }) }, + ) + } + return { + state, + run: (kind: OperationKind, exec: (operationId: string) => Promise, onDone?: (r: unknown) => void) => + start(nextOperation(kind, undefined, last.current?.op ?? null), exec, onDone), + retry: () => { + const l = last.current + if (l) start(nextOperation(l.op.kind, undefined, l.op, true), l.exec, l.onDone) + }, + } +} + +const TREE_PAGE_LIMIT = 20 + +async function loadDocSections(projectId: string, docId: string): Promise { + const items: DocTreeItem[] = [] + let cursor = '' + for (let page = 0; page < TREE_PAGE_LIMIT; page++) { + const shell = await api.rdmCommand<{ items?: DocTreeItem[]; nextCursor?: string; hasMore?: boolean }>( + 'rdm.inspect.tree', { projectId, kind: 'docs', parent: docId, cursor }) + items.push(...(shell.items ?? []).filter(i => i && typeof i.id === 'string' && typeof i.title === 'string')) + if (!shell.hasMore || !shell.nextCursor) break + cursor = shell.nextCursor + } + return items +} + +/** 节点事实缓存:按节点 id 归档,只写回发起请求的那个节点;组件按作用域重挂载即整体失效。 */ +function useNodeFacts(projectId: string, identity: number, tree: DocTree | null, nodeId: string | null) { + const [facts, setFacts] = useState>({}) + const requested = useRef(new Set()) + const alive = useRef(true) + useEffect(() => { alive.current = true; return () => { alive.current = false } }, []) + const node = tree && nodeId ? tree.nodes[nodeId] : undefined + useEffect(() => { + if (!node || node.kind === 'doc' || requested.current.has(node.id)) return + const id = node.id + const live = () => alive.current && identity === store.getIdentityEpoch() + requested.current.add(id) + setFacts(f => ({ ...f, [id]: 'loading' })) + api.rdmCommand('rdm.inspect.object', { projectId, kind: node.kind, id }) + .then(d => { if (live()) setFacts(f => ({ ...f, [id]: nodeFacts(d) })) }) + .catch(() => { + if (!live()) return + requested.current.delete(id) + setFacts(f => ({ ...f, [id]: 'error' })) + }) + }, [node, projectId, identity]) + return facts +} + +/** 交付记录:首屏 + 分页追加,同一守卫串行化;作用域变化随组件重挂载失效。 */ +function useDeliveries(projectId: string, docId: string, identity: number, enabled: boolean) { + const guard = useRef(createRequestGuard()) + const [state, setState] = useState<{ items: RenderDelivery[]; nextCursor?: string; error: string; loading: boolean }>({ items: [], error: '', loading: enabled }) + const fetchPage = (cursor: string | undefined, append: boolean) => { + const scope = String(identity) + const ticket = guard.current.begin(scope) + const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch() + setState(s => ({ ...s, loading: true, error: '' })) + api.rdmCommand('rdm.render.deliveries', { projectId, docId, limit: 20, ...(cursor ? { cursor } : {}) }) + .then(raw => { + if (!live()) return + const page = normalizeDeliveries(raw) + setState(s => ({ items: append ? mergeDeliveries(s.items, page.items) : page.items, nextCursor: page.nextCursor, error: '', loading: false })) + }) + .catch(e => { if (live()) setState(s => ({ ...s, error: errText(e), loading: false })) }) + } + useEffect(() => { + const g = guard.current + if (enabled) fetchPage(undefined, false) + return () => g.cancel() + }, [enabled]) + return { ...state, reload: () => fetchPage(undefined, false), loadMore: () => state.nextCursor && fetchPage(state.nextCursor, true) } +} + +function useDownloader(projectId: string, identity: number) { + const guard = useRef(createRequestGuard()) + const [state, setState] = useState<{ busy: { deliveryId: string; kind: DownloadKind } | null; error: string; lastFile: string }>({ busy: null, error: '', lastFile: '' }) + useEffect(() => { const g = guard.current; return () => g.cancel() }, []) + const download = (deliveryId: string, kind: DownloadKind) => { + const scope = String(identity) + const ticket = guard.current.begin(scope) + const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch() + setState(s => ({ ...s, busy: { deliveryId, kind }, error: '' })) + api.rdmCommand('rdm.render.download', { projectId, deliveryId, kind }) + .then(raw => { + if (!live()) return + const p = normalizeDownload(raw) + saveBase64File(p.fileName, p.contentType, p.dataBase64) + setState({ busy: null, error: '', lastFile: p.fileName }) + }) + .catch(e => { if (live()) setState(s => ({ ...s, busy: null, error: errText(e) })) }) + } + return { ...state, download } +} + +// ---- 两种树视图(共用选中与过滤) ---- + +interface ViewProps { + tree: DocTree + order: string[] + visible: Set + selectedId: string | null + onSelect: (id: string) => void + badges: (id: string) => ReactNode + facts: Record +} + +function useFocusSelected(root: React.RefObject, selectedId: string | null) { + const wantFocus = useRef(false) + useEffect(() => { + if (!selectedId || !root.current) return + const el = root.current.querySelector(`[data-node-id="${CSS.escape(selectedId)}"]`) + if (!el) return + el.scrollIntoView?.({ block: 'nearest', inline: 'nearest' }) + if (wantFocus.current) { el.focus({ preventScroll: true }); wantFocus.current = false } + }, [root, selectedId]) + return wantFocus +} + +function StructureCanvas({ tree, order, visible, selectedId, onSelect, badges }: ViewProps) { + const ref = useRef(null) + const wantFocus = useFocusSelected(ref, selectedId) + const layout = useMemo(() => layoutMindMap(tree, visible), [tree, visible]) + const pan = useRef<{ x: number; y: number; left: number; top: number } | null>(null) + const [panning, setPanning] = useState(false) + const tabStop = selectedId && visible.has(selectedId) ? selectedId : tree.rootId + + const onKey = (e: KeyboardEvent) => { + const next = moveSelection(tree, order, selectedId, e.key) + if (next === null) return + e.preventDefault() + wantFocus.current = true + onSelect(next) + } + const onPointerDown = (e: PointerEvent) => { + if ((e.target as HTMLElement).closest('.drw-canvas-node') || !ref.current) return + pan.current = { x: e.clientX, y: e.clientY, left: ref.current.scrollLeft, top: ref.current.scrollTop } + ref.current.setPointerCapture?.(e.pointerId) + setPanning(true) + } + const onPointerMove = (e: PointerEvent) => { + if (!pan.current || !ref.current) return + ref.current.scrollLeft = pan.current.left - (e.clientX - pan.current.x) + ref.current.scrollTop = pan.current.top - (e.clientY - pan.current.y) + } + const endPan = () => { pan.current = null; setPanning(false) } + + return ( +
+
+ + {layout.nodes.map(n => { + const node = tree.nodes[n.id] + return ( + + ) + })} +
+
+ ) +} + +function ReaderView({ tree, order, visible, selectedId, onSelect, badges, facts }: ViewProps) { + const ref = useRef(null) + const wantFocus = useFocusSelected(ref, selectedId) + const onKey = (e: KeyboardEvent) => { + const next = moveSelection(tree, order, selectedId, e.key) + if (next === null) return + e.preventDefault() + wantFocus.current = true + onSelect(next) + } + return ( +
+ {visibleOrder(tree, visible).map(id => { + const n = tree.nodes[id] + const level = Math.min(n.depth + 2, 5) + const Heading = `h${level}` as 'h2' | 'h3' | 'h4' | 'h5' + const f = facts[id] + return ( +
+ + + + {badges(id)} + {n.state && {n.state}} + + {f && typeof f === 'object' && } +
+ ) + })} +
+ ) +} + +// ---- 审阅输入 ---- + +function ReviewComposer({ nodeTitle, replyTo, onCancelReply, canAnnotate, canRevise, busy, onAnnotate, onRevise }: { + nodeTitle: string | undefined + replyTo: ReviewAnnotation | null + onCancelReply: () => void + canAnnotate: boolean + canRevise: boolean + busy: boolean + onAnnotate: (text: string, replyTo?: string) => void + onRevise: (kind: 'insert' | 'delete' | 'replace', before?: string, after?: string) => void +}) { + const [mode, setMode] = useState<'comment' | 'revise'>(canAnnotate ? 'comment' : 'revise') + const [text, setText] = useState('') + const [kind, setKind] = useState<'insert' | 'delete' | 'replace'>('replace') + const [before, setBefore] = useState('') + const [after, setAfter] = useState('') + const [hint, setHint] = useState('') + if (!nodeTitle) return
先在文档树中选择一个章节,再添加批注或修订
+ const submit = () => { + if (mode === 'comment') { + const bad = validateReviewText(text) + if (bad) { setHint(bad); return } + onAnnotate(text, replyTo?.id) + setText('') + } else { + const bad = (kind !== 'insert' ? validateReviewText(before) : undefined) ?? (kind !== 'delete' ? validateReviewText(after) : undefined) + if (bad) { setHint(bad); return } + onRevise(kind, kind !== 'insert' ? before : undefined, kind !== 'delete' ? after : undefined) + setBefore(''); setAfter('') + } + setHint('') + } + return ( +
+
+ 目标:{nodeTitle} + {canAnnotate && canRevise && ( + + )} +
+ {mode === 'comment' ? ( + <> + {replyTo && ( +
+ 回复 {replyTo.author.displayName || '批注'} + +
+ )} +