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/e2e/rdm-export-races.playwright.ts b/e2e/rdm-export-races.playwright.ts new file mode 100644 index 0000000..eb73084 --- /dev/null +++ b/e2e/rdm-export-races.playwright.ts @@ -0,0 +1,107 @@ +// Isolated browser component regression: actual React workspace, deliberately deferred API responses. +// This checks client request ownership, not server authorization or business acceptance. +import { test, expect, type Page } from '@playwright/test' + +test.skip(!process.env.PLAYWRIGHT_COMPONENT_BASE_URL, 'requires the isolated Vite component test server') +test.use({ baseURL: process.env.PLAYWRIGHT_COMPONENT_BASE_URL }) + +async function mount(page: Page, delayed: string) { + const index = await page.request.get('/') + const html = (await index.text()).replace(/]+src="\/src\/main\.tsx[^" ]*"[^>]*><\/script>/, '') + // Let Vite prepare ReactDOM before requesting its optimized module directly. + await page.request.get('/src/main.tsx') + await page.route('**/__export_test', route => route.fulfill({ contentType: 'text/html', + body: html })) + await page.goto('/__export_test') + await page.waitForFunction(() => typeof (window as any).$RefreshReg$ === 'function') + await page.evaluate(async delayed => { + const load = (path: string) => import(/* @vite-ignore */ path) + const { default: React } = await load('/node_modules/.vite/deps/react.js') + const { default: ReactDOM } = await load('/node_modules/.vite/deps/react-dom_client.js') + const { RdmAuditWorkspace } = await load('/src/components/RdmAuditWorkspace.tsx') + const { api } = await load('/src/api.ts') + const { auth } = await load('/src/auth.ts') + const { store } = await load('/src/store.ts') + const { RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } = await load('/src/workspaces.ts') + const calls: { command: string; params: any }[] = [] + let actor = 'alice' + const pending: (() => void)[] = [] + let downloads = 0 + URL.createObjectURL = () => { downloads++; return 'blob:export-test' } + URL.revokeObjectURL = () => {} + HTMLAnchorElement.prototype.click = () => {} + api.listPlugins = async () => [] + api.listWorkspaces = async () => [] + api.listCommands = async () => [...RDM_AUDIT_COMMANDS, ...RDM_EXPORT_COMMANDS].map(id => ({ id, enabled: true })) + api.adminZoneSections = async () => [] + api.getSettings = async () => ({ user: {} }) + api.rdmCommand = async (command: string, params: any = {}) => { + calls.push({ command, params }) + const result = command === 'rdm.inspect.capabilities' + ? { actor, crossProject: false, projects: ['A', 'B'].map(projectId => ({ projectId, name: actor + projectId, canAudit: true })) } + : command === 'rdm.inspect.summary' + ? { projectId: params.projectId, counts: {}, watermark: 7 } + : command === 'rdm.inspect.export.create' + ? { exportId: params.projectId + '-report', watermark: 7, sha256: 'abc', fileName: 'report.docx' } + : command === 'rdm.inspect.export.status' + ? { expiresAt: '2099-01-01', watermark: 7, sha256: 'abc' } + : { fileName: 'report.docx', mime: 'application/octet-stream', dataB64: 'QQ==', sha256: 'abc' } + if (command === delayed && params.projectId === 'A') { + await new Promise(resolve => pending.push(resolve)) + } + return result + } + await store.refreshPlugins() + const root = ReactDOM.createRoot(document.getElementById('root')!) + root.render(React.createElement(RdmAuditWorkspace)) + ;(window as any).exportTest = { calls, release: () => pending.splice(0).forEach(resolve => resolve()), + downloads: () => downloads, identity: async () => { actor = 'bob'; auth.beginSession(); await store.refreshPlugins() }, + unmount: () => root.unmount() } + }, delayed) + await expect(page.locator('.rdm-audit-export')).toBeVisible() +} +async function release(page: Page) { + await page.evaluate(async () => { (window as any).exportTest.release(); await new Promise(resolve => setTimeout(resolve, 0)) }) +} +const generate = (page: Page) => page.getByRole('button', { name: '生成', exact: true }).click() + +for (const stage of ['create', 'status']) { + test(`ignores old ${stage} response after project A -> B -> A`, async ({ page }) => { + await mount(page, 'rdm.inspect.export.' + stage) + await generate(page) + await expect.poll(() => page.evaluate(stage => (window as any).exportTest.calls + .some((c: any) => c.command === 'rdm.inspect.export.' + stage), stage)).toBe(true) + await page.getByLabel('选择项目').selectOption('B') + await expect(page.locator('.rdm-audit-export')).toBeVisible() + await page.getByLabel('选择项目').selectOption('A') + await expect(page.locator('.rdm-audit-export')).toBeVisible() + await release(page) + await expect(page.getByRole('button', { name: '下载', exact: true })).toHaveCount(0) + if (stage === 'create') expect(await page.evaluate(() => (window as any).exportTest.calls + .filter((c: any) => c.command === 'rdm.inspect.export.status').length)).toBe(0) + await expect(page.getByRole('button', { name: '生成', exact: true })).toBeEnabled() + }) +} +for (const change of ['project', 'identity', 'unmount']) { + test(`suppresses delayed download after ${change} change`, async ({ page }) => { + await mount(page, 'rdm.inspect.export.download') + await generate(page) + await page.getByRole('button', { name: '下载', exact: true }).click() + if (change === 'project') { + await page.getByLabel('选择项目').selectOption('B') + await expect(page.locator('.rdm-audit-export')).toBeVisible() + } else if (change === 'identity') { + await page.evaluate(() => (window as any).exportTest.identity()) + await expect(page.getByLabel('选择项目').locator('option').first()).toHaveText('bobA') + } else await page.evaluate(() => (window as any).exportTest.unmount()) + await release(page) + expect(await page.evaluate(() => (window as any).exportTest.downloads())).toBe(0) + }) +} +test('current identity can generate and download normally', async ({ page }) => { + await mount(page, '') + await generate(page) + await page.getByRole('button', { name: '下载', exact: true }).click() + await expect.poll(() => page.evaluate(() => (window as any).exportTest.downloads())).toBe(1) + await expect(page.locator('.rdm-audit-export-result')).toContainText('水位 7') +}) diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index 7653201..a3b2e88 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 { store, useStore } from '../store' +import { exportNeedsDraft } from '../workspaces' // ---- 类型(U3 ListShell/详情的精简前端契约) ---- @@ -45,22 +47,30 @@ function RdmErrorState({ raw }: { raw: string }) { } export function RdmAuditWorkspace() { + const identity = useStore(() => store.getIdentityEpoch()) const [caps, setCaps] = useState(null) + const [capsIdentity, setCapsIdentity] = useState(-1) const [pid, setPid] = useState('') const [tab, setTab] = useState('summary') const [error, setError] = useState('') useEffect(() => { let cancelled = false + setCaps(null); setPid(''); setError('') 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) }) + .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 - if (!caps) return
加载中…
+ if (!caps || capsIdentity !== identity) return
加载中…
if (caps.projects.length === 0) return 无可见研发项目——项目创建者与域管理员可查看追溯审计 return ( @@ -82,7 +92,7 @@ export function RdmAuditWorkspace() { ))}
- {tab === 'summary' && } + {tab === 'summary' && } {tab === 'materials' && } {tab === 'trace' && } {tab === 'timeline' && } @@ -93,7 +103,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 +143,131 @@ 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 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('rdm.inspect.export.create', { projectId: pid, kind, draft }) + if (!current()) return + const st = await api.rdmCommand('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('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId }) + if (!current()) return + saveExportFile(dl.fileName, dl.mime, dl.dataB64) + } catch (e) { + if (current()) setErr(e instanceof Error ? e.message : String(e)) + } finally { + if (current()) setBusy(false) + } + } + + 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 3766b70..e35de0b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -11,7 +11,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 @@ -121,6 +121,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 @@ -194,6 +196,7 @@ let state: AppState = { plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, 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, @@ -404,6 +407,7 @@ export function useStore(selector: (s: AppState) => T): T { export const store = { getState: () => state, + getIdentityEpoch: () => identityEpoch, resetIdentity() { identityEpoch++; projectsRequest++; tasksRequest++; meRequest++ @@ -424,7 +428,7 @@ export const store = { plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [], - rdmAuditAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, + rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, previewTabs: {}, previewActive: {}, previewOpenIntent: 0 }) @@ -1167,6 +1171,7 @@ export const store = { if (epoch !== identityEpoch) return set({ rdmAuditAvailable: rdmAuditReady(cmds), + rdmExportAvailable: rdmExportReady(cmds), rdmRolesAvailable: rdmRolesReady(cmds), rdmMembersAvailable: rdmMembersReady(cmds), rdmDocReviewAvailable: rdmDocReviewReady(cmds), @@ -1174,7 +1179,7 @@ export const store = { }) } catch { if (epoch !== identityEpoch) return - set({ rdmAuditAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, + set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false }) } try { 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/project-access.test.ts b/test/project-access.test.ts index c6e82c2..91c6cf0 100644 --- a/test/project-access.test.ts +++ b/test/project-access.test.ts @@ -347,3 +347,17 @@ describe('identity-scoped state', () => { expect(await pending).toBeNull() }) }) + + +describe('export capability identity', () => { + it('drops the old identity export capability before any new command refresh', async () => { + const previous = route + route = async (path, init) => path === '/api/commands' + ? response(['rdm.inspect.export.create', 'rdm.inspect.export.status', 'rdm.inspect.export.download'] + .map(id => ({ id, enabled: true }))) : previous(path, init) + await store.refreshPlugins() + expect(store.getState().rdmExportAvailable).toBe(true) + auth.beginSession() + expect(store.getState().rdmExportAvailable).toBe(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) + }) })