From d9bb780384b16f661d71a5e6ad288a77426d9d98 Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 12 Oct 2026 15:38:00 +0000 Subject: [PATCH] Add an audit overview export that stays outside the readiness gate Why: auditors need to generate and download a watermarked report from the overview, and that must not appear when the export commands are off or change whether the workspace itself opens. Evidence: SELF npm test, 138 passed. Playwright download case is in e2e/rdm-audit.playwright.ts and runs under scripts/e2e.sh. Impact: the overview shows type, generate, draft confirmation, watermark, sha256, expiry, and download only when the three export commands are enabled. Refs: RDM pull request 301 Co-authored-by: Cursor --- e2e/rdm-audit.playwright.ts | 20 +++++ src/components/RdmAuditWorkspace.tsx | 116 ++++++++++++++++++++++++++- src/store.ts | 8 +- src/styles.css | 8 ++ src/workspaces.ts | 18 +++++ test/workspaces.test.ts | 27 ++++++- 6 files changed, 192 insertions(+), 5 deletions(-) diff --git a/e2e/rdm-audit.playwright.ts b/e2e/rdm-audit.playwright.ts index a75ce77..7b1f21c 100644 --- a/e2e/rdm-audit.playwright.ts +++ b/e2e/rdm-audit.playwright.ts @@ -123,4 +123,24 @@ test.describe.serial('U4 项目追溯审计工作区', () => { await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '', ) }) + + test('概览导出:生成后显示水位与校验和,并可下载', async ({ page }) => { + await login(page) + await openAudit(page) + const exportBox = page.locator('.rdm-audit-export') + await expect(exportBox).toBeVisible() + await exportBox.getByLabel('导出类型').selectOption('cgraph-docx') + await exportBox.getByRole('button', { name: '生成', exact: true }).click() + const draft = exportBox.getByRole('button', { name: '生成草稿' }) + if (await draft.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false)) { + await draft.click() + } + await expect(exportBox.getByText(/水位 \d+/)).toBeVisible({ timeout: 15_000 }) + await expect(exportBox.getByText(/sha256 [0-9a-f]{64}/)).toBeVisible() + await expect(exportBox.getByText(/有效期/)).toBeVisible() + const download = page.waitForEvent('download') + await exportBox.getByRole('button', { name: '下载' }).click() + const file = await download + expect(file.suggestedFilename()).toMatch(/\.docx$/) + }) }) diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index 7653201..fd9f9a7 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -5,6 +5,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { api } from '../api' import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui' import { classifyRdmError } from '../rdmErrors' +import { useStore } from '../store' +import { exportNeedsDraft } from '../workspaces' // ---- 类型(U3 ListShell/详情的精简前端契约) ---- @@ -59,6 +61,7 @@ export function RdmAuditWorkspace() { }, []) const project = caps?.projects.find(p => p.projectId === pid) + const exportOn = useStore(s => s.rdmExportAvailable) if (error) return if (!caps) return
加载中…
if (caps.projects.length === 0) return 无可见研发项目——项目创建者与域管理员可查看追溯审计 @@ -82,7 +85,7 @@ export function RdmAuditWorkspace() { ))}
- {tab === 'summary' && } + {tab === 'summary' && } {tab === 'materials' && } {tab === 'trace' && } {tab === 'timeline' && } @@ -93,7 +96,7 @@ export function RdmAuditWorkspace() { // ---- 概览 ---- -function SummaryTab({ pid }: { pid: string }) { +function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) { const [data, setData] = useState(null) const [err, setErr] = useState('') useEffect(() => { @@ -133,6 +136,115 @@ function SummaryTab({ pid }: { pid: string }) { {data.coverage && data.coverage.length > 0 && ( )} + {exportOn && } +
+ ) +} + +// ---- 概览导出(三条命令都启用才挂上;不参与七命令就绪门) ---- + +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 saveExportFile(fileName: string, mime: string, dataB64: string) { + const bin = atob(dataB64) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i) + const blob = new Blob([bytes], { type: mime || 'application/octet-stream' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = fileName + a.click() + URL.revokeObjectURL(url) +} + +function ExportSection({ pid }: { pid: string }) { + 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(() => { + setArtifact(null) + setErr('') + setNeedDraft(false) + }, [pid]) + + const generate = async (draft: boolean) => { + setBusy(true) + setErr('') + setNeedDraft(false) + try { + const created = await api.rdmCommand('rdm.inspect.export.create', { projectId: pid, kind, draft }) + const st = await api.rdmCommand('rdm.inspect.export.status', { projectId: pid, exportId: created.exportId }) + setArtifact({ ...created, expiresAt: st.expiresAt }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + if (!draft && exportNeedsDraft(msg)) setNeedDraft(true) + else setErr(msg) + } finally { + setBusy(false) + } + } + + const download = async () => { + if (!artifact) return + setErr('') + try { + const dl = await api.rdmCommand('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId }) + saveExportFile(dl.fileName, dl.mime, dl.dataB64) + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } + } + + return ( +
+ 导出 +
+ + +
+ {needDraft && ( +
+

正式报告不完整。可以生成带缺口标注的草稿。

+ +
+ )} + {err &&
{err}
} + {artifact && ( +
+
水位 {artifact.watermark}
+
sha256 {artifact.sha256}
+
有效期 {artifact.expiresAt ? new Date(artifact.expiresAt).toLocaleString('zh-CN') : '—'}
+ {artifact.draft &&
草稿
} + +
+ )}
) } diff --git a/src/store.ts b/src/store.ts index 32e89b8..4efa0d7 100644 --- a/src/store.ts +++ b/src/store.ts @@ -9,7 +9,7 @@ import { canFlushOutbox, deriveShellPhase } from './runstate' import { flashElement } from './scrollsync' import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme' import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey' -import { rdmAuditReady } from './workspaces' +import { rdmAuditReady, rdmExportReady } from './workspaces' import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement' type FontSizeChoice = 13 | 14 | 16 | 18 @@ -117,6 +117,8 @@ interface AppState { serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[] /** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */ rdmAuditAvailable: boolean + /** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */ + rdmExportAvailable: boolean /** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */ rdmRolesAvailable: boolean rdmMembersAvailable: boolean @@ -188,6 +190,7 @@ let state: AppState = { plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], rdmAuditAvailable: false, + rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, adminSections: [], ui: loadUI(), connected: false, posting: false, error: null, toast: null, @@ -812,12 +815,13 @@ export const store = { const cmds = await api.listCommands() set({ rdmAuditAvailable: rdmAuditReady(cmds), + rdmExportAvailable: rdmExportReady(cmds), rdmRolesAvailable: rdmRolesReady(cmds), rdmMembersAvailable: rdmMembersReady(cmds), rdmDocReviewAvailable: rdmDocReviewReady(cmds), rdmPermMatrixAvailable: rdmPermMatrixReady(cmds), }) - } catch { set({ rdmAuditAvailable: false }) } + } catch { set({ rdmAuditAvailable: false, rdmExportAvailable: false }) } try { const sections = await api.adminZoneSections() set({ adminSections: sections ?? [] }) diff --git a/src/styles.css b/src/styles.css index 15e659d..c20c68e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -627,6 +627,14 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .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-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; } +.rdm-audit-export-kind { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); } +.rdm-audit-export-kind .ui-input { min-width: 180px; height: 32px; font-size: var(--fs-ui-sm); } +.rdm-audit-export-draft { padding: 8px 10px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); } +.rdm-audit-export-draft p { margin: 0 0 8px; } +.rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); } +.rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); } .rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); } .rdm-audit-err { display: flex; flex-direction: column; gap: 8px; } .rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); } diff --git a/src/workspaces.ts b/src/workspaces.ts index e455f24..8e99a45 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -30,6 +30,24 @@ export const RDM_AUDIT_COMMANDS = [ 'rdm.inspect.history', ] as const +/** 概览页导出区单独探测。不并入上面的七命令就绪门。 */ +export const RDM_EXPORT_COMMANDS = [ + 'rdm.inspect.export.create', + 'rdm.inspect.export.status', + 'rdm.inspect.export.download', +] as const + +/** 三条导出命令都启用才显示概览导出区。缺一或清单不可用则隐藏。 */ +export function rdmExportReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean { + if (!Array.isArray(cmds)) return false + return RDM_EXPORT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true)) +} + +/** 正式报告被完整性门拒绝时,界面先确认再带 draft 重试。 */ +export function exportNeedsDraft(message: string): boolean { + return /不完整|草稿/.test(message) +} + /** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */ export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean { if (!Array.isArray(cmds)) return false diff --git a/test/workspaces.test.ts b/test/workspaces.test.ts index 429a0f0..167e608 100644 --- a/test/workspaces.test.ts +++ b/test/workspaces.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces' +import { buildWorkspaceEntries, rdmAuditReady, rdmExportReady, exportNeedsDraft, RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } from '../src/workspaces' // 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。 // 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座 @@ -72,4 +72,29 @@ describe('rdmAuditReady', () => { expect(rdmAuditReady(null)).toBe(false) expect(rdmAuditReady(undefined)).toBe(false) }) + + it('导出命令不参与七命令就绪门', () => { + expect(rdmAuditReady(all)).toBe(true) + const withExportOff = [ + ...all, + { id: 'rdm.inspect.export.create', enabled: false }, + ] + expect(rdmAuditReady(withExportOff)).toBe(true) + }) +}) + +describe('rdmExportReady', () => { + const audit = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true })) + const exp = RDM_EXPORT_COMMANDS.map(id => ({ id, enabled: true })) + + it('三条导出命令启用才显示导出区', () => { + expect(rdmExportReady([...audit, ...exp])).toBe(true) + expect(rdmExportReady(audit)).toBe(false) + expect(rdmExportReady(exp.filter(c => c.id !== 'rdm.inspect.export.download'))).toBe(false) + }) + + it('正式报告不完整时才要求草稿确认', () => { + expect(exportNeedsDraft('正式报告不完整(合规缺口)——传 draft:true 生成草稿')).toBe(true) + expect(exportNeedsDraft('项目不存在')).toBe(false) + }) })