diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index a3b2e88..b2e152a 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -5,6 +5,10 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { api } from '../api' import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui' import { classifyRdmError } from '../rdmErrors' +import { + describeAuditEvent, humanizeKind, humanizeRelation, humanizeState, + type AuditEventViewInput, +} from '../rdmAuditPresentation' import { store, useStore } from '../store' import { exportNeedsDraft } from '../workspaces' @@ -17,7 +21,7 @@ interface ListShell { items: T[]; nextCursor?: string; hasMore: boolean interface SummaryData { projectId: string; counts: Record; 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 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 } @@ -32,6 +36,18 @@ const TABS = [ ] as const 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', '回答风险澄清'], +] as const + /** 错误态(增量 2):分类文案(能力缺失/登录失效/查询失败)+ 原始回执折叠供诊断; * 「无可审计项目」由调用处独立空态承担,不走本组件。 */ function RdmErrorState({ raw }: { raw: string }) { @@ -347,9 +363,9 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { {item.kind === 'doc' && children[item.id] && (
@@ -357,9 +373,9 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { {children[item.id].map(sub => ( ))}
@@ -440,7 +456,7 @@ function TraceTab({ pid }: { pid: string }) {
{sel.title} - {sel.kind}:{sel.id} + {humanizeKind(sel.kind)} · {sel.id}
{err && } {!relations && !err &&
加载中…
} @@ -464,12 +480,13 @@ function TraceTab({ pid }: { pid: string }) { 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) + const readable = humanizeRelation(r) return (
- {r.layer} - {r.type || r.kind || '关联'} - {other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''} - {r.state} + {readable.layer} + {readable.type} + {humanizeKind(other?.kind)}“{other?.title || other?.id || '未知对象'}”{other?.ver ? `(版本 ${other.ver})` : ''} + {readable.state}
) } @@ -521,8 +538,9 @@ function TimelineTab({ pid }: { pid: string }) {
setFilterActor(e.target.value)} /> - setFilterOp(e.target.value)} /> + @@ -573,27 +591,38 @@ function TimelineTab({ pid }: { pid: string }) { } function OpGroupView({ group }: { group: OpGroup }) { - const [open, setOpen] = useState(false) + const first = group.events[0] ? describeAuditEvent(group.events[0]) : null return (
- - {open && group.events.map(ev => )} + + {first?.subject || group.actor} + {first?.action || '项目操作'} + {new Date(group.at).toLocaleString('zh-CN')} + 包含 {group.events.length} 项记录 + + )}> + {group.events.map(ev => )} +
) } function EventRow({ ev }: { ev: EventDTO }) { + const view = describeAuditEvent(ev) return (
- {new Date(ev.at).toLocaleTimeString('zh-CN')} - {ev.op} - {ev.actor} - {ev.detail && {ev.detail.slice(0, 80)}} - {ev.disclosure !== 'ok' && {ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}} +
+ {new Date(ev.at).toLocaleString('zh-CN')} + {view.action} + {view.outcome} +
+
{view.subject}处理了{view.object}
+
+
原因
{view.reason}
+
影响
{view.impact}
+
依据
{view.source}
+
) } @@ -604,8 +633,8 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) { return (
{detail.title || detail.id}
-
{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}
- {detail.state &&
状态:{detail.state}
} +
{humanizeKind(detail.kind)} · 对象编号 {detail.id}{detail.ver ? ` · 版本 ${detail.ver}` : ''}
+ {detail.state &&
状态:{humanizeState(detail.state)}
} {detail.labels &&
标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}
} {detail.versions && detail.versions.length > 0 && (
diff --git a/src/rdmAuditPresentation.test.ts b/src/rdmAuditPresentation.test.ts new file mode 100644 index 0000000..fbcc6ce --- /dev/null +++ b/src/rdmAuditPresentation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { describeAuditEvent, humanizeKind, humanizeRelation, humanizeState } from './rdmAuditPresentation' + +describe('RDM audit presentation', () => { + it('turns a structured document event into a readable action and impact', () => { + expect(describeAuditEvent({ + seq: 18, + at: '2026-10-15T08:00:00Z', + op: 'doc.instantiate', + actor: 'alice', + source: 'template:dhf-proposal@v2', + target: { kind: 'doc', id: 'doc-1', title: '项目建议书' }, + detail: '{"raw":"must not be shown"}', + operationId: 'op-1', + disclosure: 'ok', + result: 'committed', + impact: '新增 12 个递归节点', + reason: '从已发布模板建立项目实例', + })).toEqual({ + action: '创建文档实例', + subject: 'Alice', + object: '文档“项目建议书”', + outcome: '已生效', + reason: '从已发布模板建立项目实例', + impact: '新增 12 个递归节点', + source: '模板:DHF 项目建议书(版本 v2)', + }) + }) + + it('does not expose unknown operation codes or raw JSON as user-facing text', () => { + const view = describeAuditEvent({ + seq: 19, + at: '2026-10-15T08:01:00Z', + op: 'internal.weird_opcode', + actor: 'user-42', + source: 'host', + target: { kind: 'node', id: 'node-secret' }, + detail: '{"secret":"raw payload"}', + operationId: 'op-2', + disclosure: 'restricted', + }) + expect(view.action).toBe('更新项目内容') + expect(JSON.stringify(view)).not.toContain('internal.weird_opcode') + expect(JSON.stringify(view)).not.toContain('raw payload') + expect(view.outcome).toBe('部分信息受限') + }) + + it('humanizes tree, relationship, and lifecycle vocabulary', () => { + expect(humanizeKind('section')).toBe('章节') + expect(humanizeState('candidate')).toBe('待确认') + expect(humanizeRelation({ layer: 'compliance', type: 'verifies', state: 'active' })).toEqual({ + layer: '合规关系', + type: '验证', + state: '已生效', + }) + }) +}) diff --git a/src/rdmAuditPresentation.ts b/src/rdmAuditPresentation.ts new file mode 100644 index 0000000..3c15f3c --- /dev/null +++ b/src/rdmAuditPresentation.ts @@ -0,0 +1,134 @@ +export interface AuditEventViewInput { + seq: number + at: string + op: string + actor: string + source: string + target: { kind: string; id: string; title?: string } + detail?: string + operationId: string + disclosure: string + summary?: string + result?: string + reason?: string + impact?: string +} + +interface AuditEventPresentation { + action: string + subject: string + object: string + outcome: string + reason: string + impact: string + source: string +} + +const KIND_LABELS: Record = { + doc: '文档', section: '章节', node: '节点', note: '知识', slice: '资料切片', + requirement: '要求', risk: '风险', proposal: '变更提案', relation: '追溯关系', + template: '模板', project: '项目', conclusion: '结论', issue: '问题', +} + +const STATE_LABELS: Record = { + active: '已生效', candidate: '待确认', pending: '待处理', baselined: '已建立基线', + approved: '已批准', rejected: '已拒绝', revoked: '已撤销', superseded: '已被取代', + failed: '失败', committed: '已生效', accepted: '已接受', cancelled: '已取消', + unknown: '结果未知', restricted: '部分信息受限', +} + +const OP_LABELS: Record = { + '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': '更新项目要求', +} + +const RELATION_LABELS: Record = { + verifies: '验证', supports: '支持', derives: '派生自', impacts: '影响', + traces: '追溯到', replaces: '取代', conflicts: '存在冲突', references: '引用', +} + +const LAYER_LABELS: Record = { + compliance: '合规关系', knowledge: '知识关系', document: '文档关系', project: '项目关系', +} + +export function humanizeKind(kind: string): string { + return KIND_LABELS[kind] || '项目对象' +} + +export function humanizeState(state: string): string { + return STATE_LABELS[state] || '状态已记录' +} + +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('risk.')) return '处理项目风险' + if (op.startsWith('proposal.')) return '处理结构变更' + return '更新项目内容' +} + +function humanizeActor(actor: string): string { + if (!actor) return '未知成员' + if (/^[a-z][a-z0-9._-]*$/i.test(actor) && !actor.includes('-') && !actor.includes('_')) { + return actor.charAt(0).toUpperCase() + actor.slice(1) + } + return actor +} + +function humanizeSource(source: string): string { + if (!source) return '当时未记录来源' + const template = /^template:([^@]+)@(.+)$/.exec(source) + if (template) { + const title = template[1] === 'dhf-proposal' ? 'DHF 项目建议书' : template[1] + return `模板:${title}(版本 ${template[2]})` + } + if (source === 'host') return '系统受信操作记录' + if (source.startsWith('message:')) return '项目对话' + if (source.startsWith('artifact:')) return '项目文件' + return '已登记项目来源' +} + +function humanizeOutcome(input: AuditEventViewInput): string { + if (input.disclosure !== 'ok') return input.disclosure === 'model-candidate' ? '模型候选,尚未确认' : '部分信息受限' + return humanizeState(input.result || 'committed') +} + +export function describeAuditEvent(input: AuditEventViewInput): AuditEventPresentation { + const targetTitle = input.target?.title ? `“${input.target.title}”` : '' + return { + action: input.summary || humanizeOperation(input.op), + subject: humanizeActor(input.actor), + object: `${humanizeKind(input.target?.kind)}${targetTitle}`, + outcome: humanizeOutcome(input), + reason: input.reason || '未记录具体原因', + impact: input.impact || '影响范围尚未记录', + source: humanizeSource(input.source), + } +} + +export function humanizeRelation(input: { layer?: string; type?: string; kind?: string; state?: string }) { + const type = input.type || input.kind || '' + return { + layer: LAYER_LABELS[input.layer || ''] || '项目关系', + type: RELATION_LABELS[type] || '关联', + state: humanizeState(input.state || ''), + } +} diff --git a/src/styles.css b/src/styles.css index 54016a1..9aa1e01 100644 --- a/src/styles.css +++ b/src/styles.css @@ -625,12 +625,17 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; } .rdm-audit-opgroup-head:hover { background: var(--bg-hover); } .rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); } +.rdm-audit-opgroup-action { color: var(--text-dim); } .rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); } -.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); } -.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; } -.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; } -.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; } -.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); } +.rdm-audit-event { display: flex; flex-direction: column; gap: 8px; padding: 10px 12px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); } +.rdm-audit-event-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-dim); } +.rdm-audit-event-action { color: var(--text); } +.rdm-audit-event-summary { color: var(--text); } +.rdm-audit-event-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 0; } +.rdm-audit-event-fields > div { min-width: 0; padding: 8px; background: var(--bg-raised); border-radius: var(--radius-sm); } +.rdm-audit-event-fields dt { color: var(--text-dim); font-size: var(--fs-ui-xs); } +.rdm-audit-event-fields dd { margin: 3px 0 0; color: var(--text); overflow-wrap: anywhere; } .rdm-audit-coverage { margin-top: 12px; } .rdm-audit-export { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; } .rdm-audit-export-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; } @@ -644,3 +649,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .rdm-audit-err { display: flex; flex-direction: column; gap: 8px; } .rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); } .rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; } +@media (max-width: 760px) { + .rdm-audit-event-fields { grid-template-columns: 1fr; } + .rdm-audit-trace-cols { grid-template-columns: 1fr; } +}