diff --git a/src/api.ts b/src/api.ts index 8534172..cc23761 100644 --- a/src/api.ts +++ b/src/api.ts @@ -127,6 +127,21 @@ export const api = { savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)), deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }), listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>, + + // ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ---- + // receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。 + rdmCommand: (id: string, params: Record = {}): Promise => + auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ params }), + }).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r)) + .then(out => { + const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } + if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝') + if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行') + if (envelope && envelope.payload !== undefined) return envelope.payload + return out as T + }), } // ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ---- diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx new file mode 100644 index 0000000..83605e0 --- /dev/null +++ b/src/components/RdmAuditWorkspace.tsx @@ -0,0 +1,467 @@ +// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。 +// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。 +// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。 +import { useCallback, useEffect, useRef, useState } from 'react' +import { api } from '../api' +import { Chip, EmptyState, SectionLabel } from '@ui' + +// ---- 类型(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 { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] } +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 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'] + +export function RdmAuditWorkspace() { + const [caps, setCaps] = useState(null) + const [pid, setPid] = useState('') + const [tab, setTab] = useState('summary') + const [error, setError] = useState('') + + useEffect(() => { + let cancelled = false + api.rdmCommand('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 {error} + if (!caps) return
加载中…
+ if (caps.projects.length === 0) return 无可见研发项目——项目创建者与域管理员可查看追溯审计 + + return ( +
+
+ + {project?.canAudit && 可审计} + {caps.crossProject && 跨项目} +
+
+ {TABS.map(t => ( + + ))} +
+
+ {tab === 'summary' && } + {tab === 'materials' && } + {tab === 'trace' && } + {tab === 'timeline' && } +
+
+ ) +} + +// ---- 概览 ---- + +function SummaryTab({ pid }: { pid: string }) { + const [data, setData] = useState(null) + const [err, setErr] = useState('') + useEffect(() => { + let c = false + setData(null); setErr('') + api.rdmCommand('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 {err} + if (!data) return
加载中…
+ 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 ( +
+ 统计(截至水位 {data.watermark}) +
+ {entries.map(([label, , val]) => ( +
+
{val}
+
{label}
+
+ ))} +
+ {data.lastOpAt &&
最近动作:{new Date(data.lastOpAt).toLocaleString('zh-CN')}
} + {data.coverage && data.coverage.length > 0 && ( + + )} +
+ ) +} + +// ---- 资料与文档 ---- + +function MaterialsTab({ pid }: { pid: string }) { + const [kind, setKind] = useState<'notes' | 'docs'>('docs') + return ( +
+
+
+ + +
+ +
+
+ ) +} + +function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) { + const [shell, setShell] = useState | null>(null) + const [err, setErr] = useState('') + const [selected, setSelected] = useState(null) + const [detail, setDetail] = useState(null) + const genRef = useRef(0) + + useEffect(() => { + const gen = ++genRef.current + setShell(null); setErr(''); setSelected(null); setDetail(null) + api.rdmCommand>('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>('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 selectNode = (item: TreeItem) => { + setSelected(item); setDetail(null) + const gen = genRef.current + api.rdmCommand('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 {err} + if (!shell) return
加载中…
+ if (shell.items.length === 0) return 暂无{kind === 'notes' ? '骨架节点' : '文档'} + + return ( +
+
+ {shell.items.map(item => ( + + ))} + {shell.hasMore && } + {shell.truncated &&
已截断(深度或数量上限)
} +
+ {selected && ( +
+ 对象详情 + {!detail ?
加载中…
: } +
+ )} +
+ ) +} + +// ---- 关联追溯 ---- + +function TraceTab({ pid }: { pid: string }) { + const [sel, setSel] = useState<{ kind: string; id: string; title: string } | null>(null) + const [relations, setRelations] = useState(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>('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 [sections, setSections] = useState([]) + useEffect(() => { + if (sel) return + let c = false + api.rdmCommand>('rdm.inspect.tree', { projectId: pid, kind: 'docs' }) + .then(s => { if (!c) setSections(s.items.filter(i => i.kind === 'section')) }) + .catch(() => {}) + return () => { c = true } + }, [pid, sel]) + + if (!sel) { + return ( +
+ 选择对象查看来源与影响 + {sections.length === 0 ? 暂无章节 : ( +
+ {sections.map(s => ( + + ))} +
+ )} +
+ ) + } + + 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 ( +
+
+ + {sel.title} + {sel.kind}:{sel.id} +
+ {err && {err}} + {!relations && !err &&
加载中…
} + {relations && relations.length === 0 && 没有已知关联} + {relations && relations.length > 0 && ( +
+
+ 来源依据({sources.length}) + {sources.length === 0 ?
无来源
: sources.map((r, i) => )} +
+
+ 影响对象({impacts.length}) + {impacts.length === 0 ?
无影响
: impacts.map((r, i) => )} +
+
+ )} + {truncated &&
已截断(深度或数量上限)
} +
+ ) +} + +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 ( +
+ {r.layer} + {r.type || r.kind || '关联'} + {other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''} + {r.state} +
+ ) +} + +// ---- 操作记录(事件时间线) ---- + +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(null) + const genRef = useRef(0) + + const load = useCallback((cur: string, replace: boolean) => { + const gen = ++genRef.current + if (replace) { setItems([]); setErr('') } + const params: Record = { projectId: pid, cursor: cur, limit: 30 } + if (filterOp) params.op = filterOp + if (filterActor) params.actor = filterActor + api.rdmCommand>('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 = { projectId: pid, atSeq: historySeq || undefined } + api.rdmCommand('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 ( +
+
+ setFilterActor(e.target.value)} /> + setFilterOp(e.target.value)} /> + + {historyMode && ( + setHistorySeq(Number(e.target.value) || 0)} /> + )} +
+ + {historyMode ? ( +
+ {!historyData ?
加载中…
: ( +
+ 截至 seq {historyData.atSeq}(当前水位 {historyData.watermark}) + {historyData.countOnlySnapshots ?
含 {historyData.countOnlySnapshots} 个旧计数快照(不可作恢复依据)
: null} + {historyData.formalMissing ?
{historyData.formalMissing}
: null} + {historyData.object ? ( +
+
标题:{historyData.object.title}
+
状态:{historyData.object.state}
+ {historyData.object.contentPreview &&
内容预览:{historyData.object.contentPreview}
} +
+ ) : historyData.empty ? 该时点无事件 : +
输入对象 kind:id 查看历史快照
} + {historyData.formal && ( +
+ 正式版 R{historyData.formal.rev}(内容 v{historyData.formal.seq})——{historyData.formal.actor} 于 {new Date(historyData.formal.at).toLocaleString('zh-CN')} +
+ )} +
+ )} +
+ ) : ( +
+ {err && {err}} + {!err && items.length === 0 && 暂无事件} + {items.map((item, i) => { + if ('events' in item && Array.isArray((item as OpGroup).events)) { + return + } + return + })} + {hasMore && } +
+ )} +
+ ) +} + +function OpGroupView({ group }: { group: OpGroup }) { + const [open, setOpen] = useState(false) + return ( +
+ + {open && group.events.map(ev => )} +
+ ) +} + +function EventRow({ ev }: { ev: EventDTO }) { + 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' ? '模型候选' : '来源受限'}} +
+ ) +} + +// ---- 对象详情视图 ---- + +function ObjectDetailView({ detail }: { detail: ObjectDetail }) { + return ( +
+
{detail.title || detail.id}
+
{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}
+ {detail.state &&
状态:{detail.state}
} + {detail.labels &&
标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}
} + {detail.versions && detail.versions.length > 0 && ( +
+ 版本链({detail.versions.length}) + {detail.versions.map((v: any, i: number) => ( +
+ v{v.seq} · {v.actor} · {new Date(v.at).toLocaleString('zh-CN')} +
+ ))} +
+ )} + {detail.formal && ( +
+ 正式版 R{detail.formal.rev}(内容 v{detail.formal.seq})——{detail.formal.actor} 于 {new Date(detail.formal.at).toLocaleString('zh-CN')} +
+ )} + {detail.relations && detail.relations.length > 0 && ( +
+ 一跳关系({detail.relations.length}) + {detail.relations.map((r, i) => )} +
+ )} + {detail.recentEvents && detail.recentEvents.length > 0 && ( +
+ 最近事件 + {detail.recentEvents.map(ev => )} +
+ )} +
+ ) +} + +// ---- 覆盖范围标注(不冒充完整) ---- + +function CoverageList({ cov }: { cov: CoverageItem[] }) { + return ( +
+ 覆盖范围 + {cov.map(c => ( +
+ {c.face} + {c.reason} +
+ ))} +
+ ) +} diff --git a/src/components/Workspaces.tsx b/src/components/Workspaces.tsx index b63bcc0..da0b3eb 100644 --- a/src/components/Workspaces.tsx +++ b/src/components/Workspaces.tsx @@ -7,6 +7,7 @@ import { OverlayHost, type DocBridge } from '@ui' import { docBridge } from './Workbench' import { AgentRow } from './Workbench' import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces' +import { RdmAuditWorkspace } from './RdmAuditWorkspace' /** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */ export function workspaceEntries(): WorkspaceEntry[] { @@ -28,6 +29,11 @@ export function Workspaces() { } + if (active === 'rdm-audit') { + return + + + } if (active.startsWith('plugin:')) { const panel = pluginPanels.find(p => `plugin:${p.id}` === active) return [] = [ { key: 'agents', title: '代理', icon: '🤖' }, + { key: 'rdm-audit', title: '追溯审计', icon: '🔍' }, ] function shortcutNum(raw?: string): number | null {