Add document render workspace with profiles, preview, review and downloads

Why: unified renderer Task 11 needs a reader/delivery UI over the RDM
document tree: four-level rule selection with controlled-document
no-fallback explanation, preview with node-linked diagnostics, a
structure canvas and a linear reader sharing one selection and filter,
review annotations/revisions, and per-delivery downloads.

Evidence: npx vitest run -> 35 files, 338 tests passed (45 new in
test/document-render-workspace.test.ts, design-guard green);
npm run build -> tsc -b + vite build ok; npx knip -> findings identical
to the pre-existing baseline (check-knip 43 <= 43); git diff --check
clean. e2e/document-renderer.playwright.ts authored, not executed.

Impact: new src/documentRenderPresentation.ts (pure helpers) and
src/components/DocumentRenderWorkspace.tsx, opened from a per-document
"阅读与交付" action in the audit materials tree; store gains an
rdmRender capability probe (fail-closed); audit export reuses the shared
base64 file saver; token-only styles appended to styles.css. Async
state is owned per hook with request guards plus identity epoch, and
the workspace remounts per identity/project/document scope.

Refs: unified renderer plan Task 11
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-10-15 23:44:39 +00:00
co-authored by Cursor
parent a81e94a4f2
commit 85428b4633
7 changed files with 2431 additions and 27 deletions
+179
View File
@@ -0,0 +1,179 @@
// 统一渲染 Task 11 运行验收:真实产品走通「资料与文档 → 阅读与交付」。
// 驱动方:Dev 仓 scripts/e2e.sh(同 rdm-audit.playwright.ts 的环境契约);
// PLAYWRIGHT_BASE_URL 未设置时整组跳过。种子数据差异(是否有诊断、是否已有审阅提交)
// 以「可理解状态」分支断言,不伪造服务端内容。
import { test, expect, type Page } from '@playwright/test'
const OWNER = process.env.E2E_OWNER || 'e2e-auditor'
const PASSWORD = process.env.E2E_PASSWORD || 'E2eAudit#2026'
const PASSWORD2 = process.env.E2E_PASSWORD2 || 'E2eAudit#2026b'
const MAIN = process.env.E2E_PROJECT_MAIN || '审计演示项目'
const ISOLATED = process.env.E2E_PROJECT_ISOLATED || '隔离检查项目'
test.skip(!process.env.PLAYWRIGHT_BASE_URL, 'set PLAYWRIGHT_BASE_URL (scripts/e2e.sh drives this suite)')
async function login(page: Page) {
await page.goto('/')
await expect(page.getByPlaceholder('由管理员分配')).toBeVisible()
await page.getByPlaceholder('由管理员分配').fill(OWNER)
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD)
await page.getByRole('button', { name: '登录', exact: true }).click()
const gated = await page.getByText('修改密码').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true).catch(() => false)
if (gated) {
const pwInputs = page.locator('.lg-card input[type="password"]')
await pwInputs.nth(0).fill(PASSWORD)
await pwInputs.nth(1).fill(PASSWORD2)
await pwInputs.nth(2).fill(PASSWORD2)
await page.getByRole('button', { name: '设置新密码并进入' }).click()
} else {
await page.getByPlaceholder('初始密码或常用密码').fill(PASSWORD2)
await page.getByRole('button', { name: '登录', exact: true }).click()
}
await expect(page.locator('.tsb-tool', { hasText: '追溯审计' })).toBeVisible({ timeout: 15_000 })
}
async function openDocument(page: Page) {
await page.locator('.tsb-tool', { hasText: '追溯审计' }).click()
await expect(page.locator('.rdm-audit-project')).toBeVisible()
await page.locator('.rdm-audit-project').selectOption({ label: MAIN })
await page.getByRole('tab', { name: '资料与文档' }).click()
const open = page.locator('.rdm-audit-tree-open').first()
await expect(open).toBeVisible({ timeout: 10_000 })
await open.click()
await expect(page.locator('.drw')).toBeVisible()
await expect(page.getByRole('tree', { name: '文档结构画布' })).toBeVisible({ timeout: 10_000 })
}
const panel = (page: Page, label: string) =>
page.locator('.drw-panel').filter({ has: page.locator('.ui-seclabel', { hasText: new RegExp(`^${label}$`) }) })
/** 失败文案必须可读:不得把原始 JSON 回执直出到主文案。 */
async function expectReadableFailures(page: Page) {
for (const msg of await page.locator('.drw-failure-msg').allTextContents()) {
expect(msg.trim().startsWith('{')).toBe(false)
expect(msg).not.toContain('"receipt"')
}
}
test.describe.serial('统一渲染 Task 11 文档阅读与交付', () => {
test('结构画布与阅读视图共用选中与过滤,切换不丢', async ({ page }) => {
await login(page)
await openDocument(page)
const nodes = page.locator('.drw-canvas-node')
await expect(nodes.first()).toBeVisible()
const count = await nodes.count()
test.skip(count < 2, '种子文档无章节,跳过双视图选中断言')
const target = nodes.nth(1)
const title = (await target.getAttribute('title')) ?? ''
await target.click()
await expect(target).toHaveAttribute('aria-selected', 'true')
await page.getByLabel('过滤节点').fill(title.slice(0, 2))
await page.getByRole('tab', { name: '阅读视图' }).click()
await expect(page.locator('.drw-reader')).toBeVisible()
await expect(page.locator('.drw-reader-title[aria-current="true"]')).toHaveText(title)
await expect(page.getByLabel('过滤节点')).toHaveValue(title.slice(0, 2))
await page.getByRole('tab', { name: '结构画布' }).click()
await expect(page.locator(`.drw-canvas-node[aria-selected="true"]`)).toHaveAttribute('title', title)
})
test('结构画布可键盘导航并可滚动平移', async ({ page }) => {
await login(page)
await openDocument(page)
const root = page.locator('.drw-canvas-node').first()
await root.focus()
await page.keyboard.press('ArrowDown')
const selected = page.locator('.drw-canvas-node[aria-selected="true"]')
if (await page.locator('.drw-canvas-node').count() > 1) {
await expect(selected).toBeFocused()
await page.keyboard.press('ArrowLeft')
await expect(root).toHaveAttribute('aria-selected', 'true')
}
const canvas = page.getByRole('tree', { name: '文档结构画布' })
const box = await canvas.boundingBox()
expect(box?.height ?? 0).toBeGreaterThan(100)
})
test('规则选择显示来源/版本/状态;不可用项给出原因;拒绝可读', async ({ page }) => {
await login(page)
await openDocument(page)
const rules = panel(page, '渲染规则')
await expect(rules.locator('.drw-profile').or(rules.locator('.drw-failure')).or(rules.getByText('没有可用的渲染规则')).first())
.toBeVisible({ timeout: 10_000 })
await expectReadableFailures(page)
for (const option of await rules.locator('.drw-profile').all()) {
await expect(option.locator('.drw-profile-source')).toHaveText(/文档规则|项目规则|模板规则|公司默认规则/)
await expect(option.locator('.drw-profile-ver')).toHaveText(/^v.+/)
if (await option.locator('input[disabled]').count()) await expect(option.locator('.drw-profile-reason')).not.toBeEmpty()
}
if (await rules.locator('.drw-notice').count()) await expect(rules.locator('.drw-notice')).toContainText('不会回退到公司默认规则')
})
test('生成预览:状态与诊断可见,点击诊断选中对应节点', async ({ page }) => {
await login(page)
await openDocument(page)
const generate = page.getByRole('button', { name: '生成预览', exact: true })
await expect(generate).toBeVisible({ timeout: 10_000 })
test.skip(await generate.isDisabled(), '无可预览规则')
await generate.click()
const preview = panel(page, '预览与提交')
await expect(preview.locator('.drw-receipt').or(preview.locator('.drw-failure')).first()).toBeVisible({ timeout: 30_000 })
await expectReadableFailures(page)
if (await preview.locator('.drw-failure').count()) {
await expect(preview.getByRole('button', { name: '重试' })).toBeVisible()
return
}
const located = preview.locator('button.drw-diag-node').first()
if (await located.count()) {
const title = await located.getAttribute('title')
await located.click()
await expect(page.locator('.drw-canvas-node[aria-selected="true"], .drw-reader-title[aria-current="true"]').first())
.toContainText(title ?? '')
}
await expect(page.locator('.drw-delivery').first()).toBeVisible({ timeout: 15_000 })
})
test('交付记录可下载,文件名截断且 title 保留全名', async ({ page }) => {
await login(page)
await openDocument(page)
const download = page.locator('.drw-download').first()
test.skip(!(await download.waitFor({ state: 'visible', timeout: 10_000 }).then(() => true).catch(() => false)), '暂无交付记录')
const file = page.waitForEvent('download')
await download.click()
const saved = await file
expect(saved.suggestedFilename()).toMatch(/\.(docx|md|json|txt)$/)
const name = panel(page, '交付记录').locator('.drw-filename').first()
await expect(name).toHaveAttribute('title', saved.suggestedFilename())
})
test('审阅:批注与修订分组,无法定位的单独成组', async ({ page }) => {
await login(page)
await openDocument(page)
const reviewPanel = panel(page, '审阅')
test.skip(!(await reviewPanel.count()), '服务未提供审阅能力')
await expect(reviewPanel.getByText('暂无审阅提交').or(reviewPanel.locator('.drw-annos')).or(reviewPanel.getByText('暂无批注')).first())
.toBeVisible({ timeout: 10_000 })
await expectReadableFailures(page)
await expect(reviewPanel).not.toContainText('<w:')
})
test('窄屏与深色主题下保持可用', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await login(page)
await page.evaluate(() => { document.documentElement.dataset.theme = 'dark' })
await openDocument(page)
const overflow = await page.locator('.drw').evaluate(el => el.scrollWidth - el.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
await expect(page.getByRole('button', { name: '← 返回文档树' })).toBeVisible()
await page.getByRole('button', { name: '← 返回文档树' }).click()
await expect(page.locator('.rdm-audit-tree-open').first()).toBeVisible()
})
test('切换项目后不残留上一项目的文档内容', async ({ page }) => {
await login(page)
await openDocument(page)
await page.locator('.rdm-audit-project').selectOption({ label: ISOLATED })
await expect(page.locator('.drw')).toHaveCount(0)
await page.getByRole('tab', { name: '资料与文档' }).click()
await expect(page.getByText('暂无文档')).toBeVisible({ timeout: 10_000 })
})
})