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
This commit is contained in:
2026-10-14 17:08:38 +00:00
parent 814643f277
commit b52f9654a0
3 changed files with 44 additions and 30 deletions
+33 -29
View File
@@ -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<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 }
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<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
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 })) })
.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<ObjectDetail>('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 (
<div className="rdm-audit-tree">
<div className="rdm-audit-tree-list">
{shell.items.map(item => (
<div key={item.id}>
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
onClick={() => selectNode(item)}>
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{humanizeKind(item.kind)}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : item.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(item.state)}</Chip>}
</button>
{item.kind === 'doc' && children[item.id] && (
<div className="rdm-audit-tree-children">
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
{children[item.id].map(sub => (
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
onClick={() => selectNode(sub)}>
<span className="rdm-audit-tree-kind">{humanizeKind(sub.kind)}</span>
<span className="rdm-audit-tree-title">{sub.title}</span>
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : sub.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(sub.state)}</Chip>}
</button>
))}
</div>
)}
</div>
))}
{shell.items.map(item => <TreeNodeRow key={item.id} item={item} selectedId={selected?.id} children={children} onSelect={selectNode} />)}
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
</div>
@@ -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<string, TreeItem[]>
onSelect: (item: TreeItem) => void
}) {
const expandable = isTreeItemExpandable(item)
const nested = children[item.id]
return (
<div>
<button className={`rdm-audit-tree-node${selectedId === item.id ? ' sel' : ''}`} onClick={() => onSelect(item)}>
{expandable && <span className="rdm-audit-tree-caret">{nested ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{humanizeKind(item.kind)}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : item.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(item.state)}</Chip>}
</button>
{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} />)}
</div>
)}
</div>
)
}
// ---- 关联追溯 ----
function TraceTab({ pid }: { pid: string }) {
+7 -1
View File
@@ -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)
})
})
+4
View File
@@ -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
}