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