Why: the rdm-audit entry was an unconditional builtin, so on a plain base without any rdm.inspect.* commands the workspace opened straight into a rejected receipt (400) - diagnosed 2026-10-12. The page also rendered raw receipt JSON instead of a readable error. Evidence: vitest 23 files / 112 tests pass incl. the new contract matrix (workspaces.test.ts, rdm-errors.test.ts) and design-guard; tsc -b && vite build pass; e2e plain-base scenario added (driven by dev-repo scripts/e2e.sh phase 4). Impact: audit entry shows only when GET /api/commands lists all seven rdm.inspect.* commands enabled (store.rdmAuditAvailable, refreshed with the workspace list); audit errors classify into capability-missing / auth-expired / query-failed with the raw receipt kept in a Collapsible. Refs: 2026-10-12 diagnostic report, increment 2.
521 lines
24 KiB
TypeScript
521 lines
24 KiB
TypeScript
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
|
||
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
|
||
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
|
||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { api } from '../api'
|
||
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
|
||
import { classifyRdmError } from '../rdmErrors'
|
||
|
||
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||
|
||
interface InspectProject { projectId: string; name: string; canAudit: boolean }
|
||
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
|
||
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 RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
|
||
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
|
||
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
|
||
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
|
||
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
|
||
|
||
// ---- 工作区主组件 ----
|
||
|
||
const TABS = [
|
||
{ id: 'summary', label: '概览' },
|
||
{ id: 'materials', label: '资料与文档' },
|
||
{ id: 'trace', label: '关联追溯' },
|
||
{ id: 'timeline', label: '操作记录' },
|
||
] as const
|
||
type TabId = (typeof TABS)[number]['id']
|
||
|
||
/** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断;
|
||
* 「无可审计项目」由调用处独立空态承担,不走本组件。 */
|
||
function RdmErrorState({ raw }: { raw: string }) {
|
||
const c = classifyRdmError(raw)
|
||
return (
|
||
<div className="rdm-audit-err">
|
||
<EmptyState icon="alert">{c.message}</EmptyState>
|
||
<Collapsible id="rdm-audit-err-receipt" trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
|
||
<code className="rdm-audit-err-raw">{raw}</code>
|
||
</Collapsible>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function RdmAuditWorkspace() {
|
||
const [caps, setCaps] = useState<Capabilities | null>(null)
|
||
const [pid, setPid] = useState('')
|
||
const [tab, setTab] = useState<TabId>('summary')
|
||
const [error, setError] = useState('')
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
|
||
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
|
||
.catch(e => { if (!cancelled) setError(e.message) })
|
||
return () => { cancelled = true }
|
||
}, [])
|
||
|
||
const project = caps?.projects.find(p => p.projectId === pid)
|
||
if (error) return <RdmErrorState raw={error} />
|
||
if (!caps) return <div className="empty-note">加载中…</div>
|
||
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
|
||
|
||
return (
|
||
<div className="rdm-audit">
|
||
<div className="rdm-audit-header">
|
||
<select className="ui-input rdm-audit-project" value={pid} onChange={e => setPid(e.target.value)} aria-label="选择项目">
|
||
{caps.projects.map(p => (
|
||
<option key={p.projectId} value={p.projectId}>{p.name}</option>
|
||
))}
|
||
</select>
|
||
{project?.canAudit && <Chip tone="ok">可审计</Chip>}
|
||
{caps.crossProject && <Chip tone="info">跨项目</Chip>}
|
||
</div>
|
||
<div className="tabs rdm-audit-tabs" role="tablist">
|
||
{TABS.map(t => (
|
||
<button key={t.id} role="tab" aria-selected={tab === t.id}
|
||
className={`tab${tab === t.id ? ' active' : ''}`}
|
||
onClick={() => setTab(t.id)}>{t.label}</button>
|
||
))}
|
||
</div>
|
||
<div className="rdm-audit-body">
|
||
{tab === 'summary' && <SummaryTab pid={pid} />}
|
||
{tab === 'materials' && <MaterialsTab pid={pid} />}
|
||
{tab === 'trace' && <TraceTab pid={pid} />}
|
||
{tab === 'timeline' && <TimelineTab pid={pid} />}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 概览 ----
|
||
|
||
function SummaryTab({ pid }: { pid: string }) {
|
||
const [data, setData] = useState<SummaryData | null>(null)
|
||
const [err, setErr] = useState('')
|
||
useEffect(() => {
|
||
let c = false
|
||
setData(null); setErr('')
|
||
api.rdmCommand<SummaryData>('rdm.inspect.summary', { projectId: pid })
|
||
.then(d => { if (!c) setData(d) })
|
||
.catch(e => { if (!c) setErr(e.message) })
|
||
return () => { c = true }
|
||
}, [pid])
|
||
if (err) return <RdmErrorState raw={err} />
|
||
if (!data) return <div className="empty-note">加载中…</div>
|
||
const counts = data.counts || {}
|
||
const entries: [string, string, number][] = [
|
||
['切片', 'slices', counts.slices ?? 0],
|
||
['章节', 'sections', counts.sections ?? 0],
|
||
['文档', 'docs', counts.docs ?? 0],
|
||
['生效边', 'edgesActive', counts.edgesActive ?? 0],
|
||
['待确认边', 'edgesCandidate', counts.edgesCandidate ?? 0],
|
||
['结论', 'conclusions', counts.conclusions ?? 0],
|
||
['待办', 'openItems', counts.openItems ?? 0],
|
||
['挂起上传', 'uploadsHeld', counts.uploadsHeld ?? 0],
|
||
['空缺章节', 'emptySections', counts.emptySections ?? 0],
|
||
]
|
||
return (
|
||
<div>
|
||
<SectionLabel>统计(截至水位 {data.watermark})</SectionLabel>
|
||
<div className="rdm-audit-cards">
|
||
{entries.map(([label, , val]) => (
|
||
<div key={label} className="rdm-audit-card">
|
||
<div className="rdm-audit-card-num">{val}</div>
|
||
<div className="rdm-audit-card-label">{label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{data.lastOpAt && <div className="rdm-audit-meta">最近动作:{new Date(data.lastOpAt).toLocaleString('zh-CN')}</div>}
|
||
{data.coverage && data.coverage.length > 0 && (
|
||
<CoverageList cov={data.coverage} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 资料与文档 ----
|
||
|
||
function MaterialsTab({ pid }: { pid: string }) {
|
||
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
|
||
return (
|
||
<div className="rdm-audit-materials">
|
||
<div className="rdm-audit-materials-side">
|
||
<div className="tabs">
|
||
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||
</div>
|
||
<TreeList pid={pid} kind={kind} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||
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 genRef = useRef(0)
|
||
|
||
useEffect(() => {
|
||
const gen = ++genRef.current
|
||
setShell(null); setErr(''); setSelected(null); setDetail(null); 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) })
|
||
}, [pid, kind])
|
||
|
||
const loadMore = () => {
|
||
if (!shell?.nextCursor) return
|
||
const gen = genRef.current
|
||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor })
|
||
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) })
|
||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||
}
|
||
|
||
const toggleDoc = (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: '' })
|
||
.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 }
|
||
setSelected(item); setDetail(null)
|
||
const gen = genRef.current
|
||
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) })
|
||
}
|
||
|
||
if (err) return <RdmErrorState raw={err} />
|
||
if (!shell) return <div className="empty-note">加载中…</div>
|
||
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
|
||
|
||
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">{item.kind}</span>
|
||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{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">{sub.kind}</span>
|
||
<span className="rdm-audit-tree-title">{sub.title}</span>
|
||
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
{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} />}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 关联追溯 ----
|
||
|
||
function TraceTab({ pid }: { pid: string }) {
|
||
const [sel, setSel] = useState<{ kind: string; id: string; title: string } | null>(null)
|
||
const [relations, setRelations] = useState<RelationEntry[] | null>(null)
|
||
const [truncated, setTruncated] = useState(false)
|
||
const [err, setErr] = useState('')
|
||
const genRef = useRef(0)
|
||
|
||
useEffect(() => {
|
||
if (!sel) return
|
||
const gen = ++genRef.current
|
||
setRelations(null); setErr('')
|
||
api.rdmCommand<ListShell<RelationEntry>>('rdm.inspect.relations', { projectId: pid, kind: sel.kind, id: sel.id, depth: 1 })
|
||
.then(s => { if (gen === genRef.current) { setRelations(s.items); setTruncated(s.truncated) } })
|
||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||
}, [pid, sel])
|
||
|
||
// 未选对象时列出章节供选择。docs 树是层级结构:章节挂在各文档下
|
||
// (parent=<docId>),按文档聚合后合并展示。
|
||
const [sections, setSections] = useState<TreeItem[]>([])
|
||
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(() => {})
|
||
return () => { c = true }
|
||
}, [pid, sel])
|
||
|
||
if (!sel) {
|
||
return (
|
||
<div>
|
||
<SectionLabel>选择对象查看来源与影响</SectionLabel>
|
||
{sections.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}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const sources = relations?.filter(r => r.to?.id === sel.id && r.to?.kind === sel.kind) ?? []
|
||
const impacts = relations?.filter(r => r.from?.id === sel.id && r.from?.kind === sel.kind) ?? []
|
||
|
||
return (
|
||
<div>
|
||
<div className="rdm-audit-trace-head">
|
||
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
|
||
<strong>{sel.title}</strong>
|
||
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
|
||
</div>
|
||
{err && <RdmErrorState raw={err} />}
|
||
{!relations && !err && <div className="empty-note">加载中…</div>}
|
||
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
|
||
{relations && relations.length > 0 && (
|
||
<div className="rdm-audit-trace-cols">
|
||
<div className="rdm-audit-trace-col">
|
||
<SectionLabel>来源依据({sources.length})</SectionLabel>
|
||
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
|
||
</div>
|
||
<div className="rdm-audit-trace-col">
|
||
<SectionLabel>影响对象({impacts.length})</SectionLabel>
|
||
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RelationRow({ r }: { r: RelationEntry }) {
|
||
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
|
||
return (
|
||
<div className="rdm-audit-rel">
|
||
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
|
||
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
|
||
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
|
||
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 操作记录(事件时间线) ----
|
||
|
||
function TimelineTab({ pid }: { pid: string }) {
|
||
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
|
||
const [cursor, setCursor] = useState('')
|
||
const [hasMore, setHasMore] = useState(false)
|
||
const [err, setErr] = useState('')
|
||
const [filterOp, setFilterOp] = useState('')
|
||
const [filterActor, setFilterActor] = useState('')
|
||
const [historyMode, setHistoryMode] = useState(false)
|
||
const [historySeq, setHistorySeq] = useState(0)
|
||
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
|
||
const genRef = useRef(0)
|
||
|
||
const load = useCallback((cur: string, replace: boolean) => {
|
||
const gen = ++genRef.current
|
||
if (replace) { setItems([]); setErr('') }
|
||
const params: Record<string, unknown> = { projectId: pid, cursor: cur, limit: 30 }
|
||
if (filterOp) params.op = filterOp
|
||
if (filterActor) params.actor = filterActor
|
||
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
|
||
.then(s => {
|
||
if (gen !== genRef.current) return
|
||
setItems(prev => replace ? s.items : [...prev, ...s.items])
|
||
setCursor(s.nextCursor || '')
|
||
setHasMore(s.hasMore)
|
||
})
|
||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||
}, [pid, filterOp, filterActor])
|
||
|
||
useEffect(() => { if (!historyMode) load('', true) }, [load, historyMode])
|
||
|
||
useEffect(() => {
|
||
if (!historyMode) { setHistoryData(null); return }
|
||
const gen = genRef.current
|
||
setHistoryData(null)
|
||
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
|
||
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
|
||
.then(d => { if (gen === genRef.current) setHistoryData(d) })
|
||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||
}, [pid, historyMode, historySeq])
|
||
|
||
return (
|
||
<div className="rdm-audit-timeline">
|
||
<div className="rdm-audit-timeline-filters">
|
||
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
|
||
onChange={e => setFilterActor(e.target.value)} />
|
||
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
|
||
onChange={e => setFilterOp(e.target.value)} />
|
||
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
|
||
{historyMode ? '← 返回当前' : '历史模式'}
|
||
</button>
|
||
{historyMode && (
|
||
<input className="ui-input rdm-audit-seq" type="number" placeholder="截止 seq…"
|
||
value={historySeq || ''} onChange={e => setHistorySeq(Number(e.target.value) || 0)} />
|
||
)}
|
||
</div>
|
||
|
||
{historyMode ? (
|
||
<div>
|
||
{!historyData ? <div className="empty-note">加载中…</div> : (
|
||
<div>
|
||
<SectionLabel>截至 seq {historyData.atSeq}(当前水位 {historyData.watermark})</SectionLabel>
|
||
{historyData.countOnlySnapshots ? <div className="rdm-audit-truncated">含 {historyData.countOnlySnapshots} 个旧计数快照(不可作恢复依据)</div> : null}
|
||
{historyData.formalMissing ? <div className="empty-note">{historyData.formalMissing}</div> : null}
|
||
{historyData.object ? (
|
||
<div className="rdm-audit-detail-body">
|
||
<div>标题:{historyData.object.title}</div>
|
||
<div>状态:{historyData.object.state}</div>
|
||
{historyData.object.contentPreview && <div>内容预览:{historyData.object.contentPreview}</div>}
|
||
</div>
|
||
) : historyData.empty ? <EmptyState icon="folder">该时点无事件</EmptyState> :
|
||
<div className="empty-note">输入对象 kind:id 查看历史快照</div>}
|
||
{historyData.formal && (
|
||
<div className="rdm-audit-formal">
|
||
正式版 R{historyData.formal.rev}(内容 v{historyData.formal.seq})——{historyData.formal.actor} 于 {new Date(historyData.formal.at).toLocaleString('zh-CN')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div>
|
||
{err && <RdmErrorState raw={err} />}
|
||
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
|
||
{items.map((item, i) => {
|
||
if ('events' in item && Array.isArray((item as OpGroup).events)) {
|
||
return <OpGroupView key={item.operationId || i} group={item as OpGroup} />
|
||
}
|
||
return <EventRow key={(item as EventDTO).seq} ev={item as EventDTO} />
|
||
})}
|
||
{hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => load(cursor, false)}>加载更多</button>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function OpGroupView({ group }: { group: OpGroup }) {
|
||
const [open, setOpen] = useState(false)
|
||
return (
|
||
<div className="rdm-audit-opgroup">
|
||
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
|
||
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
|
||
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
|
||
<Chip tone="plain">{group.events.length} 个事件</Chip>
|
||
</button>
|
||
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EventRow({ ev }: { ev: EventDTO }) {
|
||
return (
|
||
<div className="rdm-audit-event">
|
||
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
|
||
<span className="rdm-audit-event-op">{ev.op}</span>
|
||
<span className="rdm-audit-event-actor">{ev.actor}</span>
|
||
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
|
||
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 对象详情视图 ----
|
||
|
||
function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||
return (
|
||
<div className="rdm-audit-detail-body">
|
||
<div><strong>{detail.title || detail.id}</strong></div>
|
||
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
|
||
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
|
||
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
|
||
{detail.versions && detail.versions.length > 0 && (
|
||
<div>
|
||
<SectionLabel>版本链({detail.versions.length})</SectionLabel>
|
||
{detail.versions.map((v: any, i: number) => (
|
||
<div key={i} className="rdm-audit-version">
|
||
v{v.seq} · {v.actor} · {new Date(v.at).toLocaleString('zh-CN')}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{detail.formal && (
|
||
<div className="rdm-audit-formal">
|
||
正式版 R{detail.formal.rev}(内容 v{detail.formal.seq})——{detail.formal.actor} 于 {new Date(detail.formal.at).toLocaleString('zh-CN')}
|
||
</div>
|
||
)}
|
||
{detail.relations && detail.relations.length > 0 && (
|
||
<div>
|
||
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
|
||
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
|
||
</div>
|
||
)}
|
||
{detail.recentEvents && detail.recentEvents.length > 0 && (
|
||
<div>
|
||
<SectionLabel>最近事件</SectionLabel>
|
||
{detail.recentEvents.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 覆盖范围标注(不冒充完整) ----
|
||
|
||
function CoverageList({ cov }: { cov: CoverageItem[] }) {
|
||
return (
|
||
<div className="rdm-audit-coverage">
|
||
<SectionLabel>覆盖范围</SectionLabel>
|
||
{cov.map(c => (
|
||
<div key={c.face} className="rdm-audit-coverage-item">
|
||
<Chip tone="plain">{c.face}</Chip>
|
||
<span>{c.reason}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|