From ad503c9535b79912a0f6d473208116309c6818b8 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 06:19:33 +0000 Subject: [PATCH] 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-') +}