Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e1eb39035 | ||
|
|
eb2d6d32f9 | ||
|
|
d9bb780384 |
@@ -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$/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(/<script[^>]+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<void>(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')
|
||||
})
|
||||
@@ -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<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) { 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 <RdmErrorState raw={error} />
|
||||
if (!caps) return <div className="empty-note">加载中…</div>
|
||||
if (!caps || capsIdentity !== identity) return <div className="empty-note">加载中…</div>
|
||||
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
|
||||
|
||||
return (
|
||||
@@ -82,7 +92,7 @@ export function RdmAuditWorkspace() {
|
||||
))}
|
||||
</div>
|
||||
<div className="rdm-audit-body">
|
||||
{tab === 'summary' && <SummaryTab pid={pid} />}
|
||||
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
|
||||
{tab === 'materials' && <MaterialsTab pid={pid} />}
|
||||
{tab === 'trace' && <TraceTab pid={pid} />}
|
||||
{tab === 'timeline' && <TimelineTab pid={pid} />}
|
||||
@@ -93,7 +103,7 @@ export function RdmAuditWorkspace() {
|
||||
|
||||
// ---- 概览 ----
|
||||
|
||||
function SummaryTab({ pid }: { pid: string }) {
|
||||
function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
|
||||
const [data, setData] = useState<SummaryData | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
useEffect(() => {
|
||||
@@ -133,6 +143,131 @@ function SummaryTab({ pid }: { pid: string }) {
|
||||
{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 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<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
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-3
@@ -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<T>(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 {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+26
-1
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user