governance / governance (pull_request) Successful in 15s
Why: PR #106 is behind main by 15 commits (password login, regulation
citations, RDM member search) and conflicted in RdmAuditWorkspace.tsx and
rdmManagement.ts; the user authorized merging the renderer line on 2026-10-18.
Evidence: content is the agent-exec transport ec5012fe (cursor worker, unit
frontend-task11-sync-main-50ec836-r2, receipt
/tmp/cleanup-20261018/render/frontend-sync-run2.json, SELF): tsc -b, vitest
541/541 and vite build passed; host reviewed both conflict resolutions as unions.
Impact: no behaviour removed from either side; regulation tab and document
open flow coexist in the materials tab.
Refs: PR #106, Frontend main 50ec836
Co-authored-by: Cursor <cursoragent@cursor.com>
921 lines
41 KiB
TypeScript
921 lines
41 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'
|
||
import {
|
||
describeAuditEvent, describeTreeItem, humanizeKind, humanizeRelation, humanizeState, isTreeItemExpandable,
|
||
relationOtherEndpoint,
|
||
type AuditEventViewInput,
|
||
} from '../rdmAuditPresentation'
|
||
import {
|
||
collectRecursiveTree, isTreeItemInspectable, mergeTreePage,
|
||
type AuditTreeItem, type AuditTreePage,
|
||
} from '../rdmAuditTree'
|
||
import { store, useStore } from '../store'
|
||
import { exportNeedsDraft } from '../workspaces'
|
||
import {
|
||
CITATION_CHIP_TONE, citationStateLabel, citationStyle, legStateLabel, REG_TREE_KIND_LABELS,
|
||
regsTreeParams, sealStateLabel,
|
||
type RegBatchDetail, type RegCitationDetail,
|
||
} from '../regCitations'
|
||
import { DocumentRenderWorkspace, saveBase64File } from './DocumentRenderWorkspace'
|
||
import { CitationCard, CitationListItem, CitationStateChips } from './RegCitation'
|
||
|
||
// ---- 类型(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[]; businessAudit?: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
|
||
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
|
||
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[] }
|
||
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']
|
||
|
||
const OPERATION_FILTERS = [
|
||
['', '全部操作'],
|
||
['doc.instantiate', '创建文档实例'],
|
||
['section.add', '新增文档章节'],
|
||
['slice.ingest', '新增项目资料'],
|
||
['edge.candidate', '提出追溯候选'],
|
||
['edge.confirm', '确认追溯关系'],
|
||
['change.requested', '提出变更申请'],
|
||
['change.applied', '应用变更'],
|
||
['open-item.add', '登记项目问题'],
|
||
['open-item.close', '关闭项目问题'],
|
||
['rdm.work.observe', '拆解工作来源'],
|
||
['rdm.work.risk', '发现项目风险'],
|
||
['rdm.work.risk.answer', '回答风险澄清'],
|
||
['rdm.structure.decide', '决定结构变更'],
|
||
] as const
|
||
|
||
/** 错误态(增量 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 identity = useStore(() => store.getIdentityEpoch())
|
||
const [caps, setCaps] = useState<Capabilities | null>(null)
|
||
const [capsIdentity, setCapsIdentity] = useState(-1)
|
||
const [pid, setPid] = useState('')
|
||
const [tab, setTab] = useState<TabId>('summary')
|
||
const [error, setError] = useState('')
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
setCaps(null); setPid(''); setError('')
|
||
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
|
||
.then(c => {
|
||
if (cancelled || identity !== store.getIdentityEpoch()) return
|
||
setCaps(c); setCapsIdentity(identity)
|
||
if (c.projects.length > 0) setPid(c.projects[0].projectId)
|
||
})
|
||
.catch(e => { if (!cancelled && identity === store.getIdentityEpoch()) setError(e.message) })
|
||
return () => { cancelled = true }
|
||
}, [identity])
|
||
|
||
const project = caps?.projects.find(p => p.projectId === pid)
|
||
const exportOn = useStore(s => s.rdmExportAvailable)
|
||
if (error) return <RdmErrorState raw={error} />
|
||
if (!caps || capsIdentity !== identity) 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} exportOn={exportOn} />}
|
||
{tab === 'materials' && <MaterialsTab key={pid} pid={pid} />}
|
||
{tab === 'trace' && <TraceTab pid={pid} />}
|
||
{tab === 'timeline' && <TimelineTab pid={pid} />}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 概览 ----
|
||
|
||
function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
|
||
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} />
|
||
)}
|
||
{exportOn && <ExportSection pid={pid} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 概览导出(三条命令都启用才挂上;不参与七命令就绪门) ----
|
||
|
||
interface ExportCreated {
|
||
exportId: string
|
||
fileName: string
|
||
watermark: number
|
||
sha256: string
|
||
draft?: boolean
|
||
}
|
||
interface ExportStatus {
|
||
expiresAt: string
|
||
sha256: string
|
||
watermark: number
|
||
}
|
||
interface ExportDownloaded {
|
||
fileName: string
|
||
mime: string
|
||
dataB64: string
|
||
sha256: string
|
||
}
|
||
|
||
function ExportSection({ pid }: { pid: string }) {
|
||
const identity = useStore(() => store.getIdentityEpoch())
|
||
const generation = useRef(0)
|
||
const [kind, setKind] = useState<'cgraph-docx' | 'cgraph-matrix'>('cgraph-docx')
|
||
const [busy, setBusy] = useState(false)
|
||
const [err, setErr] = useState('')
|
||
const [needDraft, setNeedDraft] = useState(false)
|
||
const [artifact, setArtifact] = useState<(ExportCreated & { expiresAt: string }) | null>(null)
|
||
|
||
useEffect(() => {
|
||
generation.current++
|
||
setArtifact(null)
|
||
setErr('')
|
||
setNeedDraft(false)
|
||
setBusy(false)
|
||
return () => { generation.current++ }
|
||
}, [pid, identity])
|
||
|
||
const generate = async (draft: boolean) => {
|
||
const request = ++generation.current
|
||
const current = () => request === generation.current && identity === store.getIdentityEpoch()
|
||
setBusy(true)
|
||
setErr('')
|
||
setNeedDraft(false)
|
||
try {
|
||
const created = await api.rdmCommand<ExportCreated>('rdm.inspect.export.create', { projectId: pid, kind, draft })
|
||
if (!current()) return
|
||
const st = await api.rdmCommand<ExportStatus>('rdm.inspect.export.status', { projectId: pid, exportId: created.exportId })
|
||
if (!current()) return
|
||
setArtifact({ ...created, expiresAt: st.expiresAt })
|
||
} catch (e) {
|
||
if (!current()) return
|
||
const msg = e instanceof Error ? e.message : String(e)
|
||
if (!draft && exportNeedsDraft(msg)) setNeedDraft(true)
|
||
else setErr(msg)
|
||
} finally {
|
||
if (current()) setBusy(false)
|
||
}
|
||
}
|
||
|
||
const download = async () => {
|
||
if (!artifact) return
|
||
const request = ++generation.current
|
||
const current = () => request === generation.current && identity === store.getIdentityEpoch()
|
||
setBusy(true)
|
||
setErr('')
|
||
try {
|
||
const dl = await api.rdmCommand<ExportDownloaded>('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId })
|
||
if (!current()) return
|
||
saveBase64File(dl.fileName, dl.mime, dl.dataB64)
|
||
} catch (e) {
|
||
if (current()) setErr(e instanceof Error ? e.message : String(e))
|
||
} finally {
|
||
if (current()) setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="rdm-audit-export">
|
||
<SectionLabel>导出</SectionLabel>
|
||
<div className="rdm-audit-export-row">
|
||
<label className="rdm-audit-export-kind">
|
||
类型
|
||
<select className="ui-input" aria-label="导出类型" value={kind} onChange={e => setKind(e.target.value as 'cgraph-docx' | 'cgraph-matrix')}>
|
||
<option value="cgraph-docx">合规追溯报告</option>
|
||
<option value="cgraph-matrix">追溯矩阵</option>
|
||
</select>
|
||
</label>
|
||
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(false) }}>生成</button>
|
||
</div>
|
||
{needDraft && (
|
||
<div className="rdm-audit-export-draft">
|
||
<p>正式报告不完整。可以生成带缺口标注的草稿。</p>
|
||
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(true) }}>生成草稿</button>
|
||
</div>
|
||
)}
|
||
{err && <div className="rdm-audit-export-err">{err}</div>}
|
||
{artifact && (
|
||
<div className="rdm-audit-export-result">
|
||
<div>水位 {artifact.watermark}</div>
|
||
<div>sha256 {artifact.sha256}</div>
|
||
<div>有效期 {artifact.expiresAt ? new Date(artifact.expiresAt).toLocaleString('zh-CN') : '—'}</div>
|
||
{artifact.draft && <div>草稿</div>}
|
||
<button type="button" className="btn v-ghost" disabled={busy} onClick={() => { void download() }}>下载</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 资料与文档 ----
|
||
|
||
function MaterialsTab({ pid }: { pid: string }) {
|
||
const [kind, setKind] = useState<'notes' | 'docs' | 'regs'>('docs')
|
||
// 打开文档时树列表保持挂载(只隐藏),返回后展开状态不丢。
|
||
const [openDoc, setOpenDoc] = useState<TreeItem | null>(null)
|
||
return (
|
||
<div className="rdm-audit-materials">
|
||
{openDoc && (
|
||
<DocumentRenderWorkspace projectId={pid} docId={openDoc.id} docTitle={openDoc.title} onBack={() => setOpenDoc(null)} />
|
||
)}
|
||
<div className="rdm-audit-materials-side" hidden={!!openDoc}>
|
||
<div className="tabs">
|
||
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||
<button className={`tab${kind === 'regs' ? ' active' : ''}`} onClick={() => setKind('regs')}>法规核验</button>
|
||
</div>
|
||
{kind === 'regs' ? <RegsTreeList pid={pid} /> : <TreeList pid={pid} kind={kind} onOpenDoc={setOpenDoc} />}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 法规核验树(U7:rdm.inspect.tree kind=regs;根层证据批,parent=<batchId> 列引用) ----
|
||
|
||
function RegTreeNode({ item, selected, onSelect }: { item: TreeItem; selected: boolean; onSelect: () => void }) {
|
||
return (
|
||
<button className={`rdm-audit-tree-node${selected ? ' sel' : ''}`} onClick={onSelect}>
|
||
<span className="rdm-audit-tree-kind">{REG_TREE_KIND_LABELS[item.kind] || item.kind}</span>
|
||
<span className="rdm-audit-tree-title">{item.title || item.id}</span>
|
||
{item.state && <Chip tone={CITATION_CHIP_TONE[citationStyle({ state: item.state })]}>{citationStateLabel(item.state)}</Chip>}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function RegsTreeList({ pid }: { pid: string }) {
|
||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||
const [err, setErr] = useState('')
|
||
const [children, setChildren] = useState<Record<string, ListShell<TreeItem>>>({})
|
||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||
const [detail, setDetail] = useState<RegBatchDetail | RegCitationDetail | null>(null)
|
||
const [detailErr, setDetailErr] = useState('')
|
||
// genRef:项目切换代际(树与子页);selRef:选中代际(快速切换时丢弃旧详情响应)。
|
||
const genRef = useRef(0)
|
||
const selRef = useRef(0)
|
||
|
||
useEffect(() => {
|
||
const gen = ++genRef.current
|
||
selRef.current++
|
||
setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({})
|
||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid))
|
||
.then(s => { if (gen === genRef.current) setShell({ ...s, items: s.items || [] }) })
|
||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||
}, [pid])
|
||
|
||
const loadMore = () => {
|
||
if (!shell?.nextCursor) return
|
||
const gen = genRef.current
|
||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { 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 loadChildren = (batchId: string, cursor?: string) => {
|
||
const gen = genRef.current
|
||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { parent: batchId, cursor }))
|
||
.then(s => {
|
||
if (gen !== genRef.current) return
|
||
setChildren(prev => {
|
||
const items = cursor ? [...(prev[batchId]?.items || []), ...(s.items || [])] : (s.items || [])
|
||
return { ...prev, [batchId]: { ...s, items } }
|
||
})
|
||
})
|
||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||
}
|
||
|
||
const toggleBatch = (item: TreeItem) => {
|
||
if (children[item.id]) {
|
||
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
|
||
return
|
||
}
|
||
loadChildren(item.id)
|
||
}
|
||
|
||
const selectNode = (item: TreeItem) => {
|
||
setSelected(item); setDetail(null); setDetailErr('')
|
||
const gen = genRef.current
|
||
const sel = ++selRef.current
|
||
const current = () => gen === genRef.current && sel === selRef.current
|
||
api.rdmCommand<RegBatchDetail | RegCitationDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||
.then(d => { if (current()) setDetail(d) })
|
||
.catch(e => { if (current()) setDetailErr(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">暂无法规核验证据批</EmptyState>
|
||
|
||
return (
|
||
<div className="rdm-audit-tree">
|
||
<div className="rdm-audit-tree-list">
|
||
{shell.items.map(item => {
|
||
const sub = children[item.id]
|
||
return (
|
||
<div key={item.id}>
|
||
<div className="regcite-tree-row">
|
||
<button type="button" className="regcite-tree-caret" aria-label={sub ? '收起引用' : '展开引用'}
|
||
aria-expanded={!!sub} onClick={() => toggleBatch(item)}>{sub ? '▾' : '▸'}</button>
|
||
<RegTreeNode item={item} selected={selected?.id === item.id} onSelect={() => selectNode(item)} />
|
||
</div>
|
||
{sub && (
|
||
<div className="rdm-audit-tree-children">
|
||
{sub.items.length === 0 && <div className="empty-note">无引用</div>}
|
||
{sub.items.map(c => (
|
||
<RegTreeNode key={c.id} item={c} selected={selected?.id === c.id} onSelect={() => selectNode(c)} />
|
||
))}
|
||
{sub.hasMore && (
|
||
<button className="btn v-ghost rdm-audit-more" onClick={() => loadChildren(item.id, sub.nextCursor)}>加载更多引用</button>
|
||
)}
|
||
{sub.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||
</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>
|
||
{detailErr ? <RdmErrorState raw={detailErr} />
|
||
: !detail ? <div className="empty-note">加载中…</div>
|
||
: selected.kind === 'reg-batch'
|
||
? <RegBatchView item={selected} detail={detail as RegBatchDetail} />
|
||
: <RegCitationView item={selected} detail={detail as RegCitationDetail} />}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RegBatchView({ item, detail }: { item: TreeItem; detail: RegBatchDetail }) {
|
||
const reasons = detail.stateReasons || []
|
||
const citations = detail.citations || []
|
||
const resolutions = detail.resolutions || []
|
||
return (
|
||
<div className="rdm-audit-detail-body">
|
||
<div><strong>{detail.title || item.title || item.id}</strong></div>
|
||
<div className="regcite-mono">reg-batch:{item.id}</div>
|
||
<div>状态:<CitationStateChips c={{ state: detail.state }} /></div>
|
||
{reasons.length > 0 && (
|
||
<div>
|
||
<SectionLabel>状态原因</SectionLabel>
|
||
<ul className="regcite-reasons">{reasons.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
||
</div>
|
||
)}
|
||
{detail.legStates && (
|
||
<div>
|
||
<SectionLabel>来源腿</SectionLabel>
|
||
<dl className="regcite-fields">
|
||
<dt>知识库</dt><dd>{legStateLabel(detail.legStates.kb, detail.legStates.kbReason)}</dd>
|
||
<dt>网络</dt><dd>{legStateLabel(detail.legStates.web, detail.legStates.webReason)}</dd>
|
||
</dl>
|
||
</div>
|
||
)}
|
||
{detail.seal && (
|
||
<div>
|
||
<SectionLabel>封存</SectionLabel>
|
||
<dl className="regcite-fields">
|
||
<dt>状态</dt><dd>{sealStateLabel(detail.seal.state)}</dd>
|
||
<dt>密钥</dt><dd className="regcite-mono">{detail.seal.keyId || '—'}</dd>
|
||
<dt>清单哈希</dt><dd className="regcite-mono">{detail.seal.manifestSha256 || '—'}</dd>
|
||
</dl>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<SectionLabel>引用({citations.length})</SectionLabel>
|
||
{citations.length === 0 && <div className="empty-note">该批没有引用</div>}
|
||
{citations.map(c => <CitationListItem key={c.id} id={c.id} citation={c} scope={`audit-${item.id}`} />)}
|
||
</div>
|
||
{resolutions.length > 0 && (
|
||
<div>
|
||
<SectionLabel>人工裁决({resolutions.length})</SectionLabel>
|
||
{resolutions.map((r, i) => (
|
||
<div key={i} className="rdm-audit-version">
|
||
{r.decision} · {r.actor} · {new Date(r.at).toLocaleString('zh-CN')}{r.note ? ` · ${r.note}` : ''}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RegCitationView({ item, detail }: { item: TreeItem; detail: RegCitationDetail }) {
|
||
return (
|
||
<div className="rdm-audit-detail-body">
|
||
<div><strong>{detail.title || item.title || item.id}</strong></div>
|
||
<CitationCard id={item.id} citation={detail.citation} scope="audit" />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TreeList({ pid, kind, onOpenDoc }: { pid: string; kind: 'notes' | 'docs'; onOpenDoc: (doc: TreeItem) => void }) {
|
||
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)
|
||
const [detailErr, setDetailErr] = useState('')
|
||
const [children, setChildren] = useState<Record<string, AuditTreePage>>({})
|
||
const genRef = useRef(0)
|
||
const detailGenRef = useRef(0)
|
||
|
||
useEffect(() => {
|
||
const gen = ++genRef.current
|
||
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) })
|
||
}, [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 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, parent: item.id, cursor: '' })
|
||
.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)
|
||
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 (detailGen === detailGenRef.current) setDetail(d) })
|
||
.catch(e => { if (detailGen === detailGenRef.current) setDetailErr(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 => <TreeNodeRow key={item.id} item={item} selectedId={selected?.id} children={children} onSelect={selectNode} onLoadMore={loadMoreChildren} onOpenDoc={onOpenDoc} />)}
|
||
{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>
|
||
{detailErr ? <RdmErrorState raw={detailErr} /> : !detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenDoc }: {
|
||
item: TreeItem
|
||
selectedId?: string
|
||
children: Record<string, AuditTreePage>
|
||
onSelect: (item: TreeItem) => void
|
||
onLoadMore: (item: TreeItem) => void
|
||
onOpenDoc: (doc: TreeItem) => void
|
||
}) {
|
||
const expandable = isTreeItemExpandable(item)
|
||
const nestedPage = children[item.id]
|
||
const nested = nestedPage?.items
|
||
const readable = describeTreeItem(item)
|
||
return (
|
||
<div>
|
||
<div className="rdm-audit-tree-row">
|
||
<button className={`rdm-audit-tree-node${selectedId === item.id ? ' sel' : ''}`} onClick={() => onSelect(item)}
|
||
aria-expanded={expandable ? Boolean(nested) : undefined}>
|
||
{expandable && <span className="rdm-audit-tree-caret" aria-hidden="true">{nested ? '▾' : '▸'}</span>}
|
||
<span className="rdm-audit-tree-kind">{readable.kind}</span>
|
||
<span className="rdm-audit-tree-title">{readable.title}</span>
|
||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : item.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(item.state)}</Chip>}
|
||
</button>
|
||
{item.kind === 'doc' && (
|
||
<button type="button" className="btn v-ghost rdm-audit-tree-open" onClick={() => onOpenDoc(item)}
|
||
aria-label={`阅读与交付:${readable.title}`}>阅读与交付</button>
|
||
)}
|
||
</div>
|
||
{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} onLoadMore={onLoadMore} onOpenDoc={onOpenDoc} />)}
|
||
{nestedPage.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => onLoadMore(item)}>加载更多下级节点</button>}
|
||
{nestedPage.truncated && <div className="rdm-audit-truncated">该节点的下级内容已截断</div>}
|
||
</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])
|
||
|
||
const [treeItems, setTreeItems] = useState<TreeItem[]>([])
|
||
const [pickErr, setPickErr] = useState('')
|
||
useEffect(() => {
|
||
if (sel) return
|
||
let c = false
|
||
;(async () => {
|
||
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])
|
||
|
||
if (!sel) {
|
||
return (
|
||
<div>
|
||
<SectionLabel>选择对象查看来源与影响</SectionLabel>
|
||
{pickErr ? <RdmErrorState raw={pickErr} /> : treeItems.length === 0 ? <EmptyState icon="folder">暂无可追溯对象</EmptyState> : (
|
||
<div className="rdm-audit-pick">
|
||
{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>
|
||
)
|
||
}
|
||
|
||
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">{humanizeKind(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} selected={sel} />)}
|
||
</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} selected={sel} />)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RelationRow({ r, selected }: { r: RelationEntry; selected: { kind: string; id: string } }) {
|
||
const other = relationOtherEndpoint(r, selected)
|
||
const readable = humanizeRelation(r)
|
||
return (
|
||
<div className="rdm-audit-rel">
|
||
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{readable.layer}</Chip>
|
||
<span className="rdm-audit-rel-type">{readable.type}</span>
|
||
<span className="rdm-audit-rel-endpoint">{humanizeKind(other?.kind)}“{other?.title || other?.id || '未知对象'}”{other?.ver ? `(版本 ${other.ver})` : ''}</span>
|
||
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' || r.state === 'stale' ? 'warn' : 'plain'}>{readable.state}</Chip>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 操作记录(事件时间线) ----
|
||
|
||
// 单一所有者:仅最近一次发起的请求可写回;返回的清理函数作废在途请求。
|
||
export function startLatestFetch<T>(
|
||
genRef: { current: number },
|
||
request: () => Promise<T>,
|
||
onData: (data: T) => void,
|
||
onError: (message: string) => void,
|
||
): () => void {
|
||
const gen = ++genRef.current
|
||
request()
|
||
.then(d => { if (gen === genRef.current) onData(d) })
|
||
.catch(e => { if (gen === genRef.current) onError(e instanceof Error ? e.message : String(e)) })
|
||
return () => { if (gen === genRef.current) genRef.current++ }
|
||
}
|
||
|
||
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 historyGenRef = 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
|
||
const page = replace ? [...(s.businessAudit || []), ...s.items] : s.items
|
||
setItems(prev => replace ? page : [...prev, ...page])
|
||
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 }
|
||
setHistoryData(null)
|
||
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
|
||
return startLatestFetch(historyGenRef, () => api.rdmCommand<HistoryData>('rdm.inspect.history', params), setHistoryData, setErr)
|
||
}, [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)} />
|
||
<select className="ui-input" aria-label="按操作筛选" value={filterOp} onChange={e => setFilterOp(e.target.value)}>
|
||
{OPERATION_FILTERS.map(([value, label]) => <option key={value || 'all'} value={value}>{label}</option>)}
|
||
</select>
|
||
<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 first = group.events[0] ? describeAuditEvent(group.events[0]) : null
|
||
return (
|
||
<div className="rdm-audit-opgroup">
|
||
<Collapsible id={`rdm-audit-operation-${group.operationId}`} trigger={(
|
||
<span className="rdm-audit-opgroup-head">
|
||
<span className="rdm-audit-opgroup-actor">{first?.subject || group.actor}</span>
|
||
<span className="rdm-audit-opgroup-action">{first?.action || '项目操作'}</span>
|
||
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
|
||
<Chip tone="plain">包含 {group.events.length} 项记录</Chip>
|
||
</span>
|
||
)}>
|
||
{group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||
</Collapsible>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EventRow({ ev }: { ev: EventDTO }) {
|
||
const view = describeAuditEvent(ev)
|
||
return (
|
||
<div className="rdm-audit-event">
|
||
<div className="rdm-audit-event-head">
|
||
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleString('zh-CN')}</span>
|
||
<strong className="rdm-audit-event-action">{view.action}</strong>
|
||
<Chip tone={ev.disclosure === 'ok' && view.outcome === '已生效' ? 'ok' : 'warn'}>{view.outcome}</Chip>
|
||
</div>
|
||
<div className="rdm-audit-event-summary">{view.subject}处理了{view.object}</div>
|
||
<dl className="rdm-audit-event-fields">
|
||
<div><dt>原因</dt><dd>{view.reason}</dd></div>
|
||
<div><dt>影响</dt><dd>{view.impact}</dd></div>
|
||
<div><dt>依据</dt><dd>{view.source}</dd></div>
|
||
</dl>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ---- 对象详情视图 ----
|
||
|
||
function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||
return (
|
||
<div className="rdm-audit-detail-body">
|
||
<div><strong>{detail.title || detail.id}</strong></div>
|
||
<div>{humanizeKind(detail.kind)} · 对象编号 {detail.id}{detail.ver ? ` · 版本 ${detail.ver}` : ''}</div>
|
||
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : detail.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(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} selected={{ kind: detail.kind, id: detail.id }} />)}
|
||
</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>
|
||
)
|
||
}
|