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
This commit is contained in:
2026-10-15 06:19:33 +00:00
parent 1ef68abb8c
commit ad503c9535
5 changed files with 229 additions and 71 deletions
+64 -48
View File
@@ -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<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
interface SummaryData { projectId: string; counts: Record<string, number>; 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<TreeItem | null>(null)
const [detail, setDetail] = useState<ObjectDetail | null>(null)
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
const [detailErr, setDetailErr] = useState('')
const [children, setChildren] = useState<Record<string, AuditTreePage>>({})
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<ListShell<TreeItem>>('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<ListShell<TreeItem>>('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<ListShell<TreeItem>>('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<ObjectDetail>('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 <RdmErrorState raw={err} />
@@ -368,28 +383,30 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
return (
<div className="rdm-audit-tree">
<div className="rdm-audit-tree-list">
{shell.items.map(item => <TreeNodeRow key={item.id} item={item} selectedId={selected?.id} children={children} onSelect={selectNode} />)}
{shell.items.map(item => <TreeNodeRow key={item.id} item={item} selectedId={selected?.id} children={children} onSelect={selectNode} onLoadMore={loadMoreChildren} />)}
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
{selected && (
<div className="rdm-audit-detail">
<SectionLabel>对象详情</SectionLabel>
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
{detailErr ? <RdmErrorState raw={detailErr} /> : !detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
</div>
)}
</div>
)
}
function TreeNodeRow({ item, selectedId, children, onSelect }: {
function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore }: {
item: TreeItem
selectedId?: string
children: Record<string, TreeItem[]>
children: Record<string, AuditTreePage>
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 (
<div>
@@ -402,7 +419,9 @@ function TreeNodeRow({ item, selectedId, children, onSelect }: {
{nested && (
<div className="rdm-audit-tree-children">
{nested.length === 0 && <div className="empty-note">无下级节点</div>}
{nested.map(child => <TreeNodeRow key={child.id} item={child} selectedId={selectedId} children={children} onSelect={onSelect} />)}
{nested.map(child => <TreeNodeRow key={child.id} item={child} selectedId={selectedId} children={children} onSelect={onSelect} onLoadMore={onLoadMore} />)}
{nestedPage.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => onLoadMore(item)}>加载更多下级节点</button>}
{nestedPage.truncated && <div className="rdm-audit-truncated">该节点的下级内容已截断</div>}
</div>
)}
</div>
@@ -427,21 +446,17 @@ function TraceTab({ pid }: { pid: string }) {
.catch(e => { if (gen === genRef.current) setErr(e.message) })
}, [pid, sel])
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
// (parent=<docId>),按文档聚合后合并展示。
const [sections, setSections] = useState<TreeItem[]>([])
const [treeItems, setTreeItems] = useState<TreeItem[]>([])
const [pickErr, setPickErr] = useState('')
useEffect(() => {
if (sel) return
let c = false
;(async () => {
const top = await api.rdmCommand<ListShell<TreeItem>>('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<ListShell<TreeItem>>('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<ListShell<TreeItem>>('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 (
<div>
<SectionLabel>选择对象查看来源与影响</SectionLabel>
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
{pickErr ? <RdmErrorState raw={pickErr} /> : treeItems.length === 0 ? <EmptyState icon="folder">暂无可追溯对象</EmptyState> : (
<div className="rdm-audit-pick">
{sections.map(s => (
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
{s.title}
{treeItems.map(item => {
const readable = describeTreeItem(item)
return <button key={item.id} className="btn v-ghost" onClick={() => setSel({ kind: item.kind, id: item.id, title: readable.title })}>
{readable.kind} · {readable.title}
</button>
))}
})}
</div>
)}
</div>
+31 -1
View File
@@ -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)
})
+33 -22
View File
@@ -25,7 +25,7 @@ interface AuditEventPresentation {
}
const KIND_LABELS: Record<string, string> = {
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<string, string> = {
const OP_LABELS: Record<string, string> = {
'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<string, string> = {
@@ -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<T extends { kind?: string; id?: string }>(
}
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
}
+42
View File
@@ -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<string, AuditTreePage>([
['|', { 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)
})
})
+59
View File
@@ -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<AuditTreePage>
export function mergeTreePage(current: AuditTreePage, next: AuditTreePage): AuditTreePage {
return { ...next, items: [...current.items, ...next.items] }
}
async function collectParentPages(fetchPage: AuditTreePageFetcher, parent: string): Promise<AuditTreeItem[]> {
const items: AuditTreeItem[] = []
const seenCursors = new Set<string>()
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<AuditTreeItem[]> {
const roots = await collectParentPages(fetchPage, '')
const out = [...roots]
const queue = roots.filter(item => item.hasChildren === true)
const seenParents = new Set<string>()
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-')
}