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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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-')
|
||||
}
|
||||
Reference in New Issue
Block a user