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 })
})
})
+896
View File
@@ -0,0 +1,896 @@
// 统一渲染 Task 11:文档阅读与交付工作区(rdm.inspect.* 读结构 + rdm.render.* 生成/审阅/下载)。
// 结构画布与阅读视图共用同一选中节点与同一过滤状态;每个异步状态只有一个所有者,
// 响应按请求守卫与身份 epoch 校验,过期即丢弃。令牌驱动(DESIGN.md):折叠只用 Collapsible。
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react'
import { Btn, Chip, Collapsible, SectionLabel } from '@ui'
import { api } from '../api'
import { store, useStore } from '../store'
import {
DELIVERY_MODE_LABELS, MIND_MAP, REVISION_KIND_LABELS,
annotatePayload, buildDocTree, controlledNotice, createRequestGuard, decodeBase64, defaultProfileKey,
describeRenderFailure, diagnosticCountsByNode, diagnosticsSummaryText, downloadOptions, formatRenderTime,
groupDiagnostics, hasFacts, latestSubmissionId, layoutMindMap, mergeDeliveries, moveSelection, nextOperation,
nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDiagnostics, normalizeDownload, normalizeProfiles,
normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, renderScope, revisePayload,
truncateMiddle, validateReviewText, visibleNodeIds, visibleOrder,
type DocFilter, type DocTree, type DocTreeItem, type DownloadKind, type NodeFacts, type OperationKind,
type RenderDelivery, type RenderDiagnostic, type RenderOperation, type RenderProfiles, type ReviewAnnotation,
type ReviewRevision,
} from '../documentRenderPresentation'
// ---- 文件保存(交付物与审计导出共用;类型取服务端 contentType,不在客户端重算校验) ----
export function saveBase64File(fileName: string, contentType: string, dataBase64: string) {
const blob = new Blob([decodeBase64(dataBase64)], { type: contentType || 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}
// ---- 可复用展示件(纯 props;测试直接 SSR) ----
export function RenderFailure({ error, id }: { error: unknown; id: string }) {
const f = describeRenderFailure(error)
return (
<div className="drw-failure" role="alert">
<div className="drw-failure-msg">{f.message}</div>
<Collapsible id={`drw-failure-${id}`} trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
<code className="rdm-audit-err-raw">{f.raw}</code>
</Collapsible>
</div>
)
}
function FileName({ name }: { name: string }) {
return <span className="drw-filename" title={name}>{truncateMiddle(name, 36)}</span>
}
export function ProfileOptionList({ profiles, selectedKey, onSelect }: {
profiles: RenderProfiles
selectedKey: string
onSelect: (key: string) => void
}) {
const views = profileOptionViews(profiles)
const notice = controlledNotice(profiles)
return (
<div className="drw-profiles">
{profiles.refusal && <RenderFailure error={profiles.refusal} id="profiles-refusal" />}
{notice && <div className="drw-notice">{notice}</div>}
{views.length === 0 && !profiles.refusal && <div className="empty-note">没有可用的渲染规则</div>}
<div className="drw-profile-list" role="radiogroup" aria-label="渲染规则">
{views.map(v => (
<label key={v.key} className={`drw-profile${selectedKey === v.key ? ' sel' : ''}${v.previewAllowed ? '' : ' off'}`}>
<input type="radio" name="drw-profile" value={v.key} checked={selectedKey === v.key}
disabled={!v.previewAllowed} onChange={() => onSelect(v.key)} />
<span className="drw-profile-main">
<span className="drw-profile-head">
<span className="drw-profile-source">{v.sourceLabel}</span>
<span className="drw-profile-id" title={v.option.profileId}>{v.option.profileId}</span>
<span className="drw-profile-ver">v{v.option.version}</span>
<Chip tone={v.option.state === 'published' ? 'ok' : v.option.state === 'blocked' ? 'danger' : 'warn'}>{v.stateLabel}</Chip>
{v.formalAllowed ? <Chip tone="info">可正式交付</Chip> : v.previewAllowed ? <Chip tone="plain">仅预览</Chip> : null}
</span>
{v.reason && <span className="drw-profile-reason">{v.reason}</span>}
</span>
</label>
))}
</div>
</div>
)
}
const DIAGNOSTIC_PAGE = 50
function DiagnosticGroupBody({ items, nodeTitle, onSelectNode }: {
items: RenderDiagnostic[]
nodeTitle: (id: string) => string | undefined
onSelectNode: (id: string) => void
}) {
const [shown, setShown] = useState(DIAGNOSTIC_PAGE)
return (
<ul className="drw-diag-list">
{items.slice(0, shown).map((d, i) => {
const title = d.nodeId ? nodeTitle(d.nodeId) : undefined
return (
<li key={`${d.code}-${i}`} className={`drw-diag sev-${d.severity}`}>
{d.nodeId && title ? (
<button type="button" className="drw-diag-node" onClick={() => onSelectNode(d.nodeId!)} title={title}>{title}</button>
) : <span className="drw-diag-node unlocated">未定位节点</span>}
<span className="drw-diag-msg">{d.message}</span>
{d.code && <span className="drw-diag-code">{d.code}</span>}
{d.suggestion && <span className="drw-diag-hint">建议:{d.suggestion}</span>}
</li>
)
})}
{items.length > shown && (
<li><button type="button" className="btn v-ghost drw-more" onClick={() => setShown(s => s + DIAGNOSTIC_PAGE)}>
显示更多(还有 {items.length - shown} 条)
</button></li>
)}
</ul>
)
}
export function DiagnosticsList({ diagnostics, nodeTitle, onSelectNode, idPrefix }: {
diagnostics: RenderDiagnostic[]
nodeTitle: (id: string) => string | undefined
onSelectNode: (id: string) => void
idPrefix: string
}) {
const groups = groupDiagnostics(diagnostics)
if (groups.length === 0) return <div className="empty-note">没有诊断问题</div>
return (
<div className="drw-diags">
{groups.map((g, i) => (
<Collapsible key={g.severity} id={`drw-diag-${idPrefix}-${g.severity}`} defaultOpen={i === 0}
triggerClassName="drw-group-trigger" trigger={<span className={`drw-group-label sev-${g.severity}`}>{g.label}({g.items.length})</span>}>
<DiagnosticGroupBody items={g.items} nodeTitle={nodeTitle} onSelectNode={onSelectNode} />
</Collapsible>
))}
</div>
)
}
function Byline({ name, at }: { name: string; at: string }) {
const time = formatRenderTime(at)
return (
<span className="drw-byline">
{name && <span className="drw-byline-name">{name}</span>}
{time && <span className="drw-byline-time">{time}</span>}
</span>
)
}
function AnnotationItem({ a, onReply }: { a: ReviewAnnotation; onReply?: (a: ReviewAnnotation) => void }) {
return (
<div className={`drw-anno${a.resolved ? ' resolved' : ''}`}>
<div className="drw-anno-head">
<Byline name={a.author.displayName} at={a.createdAt} />
{a.resolved && <Chip tone="ok">已解决</Chip>}
{onReply && <button type="button" className="drw-link" onClick={() => onReply(a)}>回复</button>}
</div>
<div className="drw-anno-text">{a.text}</div>
{a.replies.length > 0 && (
<div className="drw-anno-replies">
{a.replies.map(r => (
<div key={r.id} className="drw-anno-reply">
<Byline name={r.author.displayName} at={r.createdAt} />
<div className="drw-anno-text">{r.text}</div>
</div>
))}
</div>
)}
</div>
)
}
export function AnnotationGroups({ byNode, orphaned, nodeTitle, onSelectNode, onReply }: {
byNode: Map<string, ReviewAnnotation[]>
orphaned: ReviewAnnotation[]
nodeTitle: (id: string) => string | undefined
onSelectNode: (id: string) => void
onReply?: (a: ReviewAnnotation) => void
}) {
if (byNode.size === 0 && orphaned.length === 0) return <div className="empty-note">暂无批注</div>
return (
<div className="drw-annos">
{[...byNode].map(([nodeId, list]) => (
<div key={nodeId} className="drw-anno-group">
<button type="button" className="drw-anno-node" onClick={() => onSelectNode(nodeId)}>{nodeTitle(nodeId) ?? nodeId}</button>
{list.map(a => <AnnotationItem key={a.id} a={a} onReply={onReply} />)}
</div>
))}
{orphaned.length > 0 && (
<Collapsible id="drw-anno-orphaned" defaultOpen triggerClassName="drw-group-trigger"
trigger={<span className="drw-group-label">无法定位({orphaned.length})</span>}>
<div className="drw-anno-orphan-note">原位置已变更或不在当前文档中,未挂到任何节点</div>
{orphaned.map(a => <AnnotationItem key={a.id} a={a} />)}
</Collapsible>
)}
</div>
)
}
function RevisionItem({ r }: { r: ReviewRevision }) {
return (
<div className="drw-rev">
<div className="drw-anno-head">
<Chip tone="plain">{REVISION_KIND_LABELS[r.kind]}</Chip>
<Byline name={r.author.displayName} at={r.createdAt} />
{r.decision && <Chip tone={r.decision === 'accepted' || r.decision === 'accept' ? 'ok' : 'plain'}>{r.decision}</Chip>}
</div>
{r.before && <div className="drw-diff-del">{r.before}</div>}
{r.after && <div className="drw-diff-add">{r.after}</div>}
</div>
)
}
export function DownloadButtons({ delivery, busyKind, disabled = false, onDownload }: {
delivery: RenderDelivery
busyKind: DownloadKind | null
disabled?: boolean
onDownload: (kind: DownloadKind) => void
}) {
const options = downloadOptions(delivery)
if (options.length === 0) return <span className="drw-muted">暂无可下载文件</span>
return (
<div className="drw-downloads">
{options.map(o => (
<button key={o.kind} type="button" className="btn v-ghost drw-download" disabled={disabled || busyKind !== null}
title={`下载${o.label}`} onClick={() => onDownload(o.kind)}>
{busyKind === o.kind ? <span className="mk-sweep">下载中</span> : o.label}
</button>
))}
</div>
)
}
function NodeFactsView({ facts }: { facts: NodeFacts }) {
if (!hasFacts(facts)) return null
const rows: [string, ReactNode][] = []
if (facts.sources.length) rows.push(['来源', facts.sources.join('、')])
if (facts.confidence) rows.push(['置信度', facts.confidence])
if (facts.conflict) rows.push(['冲突', <span className="drw-conflict">{facts.conflict}</span>])
if (facts.author || facts.at) rows.push(['作者', <Byline name={facts.author ?? ''} at={facts.at ?? ''} />])
if (facts.impacts.length) rows.push(['影响', facts.impacts.join('、')])
if (facts.change?.impact) rows.push(['变更影响', facts.change.impact])
return (
<div className="drw-facts">
{rows.map(([k, v]) => (
<div key={k} className="drw-fact"><span className="drw-fact-k">{k}</span><span className="drw-fact-v">{v}</span></div>
))}
{(facts.change?.before || facts.change?.after) && (
<div className="drw-fact drw-fact-change">
<span className="drw-fact-k">变更</span>
<span className="drw-fact-v">
{facts.change.before && <div className="drw-diff-del">{facts.change.before}</div>}
{facts.change.after && <div className="drw-diff-add">{facts.change.after}</div>}
</span>
</div>
)}
</div>
)
}
// ---- 异步所有者 ----
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e))
/** 单一资源加载:requestKey 变化或 reload 发起新请求,旧请求的响应一律丢弃。 */
function useGuardedResource<T>(requestKey: string | null, identity: number, load: () => Promise<T>) {
const guard = useRef(createRequestGuard())
const loadRef = useRef(load)
loadRef.current = load
const [tick, setTick] = useState(0)
const [state, setState] = useState<{ key: string | null; data: T | null; error: string }>({ key: null, data: null, error: '' })
useEffect(() => {
const g = guard.current
if (requestKey === null) return
const ticket = g.begin(requestKey)
const live = () => g.current(ticket, requestKey) && identity === store.getIdentityEpoch()
setState(s => (s.key === requestKey ? { ...s, error: '' } : { key: requestKey, data: null, error: '' }))
loadRef.current().then(
data => { if (live()) setState({ key: requestKey, data, error: '' }) },
e => { if (live()) setState(s => ({ key: requestKey, data: s.key === requestKey ? s.data : null, error: errText(e) })) },
)
return () => g.cancel()
}, [requestKey, identity, tick])
const current = requestKey !== null && state.key === requestKey
return {
data: current ? state.data : null,
error: current ? state.error : '',
reload: () => setTick(t => t + 1),
}
}
interface OperationState { op: RenderOperation | null; status: 'idle' | 'running' | 'done' | 'failed'; error: string }
/** 写操作:每次动作新 operationId,重试复用;同一时刻只认最新一次的结果。 */
function useOperationRunner(identity: number) {
const guard = useRef(createRequestGuard())
const last = useRef<{ op: RenderOperation; exec: (operationId: string) => Promise<unknown>; onDone?: (r: unknown) => void } | null>(null)
const [state, setState] = useState<OperationState>({ op: null, status: 'idle', error: '' })
useEffect(() => { const g = guard.current; return () => g.cancel() }, [])
const start = (op: RenderOperation, exec: (operationId: string) => Promise<unknown>, onDone?: (r: unknown) => void) => {
last.current = { op, exec, onDone }
const scope = String(identity)
const ticket = guard.current.begin(scope)
const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch()
setState({ op, status: 'running', error: '' })
exec(op.operationId).then(
result => { if (!live()) return; setState({ op, status: 'done', error: '' }); onDone?.(result) },
e => { if (live()) setState({ op, status: 'failed', error: errText(e) }) },
)
}
return {
state,
run: (kind: OperationKind, exec: (operationId: string) => Promise<unknown>, onDone?: (r: unknown) => void) =>
start(nextOperation(kind, undefined, last.current?.op ?? null), exec, onDone),
retry: () => {
const l = last.current
if (l) start(nextOperation(l.op.kind, undefined, l.op, true), l.exec, l.onDone)
},
}
}
const TREE_PAGE_LIMIT = 20
async function loadDocSections(projectId: string, docId: string): Promise<DocTreeItem[]> {
const items: DocTreeItem[] = []
let cursor = ''
for (let page = 0; page < TREE_PAGE_LIMIT; page++) {
const shell = await api.rdmCommand<{ items?: DocTreeItem[]; nextCursor?: string; hasMore?: boolean }>(
'rdm.inspect.tree', { projectId, kind: 'docs', parent: docId, cursor })
items.push(...(shell.items ?? []).filter(i => i && typeof i.id === 'string' && typeof i.title === 'string'))
if (!shell.hasMore || !shell.nextCursor) break
cursor = shell.nextCursor
}
return items
}
/** 节点事实缓存:按节点 id 归档,只写回发起请求的那个节点;组件按作用域重挂载即整体失效。 */
function useNodeFacts(projectId: string, identity: number, tree: DocTree | null, nodeId: string | null) {
const [facts, setFacts] = useState<Record<string, NodeFacts | 'loading' | 'error'>>({})
const requested = useRef(new Set<string>())
const alive = useRef(true)
useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
const node = tree && nodeId ? tree.nodes[nodeId] : undefined
useEffect(() => {
if (!node || node.kind === 'doc' || requested.current.has(node.id)) return
const id = node.id
const live = () => alive.current && identity === store.getIdentityEpoch()
requested.current.add(id)
setFacts(f => ({ ...f, [id]: 'loading' }))
api.rdmCommand('rdm.inspect.object', { projectId, kind: node.kind, id })
.then(d => { if (live()) setFacts(f => ({ ...f, [id]: nodeFacts(d) })) })
.catch(() => {
if (!live()) return
requested.current.delete(id)
setFacts(f => ({ ...f, [id]: 'error' }))
})
}, [node, projectId, identity])
return facts
}
/** 交付记录:首屏 + 分页追加,同一守卫串行化;作用域变化随组件重挂载失效。 */
function useDeliveries(projectId: string, docId: string, identity: number, enabled: boolean) {
const guard = useRef(createRequestGuard())
const [state, setState] = useState<{ items: RenderDelivery[]; nextCursor?: string; error: string; loading: boolean }>({ items: [], error: '', loading: enabled })
const fetchPage = (cursor: string | undefined, append: boolean) => {
const scope = String(identity)
const ticket = guard.current.begin(scope)
const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch()
setState(s => ({ ...s, loading: true, error: '' }))
api.rdmCommand('rdm.render.deliveries', { projectId, docId, limit: 20, ...(cursor ? { cursor } : {}) })
.then(raw => {
if (!live()) return
const page = normalizeDeliveries(raw)
setState(s => ({ items: append ? mergeDeliveries(s.items, page.items) : page.items, nextCursor: page.nextCursor, error: '', loading: false }))
})
.catch(e => { if (live()) setState(s => ({ ...s, error: errText(e), loading: false })) })
}
useEffect(() => {
const g = guard.current
if (enabled) fetchPage(undefined, false)
return () => g.cancel()
}, [enabled])
return { ...state, reload: () => fetchPage(undefined, false), loadMore: () => state.nextCursor && fetchPage(state.nextCursor, true) }
}
function useDownloader(projectId: string, identity: number) {
const guard = useRef(createRequestGuard())
const [state, setState] = useState<{ busy: { deliveryId: string; kind: DownloadKind } | null; error: string; lastFile: string }>({ busy: null, error: '', lastFile: '' })
useEffect(() => { const g = guard.current; return () => g.cancel() }, [])
const download = (deliveryId: string, kind: DownloadKind) => {
const scope = String(identity)
const ticket = guard.current.begin(scope)
const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch()
setState(s => ({ ...s, busy: { deliveryId, kind }, error: '' }))
api.rdmCommand('rdm.render.download', { projectId, deliveryId, kind })
.then(raw => {
if (!live()) return
const p = normalizeDownload(raw)
saveBase64File(p.fileName, p.contentType, p.dataBase64)
setState({ busy: null, error: '', lastFile: p.fileName })
})
.catch(e => { if (live()) setState(s => ({ ...s, busy: null, error: errText(e) })) })
}
return { ...state, download }
}
// ---- 两种树视图(共用选中与过滤) ----
interface ViewProps {
tree: DocTree
order: string[]
visible: Set<string>
selectedId: string | null
onSelect: (id: string) => void
badges: (id: string) => ReactNode
facts: Record<string, NodeFacts | 'loading' | 'error'>
}
function useFocusSelected(root: React.RefObject<HTMLDivElement | null>, selectedId: string | null) {
const wantFocus = useRef(false)
useEffect(() => {
if (!selectedId || !root.current) return
const el = root.current.querySelector<HTMLElement>(`[data-node-id="${CSS.escape(selectedId)}"]`)
if (!el) return
el.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
if (wantFocus.current) { el.focus({ preventScroll: true }); wantFocus.current = false }
}, [root, selectedId])
return wantFocus
}
function StructureCanvas({ tree, order, visible, selectedId, onSelect, badges }: ViewProps) {
const ref = useRef<HTMLDivElement>(null)
const wantFocus = useFocusSelected(ref, selectedId)
const layout = useMemo(() => layoutMindMap(tree, visible), [tree, visible])
const pan = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
const [panning, setPanning] = useState(false)
const tabStop = selectedId && visible.has(selectedId) ? selectedId : tree.rootId
const onKey = (e: KeyboardEvent) => {
const next = moveSelection(tree, order, selectedId, e.key)
if (next === null) return
e.preventDefault()
wantFocus.current = true
onSelect(next)
}
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest('.drw-canvas-node') || !ref.current) return
pan.current = { x: e.clientX, y: e.clientY, left: ref.current.scrollLeft, top: ref.current.scrollTop }
ref.current.setPointerCapture?.(e.pointerId)
setPanning(true)
}
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!pan.current || !ref.current) return
ref.current.scrollLeft = pan.current.left - (e.clientX - pan.current.x)
ref.current.scrollTop = pan.current.top - (e.clientY - pan.current.y)
}
const endPan = () => { pan.current = null; setPanning(false) }
return (
<div ref={ref} className={`drw-canvas${panning ? ' is-panning' : ''}`} role="tree" aria-label="文档结构画布"
onKeyDown={onKey} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endPan} onPointerCancel={endPan}>
<div className="drw-canvas-stage" style={{ width: layout.width, height: layout.height }}>
<svg className="drw-canvas-edges" width={layout.width} height={layout.height} aria-hidden="true">
{layout.edges.map(e => <path key={e.to} d={e.d} className="drw-canvas-edge" />)}
</svg>
{layout.nodes.map(n => {
const node = tree.nodes[n.id]
return (
<button key={n.id} type="button" role="treeitem" data-node-id={n.id}
aria-selected={selectedId === n.id} aria-level={n.depth + 1}
tabIndex={tabStop === n.id ? 0 : -1}
className={`drw-canvas-node depth-${Math.min(n.depth, 3)}${selectedId === n.id ? ' sel' : ''}`}
style={{ left: n.x, top: n.y, width: MIND_MAP.nodeWidth, height: MIND_MAP.nodeHeight }}
title={node.title} onClick={() => onSelect(n.id)}>
<span className="drw-canvas-title">{node.title}</span>
{badges(n.id)}
</button>
)
})}
</div>
</div>
)
}
function ReaderView({ tree, order, visible, selectedId, onSelect, badges, facts }: ViewProps) {
const ref = useRef<HTMLDivElement>(null)
const wantFocus = useFocusSelected(ref, selectedId)
const onKey = (e: KeyboardEvent) => {
const next = moveSelection(tree, order, selectedId, e.key)
if (next === null) return
e.preventDefault()
wantFocus.current = true
onSelect(next)
}
return (
<div ref={ref} className="drw-reader" onKeyDown={onKey}>
{visibleOrder(tree, visible).map(id => {
const n = tree.nodes[id]
const level = Math.min(n.depth + 2, 5)
const Heading = `h${level}` as 'h2' | 'h3' | 'h4' | 'h5'
const f = facts[id]
return (
<section key={id} id={nodeAnchorId(id)} className={`drw-reader-node${selectedId === id ? ' sel' : ''}`}>
<Heading className="drw-reader-heading">
<button type="button" className="drw-reader-title" data-node-id={id} aria-current={selectedId === id ? 'true' : undefined}
tabIndex={selectedId === id || (!selectedId && id === tree.rootId) ? 0 : -1} onClick={() => onSelect(id)}>
{n.title}
</button>
<a className="drw-anchor" href={`#${nodeAnchorId(id)}`} aria-label={`${n.title} 的锚点`}></a>
{badges(id)}
{n.state && <Chip tone={n.state === 'baselined' ? 'ok' : 'plain'}>{n.state}</Chip>}
</Heading>
{f && typeof f === 'object' && <NodeFactsView facts={f} />}
</section>
)
})}
</div>
)
}
// ---- 审阅输入 ----
function ReviewComposer({ nodeTitle, replyTo, onCancelReply, canAnnotate, canRevise, busy, onAnnotate, onRevise }: {
nodeTitle: string | undefined
replyTo: ReviewAnnotation | null
onCancelReply: () => void
canAnnotate: boolean
canRevise: boolean
busy: boolean
onAnnotate: (text: string, replyTo?: string) => void
onRevise: (kind: 'insert' | 'delete' | 'replace', before?: string, after?: string) => void
}) {
const [mode, setMode] = useState<'comment' | 'revise'>(canAnnotate ? 'comment' : 'revise')
const [text, setText] = useState('')
const [kind, setKind] = useState<'insert' | 'delete' | 'replace'>('replace')
const [before, setBefore] = useState('')
const [after, setAfter] = useState('')
const [hint, setHint] = useState('')
if (!nodeTitle) return <div className="empty-note">先在文档树中选择一个章节,再添加批注或修订</div>
const submit = () => {
if (mode === 'comment') {
const bad = validateReviewText(text)
if (bad) { setHint(bad); return }
onAnnotate(text, replyTo?.id)
setText('')
} else {
const bad = (kind !== 'insert' ? validateReviewText(before) : undefined) ?? (kind !== 'delete' ? validateReviewText(after) : undefined)
if (bad) { setHint(bad); return }
onRevise(kind, kind !== 'insert' ? before : undefined, kind !== 'delete' ? after : undefined)
setBefore(''); setAfter('')
}
setHint('')
}
return (
<div className="drw-composer">
<div className="drw-composer-head">
<span className="drw-muted">目标:</span><span className="drw-composer-node" title={nodeTitle}>{nodeTitle}</span>
{canAnnotate && canRevise && (
<select className="ui-input drw-select" aria-label="审阅动作" value={mode} onChange={e => setMode(e.target.value as 'comment' | 'revise')}>
<option value="comment">批注</option>
<option value="revise">修订</option>
</select>
)}
</div>
{mode === 'comment' ? (
<>
{replyTo && (
<div className="drw-composer-reply">
回复 {replyTo.author.displayName || '批注'}
<button type="button" className="drw-link" onClick={onCancelReply}>取消回复</button>
</div>
)}
<textarea className="ui-input drw-textarea" aria-label="批注内容" value={text} onChange={e => setText(e.target.value)} placeholder="写下批注…" />
</>
) : (
<>
<select className="ui-input drw-select" aria-label="修订类型" value={kind} onChange={e => setKind(e.target.value as 'insert' | 'delete' | 'replace')}>
{(['replace', 'insert', 'delete'] as const).map(k => <option key={k} value={k}>{REVISION_KIND_LABELS[k]}</option>)}
</select>
{kind !== 'insert' && <textarea className="ui-input drw-textarea" aria-label="修订前" value={before} onChange={e => setBefore(e.target.value)} placeholder="原文…" />}
{kind !== 'delete' && <textarea className="ui-input drw-textarea" aria-label="修订后" value={after} onChange={e => setAfter(e.target.value)} placeholder="修改为…" />}
</>
)}
{hint && <div className="drw-inline-err">{hint}</div>}
<div className="drw-row">
<Btn variant="ghost" disabled={busy} onClick={submit}>{mode === 'comment' ? '添加批注' : '提交修订'}</Btn>
</div>
</div>
)
}
function OperationStatus({ state, onRetry, labels }: { state: OperationState; onRetry: () => void; labels: Record<string, string> }) {
if (state.status === 'idle' || !state.op) return null
const label = labels[state.op.kind] ?? '操作'
if (state.status === 'running') return <div className="drw-op"><span className="mk-sweep">{label}进行中</span></div>
if (state.status === 'failed') {
return (
<div className="drw-op">
<RenderFailure error={state.error} id={`op-${state.op.kind}`} />
<Btn variant="ghost" onClick={onRetry}>重试</Btn>
</div>
)
}
return null
}
// ---- 工作区 ----
const OP_LABELS: Record<string, string> = {
preview: '生成预览', submit: '提交审阅', 'review-export': '生成留痕稿', 'approved-export': '生成批准稿', annotate: '添加批注', revise: '提交修订',
}
interface WorkspaceProps { projectId: string; docId: string; docTitle: string; onBack: () => void }
export function DocumentRenderWorkspace(props: WorkspaceProps) {
const identity = useStore(() => store.getIdentityEpoch())
return <ScopedWorkspace key={renderScope(identity, props.projectId, props.docId)} identity={identity} {...props} />
}
function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: WorkspaceProps & { identity: number }) {
const caps = useStore(s => s.rdmRender)
const scope = renderScope(identity, projectId, docId)
const [view, setView] = useState<'canvas' | 'reader'>('canvas')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [filter, setFilter] = useState<DocFilter>({ text: '', mode: 'all' })
const [profileKeyChoice, setProfileKeyChoice] = useState<string | null>(null)
const [submissionChoice, setSubmissionChoice] = useState<string | null>(null)
const [replyTo, setReplyTo] = useState<ReviewAnnotation | null>(null)
const [receipt, setReceipt] = useState<Record<string, any> | null>(null)
const sections = useGuardedResource(`${scope}:tree`, identity, () => loadDocSections(projectId, docId))
const profiles = useGuardedResource(caps.ready ? `${scope}:profiles` : null, identity,
() => api.rdmCommand('rdm.render.profiles', { projectId, docId }).then(normalizeProfiles))
const deliveries = useDeliveries(projectId, docId, identity, caps.ready)
const renderOp = useOperationRunner(identity)
const reviewOp = useOperationRunner(identity)
const downloader = useDownloader(projectId, identity)
const tree = useMemo(() => (sections.data ? buildDocTree({ id: docId, title: docTitle }, sections.data) : null), [sections.data, docId, docTitle])
const facts = useNodeFacts(projectId, identity, tree, selectedId)
const submissionId = submissionChoice ?? latestSubmissionId(deliveries.items) ?? null
const review = useGuardedResource(caps.review && submissionId ? `${scope}:review:${submissionId}` : null, identity,
() => api.rdmCommand('rdm.render.review.view', { projectId, submissionId }).then(normalizeReviewView))
const profileData = profiles.data
const views = useMemo(() => (profileData ? profileOptionViews(profileData) : []), [profileData])
const selectedProfileKey = profileKeyChoice ?? (profileData ? defaultProfileKey(profileData) : '')
const selectedProfile = views.find(v => v.key === selectedProfileKey)
const diagnostics = useMemo(() => normalizeDiagnostics(receipt?.diagnostics), [receipt])
const diagCounts = useMemo(() => diagnosticCountsByNode(diagnostics), [diagnostics])
const knownIds = useMemo(() => new Set(tree ? tree.order : []), [tree])
const annotations = useMemo(() => partitionAnnotations(review.data?.annotations ?? [], knownIds), [review.data, knownIds])
const revisions = useMemo(() => partitionRevisions(review.data?.revisions ?? [], knownIds), [review.data, knownIds])
const marks = useMemo(() => ({
issues: new Set(Object.keys(diagCounts)),
review: new Set([...annotations.byNode.keys(), ...revisions.byNode.keys()]),
}), [diagCounts, annotations, revisions])
const visible = useMemo(() => (tree ? visibleNodeIds(tree, filter, marks) : new Set<string>()), [tree, filter, marks])
const order = useMemo(() => (tree ? visibleOrder(tree, visible) : []), [tree, visible])
const nodeTitle = (id: string) => tree?.nodes[id]?.title
const selectNode = (id: string) => {
if (tree && !visible.has(id)) setFilter({ text: '', mode: 'all' })
setSelectedId(id)
}
const badges = (id: string) => {
const d = diagCounts[id]
const notes = (annotations.byNode.get(id)?.length ?? 0) + (revisions.byNode.get(id)?.length ?? 0)
if (!d && !notes) return null
return (
<span className="drw-badges">
{d?.errors ? <span className="drw-badge sev-error" title={`${d.errors} 个错误`}>{d.errors}</span> : null}
{d?.warnings ? <span className="drw-badge sev-warning" title={`${d.warnings} 个警告`}>{d.warnings}</span> : null}
{notes ? <span className="drw-badge review" title={`${notes} 条审阅意见`}>{notes}</span> : null}
</span>
)
}
const profileParams = () => selectedProfile && { profileId: selectedProfile.option.profileId, profileVersion: selectedProfile.option.version }
const generatePreview = () => {
const p = profileParams()
if (!p) return
renderOp.run('preview', operationId => api.rdmCommand('rdm.render.preview', { projectId, docId, operationId, ...p }), r => {
setReceipt(r && typeof r === 'object' ? r as Record<string, any> : {})
deliveries.reload()
})
}
const submitReview = () => {
const p = profileParams()
if (!p || !selectedProfile?.formalAllowed) return
renderOp.run('submit', operationId => api.rdmCommand('rdm.render.submit', { projectId, docId, operationId, ...p }), r => {
const id = (r as { id?: unknown })?.id
if (typeof id === 'string' && id) setSubmissionChoice(id)
deliveries.reload()
})
}
const exportFor = (kind: 'review-export' | 'approved-export') => {
if (!submissionId) return
const command = kind === 'review-export' ? 'rdm.render.review.export' : 'rdm.render.approved.export'
renderOp.run(kind, operationId => api.rdmCommand(command, { projectId, docId, operationId, submissionId }), () => deliveries.reload())
}
const selectedNode = tree && selectedId ? tree.nodes[selectedId] : undefined
const reviewTarget = selectedNode && selectedNode.kind !== 'doc' ? selectedNode : undefined
const annotate = (text: string, reply?: string) => {
if (!submissionId || !reviewTarget) return
reviewOp.run('annotate', operationId => api.rdmCommand('rdm.render.review.annotate',
annotatePayload({ projectId, submissionId, nodeId: reviewTarget.id, text, operationId, replyTo: reply })), () => { setReplyTo(null); review.reload() })
}
const revise = (kind: 'insert' | 'delete' | 'replace', before?: string, after?: string) => {
if (!submissionId || !reviewTarget) return
reviewOp.run('revise', operationId => api.rdmCommand('rdm.render.review.revise',
revisePayload({ projectId, submissionId, nodeId: reviewTarget.id, kind, before, after, operationId })), () => review.reload())
}
const running = renderOp.state.status === 'running'
const selectedFacts = selectedId ? facts[selectedId] : undefined
return (
<div className="drw">
<div className="drw-header">
<button type="button" className="btn v-ghost" onClick={onBack}>← 返回文档树</button>
<strong className="drw-title" title={docTitle}>{docTitle}</strong>
<div className="tabs drw-view-tabs" role="tablist" aria-label="文档视图">
<button type="button" role="tab" aria-selected={view === 'canvas'} className={`tab${view === 'canvas' ? ' active' : ''}`} onClick={() => setView('canvas')}>结构画布</button>
<button type="button" role="tab" aria-selected={view === 'reader'} className={`tab${view === 'reader' ? ' active' : ''}`} onClick={() => setView('reader')}>阅读视图</button>
</div>
</div>
<div className="drw-body">
<div className="drw-main">
<div className="drw-filters">
<input className="ui-input drw-search" type="search" aria-label="过滤节点" placeholder="过滤节点…"
value={filter.text} onChange={e => setFilter(f => ({ ...f, text: e.target.value }))} />
<select className="ui-input drw-select" aria-label="节点范围" value={filter.mode}
onChange={e => setFilter(f => ({ ...f, mode: e.target.value as DocFilter['mode'] }))}>
<option value="all">全部节点</option>
<option value="issues">有诊断问题</option>
<option value="review">有审阅意见</option>
</select>
</div>
{sections.error ? <RenderFailure error={sections.error} id="tree" /> : !tree ? <div className="empty-note">加载中…</div> : (
<>
{tree.order.length === 1 && <div className="empty-note">该文档暂无章节</div>}
{order.length <= 1 && tree.order.length > 1 && <div className="empty-note">没有符合过滤条件的节点</div>}
{view === 'canvas'
? <StructureCanvas tree={tree} order={order} visible={visible} selectedId={selectedId} onSelect={selectNode} badges={badges} facts={facts} />
: <ReaderView tree={tree} order={order} visible={visible} selectedId={selectedId} onSelect={selectNode} badges={badges} facts={facts} />}
{selectedNode && (
<div className="drw-selected">
<SectionLabel>当前节点</SectionLabel>
<div className="drw-selected-title" title={selectedNode.title}>{selectedNode.title}</div>
{selectedFacts === 'loading' && <div className="empty-note">加载中…</div>}
{selectedFacts === 'error' && <div className="drw-muted">节点详情暂不可用</div>}
{selectedFacts && typeof selectedFacts === 'object' && (hasFacts(selectedFacts)
? <NodeFactsView facts={selectedFacts} />
: <div className="drw-muted">服务端未提供该节点的来源或变更信息</div>)}
</div>
)}
</>
)}
</div>
<aside className="drw-side">
{!caps.ready ? (
<div className="drw-muted">当前服务未提供文档渲染能力,仅可阅读文档结构</div>
) : (
<>
<section className="drw-panel">
<SectionLabel>渲染规则</SectionLabel>
{profiles.error ? <RenderFailure error={profiles.error} id="profiles" />
: !profileData ? <div className="empty-note">加载中…</div>
: <ProfileOptionList profiles={profileData} selectedKey={selectedProfileKey} onSelect={setProfileKeyChoice} />}
</section>
<section className="drw-panel">
<SectionLabel>预览与提交</SectionLabel>
<div className="drw-row">
<Btn variant="primary" disabled={running || !selectedProfile?.previewAllowed} onClick={generatePreview}>生成预览</Btn>
{caps.submit && (
<Btn variant="ghost" disabled={running || !selectedProfile?.formalAllowed} onClick={submitReview}
title={selectedProfile && !selectedProfile.formalAllowed ? selectedProfile.reason : undefined}>提交审阅</Btn>
)}
</div>
{selectedProfile && !selectedProfile.formalAllowed && selectedProfile.reason && <div className="drw-muted">{selectedProfile.reason}</div>}
<OperationStatus state={renderOp.state} onRetry={renderOp.retry} labels={OP_LABELS} />
{renderOp.state.status === 'done' && renderOp.state.op && renderOp.state.op.kind !== 'preview' && (
<div className="drw-ok">{OP_LABELS[renderOp.state.op.kind]}已完成</div>
)}
{receipt && (
<div className="drw-receipt">
<div className="drw-row">
<Chip tone={/fail|error|reject/i.test(String(receipt.state ?? '')) ? 'danger' : 'ok'}>{String(receipt.state || '已生成')}</Chip>
{typeof receipt.fileName === 'string' && receipt.fileName && <FileName name={receipt.fileName} />}
</div>
<div className="drw-muted">{diagnosticsSummaryText({ errors: diagnostics.filter(d => d.severity === 'error').length, warnings: diagnostics.filter(d => d.severity === 'warning').length })}</div>
<DiagnosticsList diagnostics={diagnostics} nodeTitle={nodeTitle} onSelectNode={selectNode} idPrefix={String(receipt.deliveryId ?? 'preview')} />
</div>
)}
</section>
{caps.review && (
<section className="drw-panel">
<SectionLabel>审阅</SectionLabel>
{!submissionId ? <div className="empty-note">暂无审阅提交</div> : (
<>
<div className="drw-row">
<span className="drw-muted">提交</span><span className="drw-mono" title={submissionId}>{truncateMiddle(submissionId, 24)}</span>
{caps.reviewExport && <Btn variant="ghost" disabled={running} onClick={() => exportFor('review-export')}>生成留痕稿</Btn>}
{caps.approvedExport && <Btn variant="ghost" disabled={running} onClick={() => exportFor('approved-export')}>生成批准稿</Btn>}
</div>
{review.error ? <RenderFailure error={review.error} id="review" /> : !review.data ? <div className="empty-note">加载中…</div> : (
<>
<AnnotationGroups byNode={annotations.byNode} orphaned={annotations.orphaned} nodeTitle={nodeTitle} onSelectNode={selectNode}
onReply={caps.annotate ? a => { setReplyTo(a); selectNode(a.nodeId) } : undefined} />
{(revisions.byNode.size > 0 || revisions.orphaned.length > 0) && (
<Collapsible id="drw-revisions" defaultOpen triggerClassName="drw-group-trigger"
trigger={<span className="drw-group-label">修订({review.data.revisions.length})</span>}>
{[...revisions.byNode].map(([nodeId, list]) => (
<div key={nodeId} className="drw-anno-group">
<button type="button" className="drw-anno-node" onClick={() => selectNode(nodeId)}>{nodeTitle(nodeId) ?? nodeId}</button>
{list.map(r => <RevisionItem key={r.id} r={r} />)}
</div>
))}
{revisions.orphaned.length > 0 && (
<div className="drw-anno-group">
<span className="drw-group-label">无法定位({revisions.orphaned.length})</span>
{revisions.orphaned.map(r => <RevisionItem key={r.id} r={r} />)}
</div>
)}
</Collapsible>
)}
{review.data.decisions.length > 0 && (
<div className="drw-decisions">
{review.data.decisions.map((d, i) => (
<div key={i} className="drw-decision">
<Byline name={d.actor.displayName} at={d.at} />
{d.duty && <Chip tone="plain">{d.duty}</Chip>}
<Chip tone={/approve/i.test(d.decision) ? 'ok' : /reject/i.test(d.decision) ? 'danger' : 'plain'}>{d.decision}</Chip>
{d.reason && <span className="drw-muted">{d.reason}</span>}
</div>
))}
</div>
)}
</>
)}
{(caps.annotate || caps.revise) && (
<ReviewComposer nodeTitle={reviewTarget?.title} replyTo={replyTo} onCancelReply={() => setReplyTo(null)}
canAnnotate={caps.annotate} canRevise={caps.revise} busy={reviewOp.state.status === 'running'}
onAnnotate={annotate} onRevise={revise} />
)}
<OperationStatus state={reviewOp.state} onRetry={reviewOp.retry} labels={OP_LABELS} />
</>
)}
</section>
)}
<section className="drw-panel">
<div className="drw-row drw-row-between">
<SectionLabel>交付记录</SectionLabel>
<button type="button" className="drw-link" disabled={deliveries.loading} onClick={deliveries.reload}>刷新</button>
</div>
{deliveries.error && <RenderFailure error={deliveries.error} id="deliveries" />}
{downloader.error && <RenderFailure error={downloader.error} id="download" />}
{downloader.lastFile && <div className="drw-muted">已下载 <FileName name={downloader.lastFile} /></div>}
{!deliveries.error && deliveries.items.length === 0 && (deliveries.loading ? <div className="empty-note">加载中…</div> : <div className="empty-note">暂无交付记录</div>)}
<div className="drw-deliveries">
{deliveries.items.map(d => (
<div key={d.deliveryId} className={`drw-delivery${d.submissionId && d.submissionId === submissionId ? ' sel' : ''}`}>
<div className="drw-row">
<Chip tone={d.mode === 'approved' ? 'ok' : d.mode === 'review' ? 'info' : 'plain'}>{DELIVERY_MODE_LABELS[d.mode] ?? d.mode}</Chip>
<span className="drw-muted">{formatRenderTime(d.createdAt)}</span>
{d.requestedBy?.displayName && <span className="drw-muted">{d.requestedBy.displayName}</span>}
{d.status && <span className="drw-muted">{d.status}</span>}
</div>
<div className="drw-row">
{d.profileId && <span className="drw-mono" title={d.profileId}>{truncateMiddle(d.profileId, 24)} v{d.profileVersion}</span>}
<span className="drw-muted">{diagnosticsSummaryText(d.diagnosticsSummary)}</span>
</div>
<DownloadButtons delivery={d} busyKind={downloader.busy?.deliveryId === d.deliveryId ? downloader.busy.kind : null}
disabled={downloader.busy !== null} onDownload={kind => downloader.download(d.deliveryId, kind)} />
{caps.review && d.submissionId && d.submissionId !== submissionId && (
<button type="button" className="drw-link" onClick={() => setSubmissionChoice(d.submissionId!)}>查看审阅</button>
)}
</div>
))}
</div>
{deliveries.nextCursor && <Btn variant="ghost" disabled={deliveries.loading} onClick={() => { deliveries.loadMore() }}>加载更多</Btn>}
</section>
</>
)}
</aside>
</div>
</div>
)
}
+24 -25
View File
@@ -7,6 +7,7 @@ import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import { store, useStore } from '../store'
import { exportNeedsDraft } from '../workspaces'
import { DocumentRenderWorkspace, saveBase64File } from './DocumentRenderWorkspace'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
@@ -93,7 +94,7 @@ export function RdmAuditWorkspace() {
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
{tab === 'materials' && <MaterialsTab pid={pid} />}
{tab === 'materials' && <MaterialsTab key={pid} pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
</div>
@@ -169,19 +170,6 @@ interface ExportDownloaded {
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)
@@ -231,7 +219,7 @@ function ExportSection({ pid }: { pid: string }) {
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)
saveBase64File(dl.fileName, dl.mime, dl.dataB64)
} catch (e) {
if (current()) setErr(e instanceof Error ? e.message : String(e))
} finally {
@@ -276,20 +264,25 @@ function ExportSection({ pid }: { pid: string }) {
function MaterialsTab({ pid }: { pid: string }) {
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
// 打开文档时树列表保持挂载(只隐藏),返回后展开状态不丢。
const [openDoc, setOpenDoc] = useState<TreeItem | null>(null)
return (
<div className="rdm-audit-materials">
<div className="rdm-audit-materials-side">
{openDoc && (
<DocumentRenderWorkspace projectId={pid} docId={openDoc.id} docTitle={openDoc.title} onBack={() => setOpenDoc(null)} />
)}
<div className="rdm-audit-materials-side" hidden={!!openDoc}>
<div className="tabs">
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
</div>
<TreeList pid={pid} kind={kind} />
<TreeList pid={pid} kind={kind} onOpenDoc={setOpenDoc} />
</div>
</div>
)
}
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
function TreeList({ pid, kind, onOpenDoc }: { pid: string; kind: 'notes' | 'docs'; onOpenDoc: (doc: TreeItem) => void }) {
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
const [err, setErr] = useState('')
const [selected, setSelected] = useState<TreeItem | null>(null)
@@ -344,13 +337,19 @@ function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
<div className="rdm-audit-tree-list">
{shell.items.map(item => (
<div key={item.id}>
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
onClick={() => selectNode(item)}>
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{item.kind}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
</button>
<div className="rdm-audit-tree-row">
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
onClick={() => selectNode(item)}>
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{item.kind}</span>
<span className="rdm-audit-tree-title">{item.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
</button>
{item.kind === 'doc' && (
<button type="button" className="btn v-ghost rdm-audit-tree-open" onClick={() => onOpenDoc(item)}
aria-label={`阅读与交付:${item.title}`}>阅读与交付</button>
)}
</div>
{item.kind === 'doc' && children[item.id] && (
<div className="rdm-audit-tree-children">
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
+769
View File
@@ -0,0 +1,769 @@
// 统一渲染 Task 11 的纯展示逻辑:规则选择、预览诊断、文档树(结构画布 + 阅读视图)、
// 审阅批注/修订、交付下载。服务端字段一律视为可能被裁剪——缺失即不显示,不补造;
// 身份与时间只由服务端填写,载荷构造不含任何身份字段。
// ---- 能力门控(与 rdmAuditReady 同语义:清单不可用一律 fail-closed) ----
const CORE_COMMANDS = ['rdm.render.profiles', 'rdm.render.preview', 'rdm.render.deliveries', 'rdm.render.download'] as const
export const RDM_RENDER_COMMANDS = [
...CORE_COMMANDS,
'rdm.render.submit',
'rdm.render.review.view',
'rdm.render.review.annotate',
'rdm.render.review.revise',
'rdm.render.review.export',
'rdm.render.approved.export',
] as const
export interface RenderCapabilities {
ready: boolean
submit: boolean
review: boolean
annotate: boolean
revise: boolean
reviewExport: boolean
approvedExport: boolean
}
export const NO_RENDER_CAPABILITIES: RenderCapabilities = {
ready: false, submit: false, review: false, annotate: false, revise: false, reviewExport: false, approvedExport: false,
}
export function renderCapabilities(cmds: { id?: string; enabled?: boolean }[] | null | undefined): RenderCapabilities {
if (!Array.isArray(cmds)) return NO_RENDER_CAPABILITIES
const on = (id: string) => cmds.some(c => c?.id === id && c.enabled === true)
const ready = CORE_COMMANDS.every(on)
if (!ready) return NO_RENDER_CAPABILITIES
return {
ready,
submit: on('rdm.render.submit'),
review: on('rdm.render.review.view'),
annotate: on('rdm.render.review.annotate'),
revise: on('rdm.render.review.revise'),
reviewExport: on('rdm.render.review.export'),
approvedExport: on('rdm.render.approved.export'),
}
}
// ---- 通用:安全文本 ----
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v : undefined)
const obj = (v: unknown): Record<string, any> | undefined => (v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, any> : undefined)
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
const OOXML = /<\?xml|<\/?w:[a-zA-Z]/
const MARKUP = /<\?xml|<\/?[a-zA-Z][\w:-]*[\s>/]/
/** 服务端文本的显示形态:空串/非字符串为 undefined;原始 OOXML 不显示原文。 */
export function displayText(v: unknown, max = 4000): string | undefined {
const s = str(v)
if (s === undefined) return undefined
if (OOXML.test(s)) return '(原始文档格式内容不显示)'
return s.length > max ? `${s.slice(0, max)}…` : s
}
// ---- 失败文案(原始回执只进 Collapsible) ----
const GENERIC_FAILURE = '操作失败,请稍后重试'
interface RenderFailureView { message: string; raw: string }
function readable(s: string): string {
const t = s.trim()
if (!t || MARKUP.test(t)) return GENERIC_FAILURE
return t.length > 240 ? `${t.slice(0, 240)}…` : t
}
export function describeRenderFailure(err: unknown): RenderFailureView {
const raw = err instanceof Error ? err.message : String(err ?? '')
if (/^40[13]:/.test(raw)) return { message: '登录已失效,请重新登录后再操作', raw }
if (/unknown or disabled command|commands disabled/.test(raw)) return { message: '当前服务未提供文档渲染能力', raw }
const m = raw.match(/^\d{3}:\s*([\s\S]*)$/)
const body = (m ? m[1] : raw).trim()
if (body.startsWith('{') || body.startsWith('[')) {
try {
const parsed = obj(JSON.parse(body))
const text = str(parsed?.receipt?.error) ?? str(parsed?.receipt?.message) ?? str(parsed?.error) ?? str(parsed?.message)
return { message: text ? readable(text) : GENERIC_FAILURE, raw }
} catch {
return { message: GENERIC_FAILURE, raw }
}
}
return { message: readable(body), raw }
}
// ---- 规则选择(四级来源) ----
const PROFILE_SOURCES = ['document', 'project', 'template', 'organization-default'] as const
type ProfileSource = (typeof PROFILE_SOURCES)[number]
const PROFILE_SOURCE_LABELS: Record<ProfileSource, string> = {
document: '文档规则',
project: '项目规则',
template: '模板规则',
'organization-default': '公司默认规则',
}
const PROFILE_STATE_LABELS: Record<string, string> = {
published: '已发布', draft: '草稿', blocked: '已停用', retired: '已废弃', deprecated: '已废弃',
}
export interface RenderProfileOption {
source: ProfileSource
profileId: string
version: string
state: string
publicationAllowed: boolean
snapshotHash?: string
controlled: boolean
fallbackAllowed: boolean
blockedReason?: string
}
export interface RenderProfiles {
options: RenderProfileOption[]
selected?: { source: ProfileSource; profileId: string; version: string; hash: string }
refusal?: string
}
const isSource = (v: unknown): v is ProfileSource => PROFILE_SOURCES.includes(v as ProfileSource)
const versionOf = (v: unknown): string | undefined =>
typeof v === 'number' && Number.isFinite(v) ? String(v) : str(v)
export function normalizeProfiles(raw: unknown): RenderProfiles {
const r = obj(raw)
const options: RenderProfileOption[] = []
for (const item of arr(r?.options)) {
const o = obj(item)
const profileId = str(o?.profileId)
const version = versionOf(o?.version)
if (!o || !isSource(o.source) || !profileId || !version) continue
options.push({
source: o.source, profileId, version,
state: str(o.state) ?? '',
publicationAllowed: o.publicationAllowed === true,
controlled: o.controlled === true,
fallbackAllowed: o.fallbackAllowed === true,
...(str(o.snapshotHash) ? { snapshotHash: o.snapshotHash } : {}),
...(str(o.blockedReason) ? { blockedReason: o.blockedReason } : {}),
})
}
const out: RenderProfiles = { options }
const s = obj(r?.selected)
const sid = str(s?.profileId)
const sver = versionOf(s?.version)
if (s && isSource(s.source) && sid && sver) out.selected = { source: s.source, profileId: sid, version: sver, hash: str(s.hash) ?? '' }
if (str(r?.refusal)) out.refusal = r!.refusal
return out
}
export function profileKey(o: { source: string; profileId: string; version: string }): string {
return `${o.source}:${o.profileId}@${o.version}`
}
interface ProfileOptionView {
key: string
option: RenderProfileOption
sourceLabel: string
stateLabel: string
previewAllowed: boolean
formalAllowed: boolean
reason?: string
}
export function profileOptionViews(p: RenderProfiles): ProfileOptionView[] {
const docControlled = p.options.some(o => o.controlled)
return [...p.options]
.sort((a, b) => PROFILE_SOURCES.indexOf(a.source) - PROFILE_SOURCES.indexOf(b.source))
.map(option => {
const base = {
key: profileKey(option), option,
sourceLabel: PROFILE_SOURCE_LABELS[option.source],
stateLabel: PROFILE_STATE_LABELS[option.state] ?? (option.state || '未标明状态'),
}
if (option.state === 'blocked' || option.blockedReason) {
return { ...base, previewAllowed: false, formalAllowed: false, reason: displayText(option.blockedReason) ?? '该规则已停用,不能用于生成' }
}
if (option.source === 'organization-default' && (option.controlled || docControlled) && !option.fallbackAllowed) {
return { ...base, previewAllowed: false, formalAllowed: false, reason: '受控文档不回退到公司默认规则,请使用文档、项目或模板中明确指定的规则' }
}
if (option.state === 'draft') {
return { ...base, previewAllowed: true, formalAllowed: false, reason: '草稿规则只能用于预览,不能用于正式审阅或批准交付' }
}
if (option.state !== 'published' || !option.publicationAllowed) {
return { ...base, previewAllowed: true, formalAllowed: false, reason: '该规则未获准用于正式交付,只能用于预览' }
}
return { ...base, previewAllowed: true, formalAllowed: true }
})
}
export function controlledNotice(p: RenderProfiles): string | undefined {
if (!p.options.some(o => o.controlled)) return undefined
return '受控文档:只使用文档、项目或模板中明确指定的规则,不会回退到公司默认规则'
}
export function defaultProfileKey(p: RenderProfiles): string {
const views = profileOptionViews(p)
if (p.selected) {
const key = profileKey(p.selected)
if (views.some(v => v.key === key && v.previewAllowed)) return key
}
return views.find(v => v.previewAllowed)?.key ?? ''
}
// ---- 预览诊断 ----
type DiagnosticSeverity = 'error' | 'warning' | 'info'
export interface RenderDiagnostic {
code: string
severity: DiagnosticSeverity
message: string
nodeId?: string
suggestion?: string
}
function severityOf(v: unknown): DiagnosticSeverity {
const s = typeof v === 'string' ? v.toLowerCase() : ''
if (s === 'error' || s === 'fatal') return 'error'
if (s === 'warning' || s === 'warn') return 'warning'
return 'info'
}
export function normalizeDiagnostics(raw: unknown): RenderDiagnostic[] {
const out: RenderDiagnostic[] = []
for (const item of arr(raw)) {
const d = obj(item)
const message = displayText(d?.message, 1000)
if (!d || !message) continue
const suggestion = displayText(d.suggestion, 1000)
out.push({
code: str(d.code) ?? '', severity: severityOf(d.severity), message,
...(str(d.nodeId) ? { nodeId: d.nodeId } : {}),
...(suggestion ? { suggestion } : {}),
})
}
return out
}
interface DiagnosticGroup { severity: DiagnosticSeverity; label: string; items: RenderDiagnostic[] }
const SEVERITY_LABELS: Record<DiagnosticSeverity, string> = { error: '错误', warning: '警告', info: '提示' }
export function groupDiagnostics(list: RenderDiagnostic[]): DiagnosticGroup[] {
return (['error', 'warning', 'info'] as const)
.map(severity => ({ severity, label: SEVERITY_LABELS[severity], items: list.filter(d => d.severity === severity) }))
.filter(g => g.items.length > 0)
}
export function diagnosticsSummaryText(s: { errors: number; warnings: number }): string {
const parts = [s.errors > 0 ? `${s.errors} 个错误` : '', s.warnings > 0 ? `${s.warnings} 个警告` : ''].filter(Boolean)
return parts.length ? parts.join(' · ') : '无错误或警告'
}
export function diagnosticCountsByNode(list: RenderDiagnostic[]): Record<string, { errors: number; warnings: number }> {
const out: Record<string, { errors: number; warnings: number }> = {}
for (const d of list) {
if (!d.nodeId || d.severity === 'info') continue
const c = out[d.nodeId] ?? (out[d.nodeId] = { errors: 0, warnings: 0 })
if (d.severity === 'error') c.errors++
else c.warnings++
}
return out
}
// ---- 操作编号与请求守卫(异步状态唯一所有者 + 过期响应丢弃) ----
export type OperationKind = 'preview' | 'submit' | 'review-export' | 'approved-export' | 'annotate' | 'revise'
export interface RenderOperation { kind: OperationKind; operationId: string }
function mintOperationId(): string {
const c = globalThis.crypto
if (c && typeof c.randomUUID === 'function') return `op_${c.randomUUID()}`
return `op_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`
}
/** 每次用户动作铸新编号;仅「重试同一动作」复用上次编号(服务端据此幂等)。 */
export function nextOperation(kind: OperationKind, mint: () => string = mintOperationId, last: RenderOperation | null = null, retry = false): RenderOperation {
if (retry && last && last.kind === kind) return last
return { kind, operationId: mint() }
}
interface RequestTicket { seq: number; scope: string }
interface RequestGuard {
begin(scope: string): RequestTicket
current(ticket: RequestTicket, scope: string): boolean
cancel(): void
}
export function createRequestGuard(): RequestGuard {
let seq = 0
return {
begin: scope => ({ seq: ++seq, scope }),
current: (ticket, scope) => ticket.seq === seq && ticket.scope === scope,
cancel: () => { seq++ },
}
}
export function renderScope(identity: number, projectId: string, docId: string): string {
return JSON.stringify([identity, projectId, docId])
}
// ---- 文档树 ----
export interface DocTreeItem {
kind: string
id: string
title: string
state?: string
level?: number
depth?: number
parentId?: string
parent?: string
}
export interface DocNode {
id: string
kind: string
title: string
state?: string
depth: number
parentId: string | null
children: string[]
}
export interface DocTree { rootId: string; nodes: Record<string, DocNode>; order: string[] }
function hintedDepth(item: DocTreeItem): number {
const explicit = typeof item.level === 'number' ? item.level : typeof item.depth === 'number' ? item.depth : 0
if (explicit >= 1) return Math.floor(explicit)
const m = item.title.match(/^\s*(\d+(?:\.\d+)*)(?:[\s.、]|$)/)
return m ? m[1].split('.').length : 1
}
export function buildDocTree(doc: { id: string; title: string }, items: DocTreeItem[]): DocTree {
const root: DocNode = { id: doc.id, kind: 'doc', title: doc.title, depth: 0, parentId: null, children: [] }
const nodes: Record<string, DocNode> = { [doc.id]: root }
const order = [doc.id]
const stack: { id: string; hint: number }[] = [{ id: doc.id, hint: 0 }]
for (const item of items) {
if (!item?.id || nodes[item.id]) continue
const explicitParent = item.parentId ?? item.parent
const hint = hintedDepth(item)
let parent: DocNode
if (explicitParent && nodes[explicitParent]) {
parent = nodes[explicitParent]
while (stack.length > 1 && stack[stack.length - 1].id !== parent.id) stack.pop()
} else {
while (stack.length > 1 && stack[stack.length - 1].hint >= hint) stack.pop()
parent = nodes[stack[stack.length - 1].id]
}
const node: DocNode = {
id: item.id, kind: item.kind, title: item.title, depth: parent.depth + 1, parentId: parent.id, children: [],
...(item.state ? { state: item.state } : {}),
}
nodes[item.id] = node
parent.children.push(item.id)
order.push(item.id)
stack.push({ id: item.id, hint: Math.max(hint, stack[stack.length - 1].hint + 1) })
}
return { rootId: doc.id, nodes, order }
}
export interface DocFilter { text: string; mode: 'all' | 'issues' | 'review' }
interface NodeMarks { issues: Set<string>; review: Set<string> }
export function visibleNodeIds(tree: DocTree, filter: DocFilter, marks: NodeMarks): Set<string> {
const text = filter.text.trim().toLowerCase()
const visible = new Set<string>([tree.rootId])
for (const id of tree.order) {
const n = tree.nodes[id]
if (text && !n.title.toLowerCase().includes(text)) continue
if (filter.mode === 'issues' && !marks.issues.has(id)) continue
if (filter.mode === 'review' && !marks.review.has(id)) continue
for (let cur: DocNode | undefined = n; cur && !visible.has(cur.id); cur = cur.parentId ? tree.nodes[cur.parentId] : undefined) {
visible.add(cur.id)
}
}
return visible
}
export function visibleOrder(tree: DocTree, visible: Set<string>): string[] {
return tree.order.filter(id => visible.has(id))
}
export function nodeAnchorId(nodeId: string): string {
return `drw-node-${nodeId.replace(/[^A-Za-z0-9_-]/g, '_')}`
}
// ---- 结构画布(思维导图式水平树) ----
export const MIND_MAP = { colWidth: 220, rowHeight: 44, nodeWidth: 180, nodeHeight: 32, pad: 16 } as const
interface MindMapLayout {
nodes: { id: string; x: number; y: number; depth: number }[]
edges: { from: string; to: string; d: string }[]
width: number
height: number
}
export function layoutMindMap(tree: DocTree, visible: Set<string>): MindMapLayout {
const { colWidth, rowHeight, nodeWidth, nodeHeight, pad } = MIND_MAP
const pos = new Map<string, { x: number; y: number; depth: number }>()
let leaf = 0
let maxDepth = 0
const place = (id: string): number => {
const n = tree.nodes[id]
maxDepth = Math.max(maxDepth, n.depth)
const kids = n.children.filter(c => visible.has(c))
let y: number
if (kids.length === 0) y = pad + leaf++ * rowHeight
else {
const ys = kids.map(place)
y = (ys[0] + ys[ys.length - 1]) / 2
}
pos.set(id, { x: pad + n.depth * colWidth, y, depth: n.depth })
return y
}
place(tree.rootId)
const nodes = visibleOrder(tree, visible).filter(id => pos.has(id)).map(id => ({ id, ...pos.get(id)! }))
const edges: MindMapLayout['edges'] = []
for (const n of nodes) {
const parentId = tree.nodes[n.id].parentId
const p = parentId ? pos.get(parentId) : undefined
if (!parentId || !p) continue
const x1 = p.x + nodeWidth
const y1 = p.y + nodeHeight / 2
const x2 = n.x
const y2 = n.y + nodeHeight / 2
const mx = (x1 + x2) / 2
edges.push({ from: parentId, to: n.id, d: `M${x1} ${y1} C${mx} ${y1} ${mx} ${y2} ${x2} ${y2}` })
}
return {
nodes, edges,
width: pad * 2 + maxDepth * colWidth + nodeWidth,
height: pad * 2 + Math.max(0, leaf - 1) * rowHeight + nodeHeight,
}
}
/** 键盘导航(两视图共用):上下按可见顺序,左到父,右到首个可见子,Home/End;未处理返回 null。 */
export function moveSelection(tree: DocTree, order: string[], current: string | null, key: string): string | null {
if (!['ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(key)) return null
if (order.length === 0) return current
if (!current || !order.includes(current)) return order[0]
const i = order.indexOf(current)
switch (key) {
case 'ArrowDown': return order[Math.min(order.length - 1, i + 1)]
case 'ArrowUp': return order[Math.max(0, i - 1)]
case 'Home': return order[0]
case 'End': return order[order.length - 1]
case 'ArrowLeft': {
const parent = tree.nodes[current]?.parentId
return parent && order.includes(parent) ? parent : current
}
default: return tree.nodes[current]?.children.find(c => order.includes(c)) ?? current
}
}
// ---- 节点事实(rdm.inspect.object 详情;缺失字段不显示) ----
export interface NodeFacts {
sources: string[]
impacts: string[]
confidence?: string
conflict?: string
author?: string
at?: string
change?: { before?: string; after?: string; impact?: string }
}
function percent(v: unknown): string | undefined {
if (typeof v !== 'number' || !Number.isFinite(v)) return undefined
return `${Math.round(v <= 1 ? v * 100 : v)}%`
}
function endpointLabel(e: Record<string, any> | undefined): string | undefined {
if (!e || e.private === true || (e.disclosure !== undefined && e.disclosure !== 'ok')) return undefined
const kind = str(e.kind)
const id = str(e.id)
return kind && id ? `${kind}:${id}` : undefined
}
export function nodeFacts(detail: unknown): NodeFacts {
const d = obj(detail)
const facts: NodeFacts = { sources: [], impacts: [] }
if (!d) return facts
const self = str(d.id)
for (const item of arr(d.relations)) {
const r = obj(item)
if (!r || !self) continue
const from = obj(r.from)
const to = obj(r.to)
if (to?.id === self) {
const label = endpointLabel(from)
const conf = percent(r.conf)
if (label) facts.sources.push(conf ? `${label}(置信 ${conf})` : label)
} else if (from?.id === self) {
const label = endpointLabel(to)
if (label) facts.impacts.push(label)
}
}
const confidence = percent(d.confidence)
if (confidence) facts.confidence = confidence
const conflict = d.conflict === true ? '存在冲突' : displayText(d.conflict, 500)
if (conflict) facts.conflict = conflict
const versions = arr(d.versions).map(obj).filter((v): v is Record<string, any> => !!v)
const latest = versions.reduce<Record<string, any> | undefined>((best, v) =>
(!best || (Number(v.seq) || 0) >= (Number(best.seq) || 0) ? v : best), undefined)
const authorSource = latest ?? obj(d.formal)
const author = str(authorSource?.actor)
const at = str(authorSource?.at)
if (author) facts.author = author
if (at) facts.at = at
const c = obj(d.change)
if (c) {
const change: NonNullable<NodeFacts['change']> = {}
const before = displayText(c.before)
const after = displayText(c.after)
const impact = displayText(c.impact, 1000)
if (before) change.before = before
if (after) change.after = after
if (impact) change.impact = impact
if (Object.keys(change).length) facts.change = change
}
return facts
}
export function hasFacts(f: NodeFacts): boolean {
return f.sources.length > 0 || f.impacts.length > 0 || !!(f.confidence || f.conflict || f.author || f.at || f.change)
}
// ---- 审阅:批注、修订、决定 ----
interface ReviewPerson { id: string; displayName: string }
export interface ReviewReply {
id: string
nodeId: string
author: ReviewPerson
createdAt: string
text: string
resolved: boolean
orphaned: boolean
}
export interface ReviewAnnotation extends ReviewReply { replies: ReviewReply[] }
type RevisionKind = 'insert' | 'delete' | 'replace'
export interface ReviewRevision {
id: string
nodeId: string
kind: RevisionKind
before?: string
after?: string
author: ReviewPerson
createdAt: string
decision?: string
}
interface ReviewDecision { actor: ReviewPerson; duty: string; decision: string; reason?: string; at: string }
interface ReviewView { annotations: ReviewAnnotation[]; revisions: ReviewRevision[]; decisions: ReviewDecision[] }
export const REVISION_KIND_LABELS: Record<RevisionKind, string> = { insert: '插入', delete: '删除', replace: '替换' }
function person(v: unknown): ReviewPerson {
const p = obj(v)
const id = str(p?.id) ?? ''
return { id, displayName: str(p?.displayName) ?? id }
}
function reply(v: unknown): ReviewReply | undefined {
const a = obj(v)
const id = str(a?.id)
const text = displayText(a?.text)
if (!a || !id || !text) return undefined
return {
id, text, nodeId: str(a.nodeId) ?? '', author: person(a.author), createdAt: str(a.createdAt) ?? '',
resolved: a.resolved === true, orphaned: a.orphaned === true,
}
}
export function normalizeReviewView(raw: unknown): ReviewView {
const r = obj(raw)
const annotations: ReviewAnnotation[] = []
for (const item of arr(r?.annotations)) {
const base = reply(item)
if (!base) continue
const replies = arr(obj(item)?.replies).map(reply).filter((x): x is ReviewReply => !!x)
annotations.push({ ...base, replies })
}
const revisions: ReviewRevision[] = []
for (const item of arr(r?.revisions)) {
const v = obj(item)
const id = str(v?.id)
if (!v || !id || !(v.kind in REVISION_KIND_LABELS)) continue
const before = displayText(v.before)
const after = displayText(v.after)
const decision = str(v.decision)
revisions.push({
id, kind: v.kind as RevisionKind, nodeId: str(v.nodeId) ?? '', author: person(v.author), createdAt: str(v.createdAt) ?? '',
...(before ? { before } : {}), ...(after ? { after } : {}), ...(decision ? { decision } : {}),
})
}
const decisions: ReviewDecision[] = []
for (const item of arr(r?.decisions)) {
const d = obj(item)
const decision = str(d?.decision)
if (!d || !decision) continue
const reason = displayText(d.reason, 1000)
decisions.push({ actor: person(d.actor), duty: str(d.duty) ?? '', decision, at: str(d.at) ?? '', ...(reason ? { reason } : {}) })
}
return { annotations, revisions, decisions }
}
function partitionByNode<T extends { nodeId: string; orphaned?: boolean }>(list: T[], known: Set<string>) {
const byNode = new Map<string, T[]>()
const orphaned: T[] = []
for (const item of list) {
if (item.orphaned || !item.nodeId || !known.has(item.nodeId)) { orphaned.push(item); continue }
const bucket = byNode.get(item.nodeId)
if (bucket) bucket.push(item)
else byNode.set(item.nodeId, [item])
}
return { byNode, orphaned }
}
/** 孤立批注或节点已不在当前文档树中的批注进入「无法定位」,绝不挂到其他节点。 */
export function partitionAnnotations(list: ReviewAnnotation[], known: Set<string>) {
return partitionByNode(list, known)
}
export function partitionRevisions(list: ReviewRevision[], known: Set<string>) {
return partitionByNode(list, known)
}
const REVIEW_TEXT_MAX = 4000
export function validateReviewText(text: string): string | undefined {
const t = text.trim()
if (!t) return '请输入内容'
if (t.length > REVIEW_TEXT_MAX) return `内容不能超过 ${REVIEW_TEXT_MAX} 字`
return undefined
}
interface ReviewTarget { projectId: string; submissionId: string; nodeId: string; operationId: string }
export function annotatePayload(input: ReviewTarget & { text: string; replyTo?: string }): Record<string, unknown> {
const { projectId, submissionId, nodeId, operationId } = input
return { projectId, submissionId, nodeId, text: input.text.trim(), operationId, ...(input.replyTo ? { replyTo: input.replyTo } : {}) }
}
export function revisePayload(input: ReviewTarget & { kind: RevisionKind; before?: string; after?: string }): Record<string, unknown> {
const { projectId, submissionId, nodeId, kind, operationId } = input
return {
projectId, submissionId, nodeId, kind,
...(input.before !== undefined ? { before: input.before } : {}),
...(input.after !== undefined ? { after: input.after } : {}),
operationId,
}
}
// ---- 交付记录与下载 ----
export interface RenderDelivery {
deliveryId: string
mode: string
submissionId?: string
profileId: string
profileVersion: string
createdAt: string
requestedBy?: ReviewPerson
status: string
availableKinds: string[]
diagnosticsSummary: { errors: number; warnings: number }
}
export function normalizeDeliveries(raw: unknown): { items: RenderDelivery[]; nextCursor?: string } {
const r = obj(raw)
const items: RenderDelivery[] = []
for (const item of arr(r?.items)) {
const d = obj(item)
const deliveryId = str(d?.deliveryId)
if (!d || !deliveryId) continue
const summary = obj(d.diagnosticsSummary)
items.push({
deliveryId,
mode: str(d.mode) ?? 'preview',
profileId: str(d.profileId) ?? '',
profileVersion: versionOf(d.profileVersion) ?? '',
createdAt: str(d.createdAt) ?? '',
status: str(d.status) ?? '',
availableKinds: arr(d.availableKinds).filter((k): k is string => typeof k === 'string'),
diagnosticsSummary: { errors: Number(summary?.errors) || 0, warnings: Number(summary?.warnings) || 0 },
...(str(d.submissionId) ? { submissionId: d.submissionId } : {}),
...(obj(d.requestedBy) ? { requestedBy: person(d.requestedBy) } : {}),
})
}
return { items, ...(str(r?.nextCursor) ? { nextCursor: r!.nextCursor } : {}) }
}
export function mergeDeliveries(prev: RenderDelivery[], next: RenderDelivery[]): RenderDelivery[] {
const out = [...prev]
const seen = new Set(prev.map(d => d.deliveryId))
for (const d of next) {
if (seen.has(d.deliveryId)) continue
seen.add(d.deliveryId)
out.push(d)
}
return out
}
export function latestSubmissionId(list: RenderDelivery[]): string | undefined {
return [...list]
.filter(d => d.submissionId)
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0))[0]?.submissionId
}
export const DELIVERY_MODE_LABELS: Record<string, string> = { preview: '预览', review: '审阅', approved: '批准' }
const DOWNLOAD_ORDER = ['markdown', 'docx', 'review-docx', 'manifest', 'diagnostics'] as const
export type DownloadKind = (typeof DOWNLOAD_ORDER)[number]
export function downloadOptions(d: RenderDelivery): { kind: DownloadKind; label: string }[] {
const docxLabel = d.mode === 'approved' ? '批准稿 DOCX' : d.mode === 'review' ? '审阅稿 DOCX' : '预览 DOCX'
const labels: Record<DownloadKind, string> = {
markdown: 'Markdown', docx: docxLabel, 'review-docx': '审阅留痕 DOCX', manifest: '清单(manifest)', diagnostics: '诊断报告',
}
return DOWNLOAD_ORDER.filter(k => d.availableKinds.includes(k)).map(kind => ({ kind, label: labels[kind] }))
}
interface RenderDownloadPayload { fileName: string; contentType: string; dataBase64: string; sha256: string }
/** 只核对字段存在;不在客户端重算校验和(服务端已在下载时重新鉴权与记录)。 */
export function normalizeDownload(raw: unknown): RenderDownloadPayload {
const r = obj(raw)
const fileName = str(r?.fileName) ?? str(r?.filename)
const dataBase64 = str(r?.dataBase64) ?? str(r?.data) ?? str(r?.dataB64)
if (!fileName || !dataBase64) throw new Error('下载内容不完整')
return { fileName, contentType: str(r?.contentType) ?? str(r?.mime) ?? 'application/octet-stream', dataBase64, sha256: str(r?.sha256) ?? '' }
}
export function decodeBase64(b64: string): Uint8Array<ArrayBuffer> {
const bin = atob(b64)
const bytes = new Uint8Array(new ArrayBuffer(bin.length))
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
/** 中间截断并保留扩展名;完整名放在 title 里。 */
export function truncateMiddle(name: string, max = 40): string {
if (name.length <= max) return name
const ext = name.match(/\.[A-Za-z0-9]{1,8}$/)?.[0] ?? ''
const tail = ext.length + Math.floor((max - 1 - ext.length) / 3)
const head = max - 1 - tail
return `${name.slice(0, head)}…${name.slice(name.length - tail)}`
}
export function formatRenderTime(iso: string): string {
const t = Date.parse(iso)
return Number.isNaN(t) ? '' : new Date(t).toLocaleString('zh-CN', { hour12: false })
}
+7 -2
View File
@@ -13,6 +13,7 @@ import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import { mergeSurfacesByRevision, surfaceKey } from './surfaceKey'
import { rdmAuditReady, rdmExportReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDutyReady, rdmPermMatrixReady } from './rdmManagement'
import { NO_RENDER_CAPABILITIES, renderCapabilities, type RenderCapabilities } from './documentRenderPresentation'
type FontSizeChoice = 13 | 14 | 16 | 18
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
@@ -131,6 +132,8 @@ interface AppState {
rdmMembersAvailable: boolean
rdmDocReviewAvailable: boolean
rdmPermMatrixAvailable: boolean
/** 统一渲染(Task 11):rdm.render.* 核心命令齐备才 ready;审阅/批注/导出各自门控。 */
rdmRender: RenderCapabilities
/** 管理区声明分区(WP3-I3):编译插件声明,入口全员可见、区内置权限不合并。 */
adminSections: AdminZoneSection[]
ui: UIState
@@ -201,6 +204,7 @@ let state: AppState = {
rdmAuditAvailable: false,
rdmExportAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
rdmRender: NO_RENDER_CAPABILITIES,
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
outbox: [], outboxDraining: false,
@@ -443,7 +447,7 @@ export const store = {
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES,
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null,
@@ -1202,11 +1206,12 @@ export const store = {
rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDutyReady(cmds),
rdmPermMatrixAvailable: rdmPermMatrixReady(cmds),
rdmRender: renderCapabilities(cmds),
})
} catch {
if (epoch !== identityEpoch) return
set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false })
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES })
}
try {
const sections = await api.adminZoneSections()
+124
View File
@@ -644,6 +644,130 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
.rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }
.rdm-audit-materials-side[hidden] { display: none; }
.rdm-audit-materials > .drw { flex: 1; }
.rdm-audit-tree-row { display: flex; align-items: center; gap: 4px; }
.rdm-audit-tree-row .rdm-audit-tree-node { flex: 1; min-width: 0; }
.rdm-audit-tree-open { flex: none; font-size: var(--fs-ui-sm); padding: 4px 8px; }
/* 统一渲染 Task 11:文档阅读与交付工作区(结构画布 / 阅读视图 + 规则、预览、审阅、下载) */
.drw { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.drw-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.drw-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-lg); color: var(--text); }
.drw-view-tabs { flex: none; }
.drw-body { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); gap: 16px; align-items: start; }
.drw-main, .drw-side { min-width: 0; display: flex; flex-direction: column; gap: 12px; }
.drw-filters { display: flex; gap: 8px; flex-wrap: wrap; }
.drw-search { flex: 1; min-width: 140px; height: 32px; font-size: var(--fs-ui-sm); }
.drw-select { height: 32px; font-size: var(--fs-ui-sm); max-width: 100%; }
.drw-canvas { position: relative; height: min(60vh, 520px); overflow: auto; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); cursor: grab; touch-action: none; }
.drw-canvas.is-panning { cursor: grabbing; user-select: none; }
.drw-canvas-stage { position: relative; }
.drw-canvas-edges { position: absolute; inset: 0; pointer-events: none; }
.drw-canvas-edge { fill: none; stroke: var(--border-strong); stroke-width: 1.5; }
.drw-canvas-node { position: absolute; display: flex; align-items: center; gap: 6px; padding: 0 10px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--text-dim); font-size: var(--fs-ui-sm); text-align: left; cursor: pointer; }
.drw-canvas-node:hover { background: var(--bg-hover); color: var(--text); }
.drw-canvas-node.depth-0 { color: var(--text); font-weight: 600; border-color: var(--border-strong); }
.drw-canvas-node.sel { background: var(--bg-active); color: var(--text); box-shadow: inset 0 0 0 1px var(--accent); }
.drw-canvas-node:focus-visible, .drw-reader-title:focus-visible { outline: none; box-shadow: var(--ring); }
.drw-canvas-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.drw-reader { max-height: min(60vh, 520px); overflow: auto; padding: 4px 16px 16px; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius); }
.drw-reader-node { padding: 4px 8px; border-radius: var(--radius-sm); scroll-margin-top: 8px; }
.drw-reader-node.sel { background: var(--bg-active); }
.drw-reader-heading { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 8px 0 4px; font-weight: 600; color: var(--text); }
h2.drw-reader-heading { font-size: var(--fs-ui-xl); }
h3.drw-reader-heading { font-size: var(--fs-ui-lg); }
h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); }
.drw-reader-title { background: none; border: none; padding: 0; color: inherit; font: inherit; text-align: left; cursor: pointer; border-radius: var(--radius-xs); overflow-wrap: anywhere; }
.drw-anchor { color: var(--text-faint); font-size: var(--fs-ui-sm); text-decoration: none; }
.drw-anchor:hover { color: var(--accent-text); }
.drw-badges { display: inline-flex; gap: 4px; flex: none; }
.drw-badge { min-width: 18px; padding: 0 5px; border-radius: var(--radius-full); font-size: var(--fs-ui-xs); font-weight: 600; line-height: 16px; text-align: center; color: var(--text-on-semantic); }
.drw-badge.sev-error { background: var(--err-text); }
.drw-badge.sev-warning { background: var(--warn-text); }
.drw-badge.review { background: var(--accent-soft); color: var(--accent-text); }
.drw-selected { padding: 12px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); }
.drw-selected-title { font-size: var(--fs-ui-base); color: var(--text); margin-bottom: 6px; overflow-wrap: anywhere; }
.drw-facts { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); }
.drw-fact { display: flex; gap: 8px; align-items: baseline; }
.drw-fact-k { flex: none; min-width: 4.5em; color: var(--text-dim); }
.drw-fact-v { flex: 1; min-width: 0; color: var(--text); overflow-wrap: anywhere; }
.drw-diff-del, .drw-diff-add { padding: 4px 8px; border-radius: var(--radius-xs); font-size: var(--fs-ui-sm); white-space: pre-wrap; overflow-wrap: anywhere; }
.drw-diff-del { background: var(--diff-remove); color: var(--diff-remove-text); text-decoration: line-through; }
.drw-diff-add { background: var(--diff-add); color: var(--diff-add-text); }
.drw-diff-del + .drw-diff-add { margin-top: 4px; }
.drw-conflict { color: var(--warn-text); }
.drw-byline { display: inline-flex; gap: 6px; align-items: baseline; min-width: 0; }
.drw-byline-name { color: var(--text); font-weight: 550; }
.drw-byline-time { color: var(--text-dim); font-size: var(--fs-ui-sm); }
.drw-panel { display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); min-width: 0; }
.drw-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0; }
.drw-row-between { justify-content: space-between; }
.drw-muted { font-size: var(--fs-ui-sm); color: var(--text-dim); }
.drw-mono { font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-ok { font-size: var(--fs-ui-sm); color: var(--ok-text); }
.drw-op { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; font-size: var(--fs-ui-sm); }
.drw-receipt { display: flex; flex-direction: column; gap: 6px; }
.drw-filename { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-failure { display: flex; flex-direction: column; gap: 4px; }
.drw-failure-msg { font-size: var(--fs-ui-sm); color: var(--err-text); overflow-wrap: anywhere; }
.drw-notice { padding: 8px 10px; background: var(--wait-soft); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
.drw-profiles { display: flex; flex-direction: column; gap: 8px; }
.drw-profile-list { display: flex; flex-direction: column; gap: 4px; }
.drw-profile { display: flex; gap: 8px; align-items: flex-start; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; }
.drw-profile:hover { background: var(--bg-hover); }
.drw-profile.sel { background: var(--bg-active); }
.drw-profile.off { cursor: not-allowed; }
.drw-profile input { margin-top: 3px; flex: none; }
.drw-profile-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.drw-profile-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-profile-source { color: var(--text); font-weight: 600; }
.drw-profile-id { max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-profile-ver { color: var(--text-dim); }
.drw-profile-reason { font-size: var(--fs-ui-sm); color: var(--text-dim); }
.drw-diags, .drw-annos { display: flex; flex-direction: column; gap: 6px; }
.drw-group-trigger { display: flex; width: 100%; padding: 4px 0; background: none; border: none; cursor: pointer; text-align: left; }
.drw-group-label { font-size: var(--fs-ui-sm); font-weight: 600; color: var(--text); }
.drw-group-label.sev-error { color: var(--err-text); }
.drw-group-label.sev-warning { color: var(--warn-text); }
.drw-diag-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }
.drw-diag { display: flex; flex-wrap: wrap; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); }
.drw-diag.sev-error { border-left-color: var(--err); }
.drw-diag.sev-warning { border-left-color: var(--warn); }
.drw-diag-node { max-width: 16em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
.drw-diag-node.unlocated { color: var(--text-dim); cursor: default; }
.drw-diag-msg { flex: 1 1 12em; min-width: 0; color: var(--text); overflow-wrap: anywhere; }
.drw-diag-code { font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); }
.drw-diag-hint { flex-basis: 100%; color: var(--text-dim); }
.drw-more { align-self: flex-start; font-size: var(--fs-ui-sm); }
.drw-anno-group { display: flex; flex-direction: column; gap: 4px; }
.drw-anno-node { align-self: flex-start; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); font-weight: 600; cursor: pointer; }
.drw-anno, .drw-rev { padding: 6px 8px; background: var(--bg-raised); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); }
.drw-anno.resolved { opacity: .8; }
.drw-anno-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-bottom: 2px; }
.drw-anno-text { color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
.drw-anno-replies { margin-top: 6px; padding-left: 12px; border-left: 2px solid var(--border-soft); display: flex; flex-direction: column; gap: 6px; }
.drw-anno-orphan-note { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 4px; }
.drw-rev .drw-diff-del, .drw-rev .drw-diff-add { margin-top: 4px; }
.drw-decisions { display: flex; flex-direction: column; gap: 4px; }
.drw-decision { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-downloads { display: flex; gap: 6px; flex-wrap: wrap; }
.drw-download { font-size: var(--fs-ui-sm); padding: 4px 8px; }
.drw-composer { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border-soft); }
.drw-composer-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
.drw-composer-node { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
.drw-composer-reply { display: flex; gap: 8px; align-items: center; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.drw-textarea { min-height: 64px; resize: vertical; font-size: var(--fs-ui-sm); }
.drw-inline-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
.drw-link { padding: 0; background: none; border: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; }
.drw-link:disabled { color: var(--text-dim); cursor: default; }
.drw-deliveries { display: flex; flex-direction: column; gap: 6px; }
.drw-delivery { display: flex; flex-direction: column; gap: 6px; padding: 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); }
.drw-delivery.sel { background: var(--bg-active); }
@media (max-width: 860px) {
.drw-body { grid-template-columns: minmax(0, 1fr); }
.drw-canvas, .drw-reader { height: auto; max-height: 50vh; }
.drw-canvas { min-height: 240px; }
}
.duty-matrix-wrap { overflow: auto; }
.duty-matrix .duty-sticky { position: sticky; left: 0; background: var(--bg-panel); z-index: 1; }
.duty-matrix thead th { position: sticky; top: 0; background: var(--bg-panel); z-index: 2; }
+432
View File
@@ -0,0 +1,432 @@
// 统一渲染 Task 11:规则选择、预览诊断、双视图文档树、审阅与下载的纯展示逻辑。
// 组件只消费这里的函数;服务端字段一律视为可能被裁剪,缺失即不显示,不补造。
import { describe, expect, it } from 'vitest'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import {
RDM_RENDER_COMMANDS, renderCapabilities,
normalizeProfiles, profileOptionViews, controlledNotice, defaultProfileKey, profileKey,
describeRenderFailure, displayText,
normalizeDiagnostics, groupDiagnostics, diagnosticsSummaryText, diagnosticCountsByNode,
nextOperation, createRequestGuard, renderScope,
buildDocTree, visibleNodeIds, visibleOrder, layoutMindMap, moveSelection, nodeAnchorId,
nodeFacts, hasFacts,
normalizeReviewView, partitionAnnotations, annotatePayload, revisePayload, validateReviewText,
normalizeDeliveries, mergeDeliveries, downloadOptions, normalizeDownload, decodeBase64,
truncateMiddle, latestSubmissionId, formatRenderTime,
type RenderProfiles, type DocTree,
} from '../src/documentRenderPresentation'
import {
ProfileOptionList, DiagnosticsList, AnnotationGroups, DownloadButtons, RenderFailure,
} from '../src/components/DocumentRenderWorkspace'
const cmd = (id: string, enabled = true) => ({ id, enabled })
describe('渲染能力门控(fail-closed)', () => {
it('核心命令齐备才 ready;审阅/批注各自独立门控', () => {
const all = RDM_RENDER_COMMANDS.map(id => cmd(id))
const caps = renderCapabilities(all)
expect(caps).toEqual({ ready: true, submit: true, review: true, annotate: true, revise: true, reviewExport: true, approvedExport: true })
const noReview = renderCapabilities(all.filter(c => c.id !== 'rdm.render.review.view'))
expect(noReview.ready).toBe(true)
expect(noReview.review).toBe(false)
expect(renderCapabilities(all.filter(c => c.id !== 'rdm.render.profiles')).ready).toBe(false)
})
it('null / 非数组 / disabled 一律未就绪', () => {
expect(renderCapabilities(null).ready).toBe(false)
expect(renderCapabilities('x' as never).ready).toBe(false)
expect(renderCapabilities(RDM_RENDER_COMMANDS.map(id => cmd(id, false))).ready).toBe(false)
})
})
const profiles = (over: Partial<RenderProfiles> = {}): RenderProfiles => ({
options: [
{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false },
{ source: 'template', profileId: 'tpl', version: '1', state: 'draft', publicationAllowed: false, controlled: true, fallbackAllowed: false },
{ source: 'document', profileId: 'doc', version: '2', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false, snapshotHash: 'h1' },
{ source: 'project', profileId: 'prj', version: '5', state: 'blocked', publicationAllowed: true, controlled: true, fallbackAllowed: false, blockedReason: '资产校验未通过' },
],
...over,
})
describe('规则选择(四级来源)', () => {
it('按 文档 > 项目 > 模板 > 公司默认 排序并给出来源/版本/状态', () => {
const views = profileOptionViews(profiles())
expect(views.map(v => v.option.source)).toEqual(['document', 'project', 'template', 'organization-default'])
expect(views.map(v => v.sourceLabel)).toEqual(['文档规则', '项目规则', '模板规则', '公司默认规则'])
expect(views[0]).toMatchObject({ stateLabel: '已发布', previewAllowed: true, formalAllowed: true })
expect(views[0].reason).toBeUndefined()
})
it('停用规则不可用,给出服务端原因;草稿只能预览', () => {
const [, blocked, draft] = profileOptionViews(profiles())
expect(blocked).toMatchObject({ previewAllowed: false, formalAllowed: false, reason: '资产校验未通过' })
expect(draft).toMatchObject({ previewAllowed: true, formalAllowed: false })
expect(draft.reason).toContain('草稿')
})
it('受控文档不回退到公司默认,并给出明确说明', () => {
const org = profileOptionViews(profiles())[3]
expect(org).toMatchObject({ previewAllowed: false, formalAllowed: false })
expect(org.reason).toContain('不回退到公司默认')
expect(controlledNotice(profiles())).toContain('不会回退到公司默认规则')
const free = profiles({ options: [{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true }] })
expect(controlledNotice(free)).toBeUndefined()
expect(profileOptionViews(free)[0].previewAllowed).toBe(true)
})
it('未获发布许可的已发布规则不能用于正式交付', () => {
const p = profiles({ options: [{ source: 'project', profileId: 'p', version: '1', state: 'published', publicationAllowed: false, controlled: false, fallbackAllowed: true }] })
expect(profileOptionViews(p)[0]).toMatchObject({ previewAllowed: true, formalAllowed: false })
})
it('默认选中服务端 selected;否则第一个可预览项', () => {
const p = profiles({ selected: { source: 'template', profileId: 'tpl', version: '1', hash: 'x' } })
expect(defaultProfileKey(p)).toBe(profileKey({ source: 'template', profileId: 'tpl', version: '1' }))
expect(defaultProfileKey(profiles())).toBe('document:doc@2')
expect(defaultProfileKey(profiles({ options: [] }))).toBe('')
})
it('归一化丢弃不完整或未知来源的选项,refusal 保留', () => {
const p = normalizeProfiles({ options: [{ source: 'galaxy', profileId: 'x', version: '1' }, { source: 'document', profileId: 'd' }, { source: 'document', profileId: 'd', version: 2, state: 'published' }], refusal: '无权读取规则' })
expect(p.options).toHaveLength(1)
expect(p.options[0]).toMatchObject({ profileId: 'd', version: '2', publicationAllowed: false, controlled: false, fallbackAllowed: false })
expect(p.refusal).toBe('无权读取规则')
expect(normalizeProfiles(null).options).toEqual([])
})
})
describe('拒绝与失败转为可读文案(原始回执另行折叠)', () => {
it('非 2xx JSON 回执取 error 文本,不直出 JSON', () => {
const f = describeRenderFailure(new Error('400: {"receipt":{"state":"rejected","error":"规则未发布,不能用于正式交付"}}'))
expect(f.message).toBe('规则未发布,不能用于正式交付')
expect(f.raw).toContain('"receipt"')
})
it('登录失效 / 能力缺失 / 无法解析', () => {
expect(describeRenderFailure('401: x').message).toContain('登录已失效')
expect(describeRenderFailure('400: unknown or disabled command: rdm.render.profiles').message).toContain('未提供文档渲染能力')
expect(describeRenderFailure('500: {"weird":1}').message).toBe('操作失败,请稍后重试')
expect(describeRenderFailure('{"a":1}').message).toBe('操作失败,请稍后重试')
})
it('纯文本拒绝原样(截断)显示;OOXML 不显示', () => {
expect(describeRenderFailure(new Error('当前编制人不是你')).message).toBe('当前编制人不是你')
expect(describeRenderFailure('x'.repeat(500)).message.length).toBeLessThanOrEqual(241)
expect(describeRenderFailure('<w:document><w:body/></w:document>').message).toBe('操作失败,请稍后重试')
})
it('displayText 屏蔽原始文档格式', () => {
expect(displayText('<?xml version="1.0"?><w:p/>')).toBe('(原始文档格式内容不显示)')
expect(displayText(' ')).toBeUndefined()
expect(displayText(12)).toBeUndefined()
expect(displayText('正文')).toBe('正文')
})
it('RenderFailure 只在 Collapsible 内出现原始回执', () => {
const html = renderToStaticMarkup(createElement(RenderFailure, { error: '400: {"receipt":{"error":"拒绝"}}', id: 't' }))
expect(html).toContain('拒绝')
expect(html).toContain('原始回执')
expect(html).toContain('mk-col')
expect(html).not.toContain('<details')
})
})
describe('预览诊断', () => {
const raw = [
{ code: 'W1', severity: 'warning', message: '标题层级跳跃', nodeId: 's2' },
{ code: 'E1', severity: 'error', message: '缺少必填章节', nodeId: 's1', suggestion: '补写' },
{ code: 'I1', severity: 'info', message: '提示' },
{ severity: 'error' },
{ code: 'E2', severity: 'fatal', message: '模板缺失', nodeId: 's1' },
]
it('归一化严重级并丢弃无消息条目', () => {
const list = normalizeDiagnostics(raw)
expect(list).toHaveLength(4)
expect(list.find(d => d.code === 'E2')?.severity).toBe('error')
expect(normalizeDiagnostics('nope')).toEqual([])
})
it('按 错误 > 警告 > 提示 分组,省略空组', () => {
const groups = groupDiagnostics(normalizeDiagnostics(raw))
expect(groups.map(g => [g.severity, g.items.length])).toEqual([['error', 2], ['warning', 1], ['info', 1]])
expect(groups[0].label).toBe('错误')
expect(groupDiagnostics([])).toEqual([])
})
it('摘要与节点计数', () => {
expect(diagnosticsSummaryText({ errors: 2, warnings: 1 })).toBe('2 个错误 · 1 个警告')
expect(diagnosticsSummaryText({ errors: 0, warnings: 0 })).toBe('无错误或警告')
const counts = diagnosticCountsByNode(normalizeDiagnostics(raw))
expect(counts.s1).toEqual({ errors: 2, warnings: 0 })
expect(counts.s2).toEqual({ errors: 0, warnings: 1 })
})
it('诊断列表:可定位的可点击,未定位的标明且不可点;长列表分组折叠', () => {
const list = normalizeDiagnostics(Array.from({ length: 120 }, (_, i) => ({ code: 'W', severity: 'warning', message: `问题 ${i}`, nodeId: i % 2 ? 's1' : 'ghost' })))
const html = renderToStaticMarkup(createElement(DiagnosticsList, {
diagnostics: list, nodeTitle: (id: string) => (id === 's1' ? '第一章' : undefined), onSelectNode: () => {}, idPrefix: 't',
}))
expect(html).toContain('警告(120)')
expect(html).toContain('第一章')
expect(html).toContain('未定位节点')
expect(html).toContain('mk-col')
})
})
describe('操作编号:每次动作新编号,重试复用', () => {
it('新动作铸新 id;同类重试复用;不同类不复用', () => {
let n = 0
const mint = () => `op_${++n}`
const a = nextOperation('preview', mint, null)
expect(a).toEqual({ kind: 'preview', operationId: 'op_1' })
expect(nextOperation('preview', mint, a, true)).toBe(a)
expect(nextOperation('preview', mint, a).operationId).toBe('op_2')
expect(nextOperation('submit', mint, a, true).operationId).toBe('op_3')
})
it('默认铸造器生成非空唯一编号', () => {
const x = nextOperation('preview', undefined, null)
const y = nextOperation('preview', undefined, null)
expect(x.operationId).toMatch(/^op_/)
expect(x.operationId).not.toBe(y.operationId)
})
})
describe('请求守卫:过期响应丢弃', () => {
it('更新的请求使旧票据失效', () => {
const g = createRequestGuard()
const scope = renderScope(1, 'p', 'd')
const t1 = g.begin(scope)
const t2 = g.begin(scope)
expect(g.current(t1, scope)).toBe(false)
expect(g.current(t2, scope)).toBe(true)
})
it('项目/文档/身份切换使票据失效;cancel 使全部失效', () => {
const g = createRequestGuard()
const t = g.begin(renderScope(1, 'p', 'd'))
expect(g.current(t, renderScope(1, 'p', 'other'))).toBe(false)
expect(g.current(t, renderScope(2, 'p', 'd'))).toBe(false)
expect(g.current(t, renderScope(1, 'q', 'd'))).toBe(false)
expect(g.current(t, renderScope(1, 'p', 'd'))).toBe(true)
g.cancel()
expect(g.current(t, renderScope(1, 'p', 'd'))).toBe(false)
})
it('作用域键不因分隔符拼接而碰撞', () => {
expect(renderScope(1, 'a|b', 'c')).not.toBe(renderScope(1, 'a', 'b|c'))
})
})
const doc = { id: 'doc1', title: '设计说明书' }
const items = [
{ kind: 'section', id: 's1', title: '1 范围' },
{ kind: 'section', id: 's11', title: '1.1 目的' },
{ kind: 'section', id: 's12', title: '1.2 适用', state: 'baselined' },
{ kind: 'section', id: 's2', title: '2 设计' },
{ kind: 'section', id: 's21', title: '细节', level: 2 },
{ kind: 'section', id: 's1', title: '重复' },
]
function tree(): DocTree { return buildDocTree(doc, items) }
describe('文档树:构建、过滤、布局与键盘导航', () => {
it('按编号或 level 推导层级,忽略重复 id', () => {
const t = tree()
expect(t.rootId).toBe('doc1')
expect(t.order).toEqual(['doc1', 's1', 's11', 's12', 's2', 's21'])
expect(t.nodes.doc1.children).toEqual(['s1', 's2'])
expect(t.nodes.s1.children).toEqual(['s11', 's12'])
expect(t.nodes.s21.parentId).toBe('s2')
expect(t.nodes.s12.depth).toBe(2)
expect(t.nodes.s1.title).toBe('1 范围')
})
it('显式 parentId 优先', () => {
const t = buildDocTree(doc, [{ kind: 'section', id: 'a', title: '甲' }, { kind: 'section', id: 'b', title: '乙', parentId: 'a' }])
expect(t.nodes.a.children).toEqual(['b'])
})
it('过滤保留命中节点及其祖先;问题/审阅模式按标记过滤', () => {
const t = tree()
const marks = { issues: new Set(['s21']), review: new Set(['s11']) }
expect([...visibleNodeIds(t, { text: '适用', mode: 'all' }, marks)].sort()).toEqual(['doc1', 's1', 's12'])
expect([...visibleNodeIds(t, { text: '', mode: 'issues' }, marks)].sort()).toEqual(['doc1', 's2', 's21'])
expect([...visibleNodeIds(t, { text: '', mode: 'review' }, marks)].sort()).toEqual(['doc1', 's1', 's11'])
expect(visibleNodeIds(t, { text: '', mode: 'all' }, marks).size).toBe(6)
expect(visibleOrder(t, new Set(['doc1', 's2', 's21']))).toEqual(['doc1', 's2', 's21'])
})
it('思维导图布局:父节点居中于子节点,边连接父子,尺寸覆盖全部节点', () => {
const t = tree()
const layout = layoutMindMap(t, visibleNodeIds(t, { text: '', mode: 'all' }, { issues: new Set(), review: new Set() }))
const at = (id: string) => layout.nodes.find(n => n.id === id)!
expect(at('s11').x).toBeGreaterThan(at('s1').x)
expect(at('s1').y).toBe((at('s11').y + at('s12').y) / 2)
expect(layout.edges).toHaveLength(5)
expect(layout.edges[0].d).toMatch(/^M[\d.]+ [\d.]+ C/)
for (const n of layout.nodes) {
expect(n.x + 180).toBeLessThanOrEqual(layout.width)
expect(n.y + 32).toBeLessThanOrEqual(layout.height)
}
})
it('键盘:上下按可见顺序、左到父、右到首子、Home/End,未处理返回 null', () => {
const t = tree()
const order = t.order
expect(moveSelection(t, order, 's1', 'ArrowDown')).toBe('s11')
expect(moveSelection(t, order, 's1', 'ArrowUp')).toBe('doc1')
expect(moveSelection(t, order, 's12', 'ArrowLeft')).toBe('s1')
expect(moveSelection(t, order, 's1', 'ArrowRight')).toBe('s11')
expect(moveSelection(t, order, 's1', 'End')).toBe('s21')
expect(moveSelection(t, order, 's21', 'Home')).toBe('doc1')
expect(moveSelection(t, order, null, 'ArrowDown')).toBe('doc1')
expect(moveSelection(t, order, 's21', 'ArrowDown')).toBe('s21')
expect(moveSelection(t, order, 's1', 'x')).toBeNull()
expect(moveSelection(t, ['doc1', 's2'], 's2', 'ArrowRight')).toBe('s2')
})
it('锚点 id 只含安全字符', () => {
expect(nodeAnchorId('sec/1 a')).toBe('drw-node-sec_1_a')
})
})
describe('节点事实:只显示服务端给出的字段', () => {
it('空详情不产出任何事实', () => {
const f = nodeFacts({ kind: 'section', id: 's1', relations: [], recentEvents: [] })
expect(hasFacts(f)).toBe(false)
expect(f).toEqual({ sources: [], impacts: [] })
expect(hasFacts(nodeFacts(null))).toBe(false)
})
it('来源/影响/置信/冲突/作者时间/变更前后', () => {
const f = nodeFacts({
kind: 'section', id: 's1', confidence: 0.82, conflict: '与需求 R3 表述冲突',
versions: [{ seq: 1, actor: 'amy', at: '2026-10-01T00:00:00Z' }, { seq: 3, actor: 'bob', at: '2026-10-03T00:00:00Z' }],
change: { before: '旧文', after: '新文', impact: '影响验证计划' },
relations: [
{ layer: 'trace', state: 'active', from: { kind: 'req', id: 'r1' }, to: { kind: 'section', id: 's1' }, conf: 0.9 },
{ layer: 'trace', state: 'active', from: { kind: 'note', id: 'secret', disclosure: 'private' }, to: { kind: 'section', id: 's1' } },
{ layer: 'trace', state: 'active', from: { kind: 'section', id: 's1' }, to: { kind: 'test', id: 't1' } },
],
})
expect(f.sources).toEqual(['req:r1(置信 90%)'])
expect(f.impacts).toEqual(['test:t1'])
expect(f.confidence).toBe('82%')
expect(f.conflict).toBe('与需求 R3 表述冲突')
expect(f.author).toBe('bob')
expect(f.at).toBe('2026-10-03T00:00:00Z')
expect(f.change).toEqual({ before: '旧文', after: '新文', impact: '影响验证计划' })
expect(hasFacts(f)).toBe(true)
})
it('无版本时退到正式版作者;OOXML 变更不显示原文', () => {
const f = nodeFacts({ id: 's1', formal: { actor: 'carol', at: '2026-09-01T00:00:00Z' }, change: { after: '<w:p>x</w:p>' } })
expect(f.author).toBe('carol')
expect(f.change?.after).toBe('(原始文档格式内容不显示)')
expect(f.change?.before).toBeUndefined()
})
})
describe('审阅:批注、修订与载荷', () => {
const view = normalizeReviewView({
annotations: [
{ id: 'a1', nodeId: 's1', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '请补充', replies: [{ id: 'a1r', nodeId: 's1', author: { id: 'u2', displayName: '李四' }, createdAt: '2026-10-02T00:00:00Z', text: '已补', resolved: false, orphaned: false }], resolved: false, orphaned: false },
{ id: 'a2', nodeId: 's1', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '原位置已删除', replies: [], resolved: false, orphaned: true },
{ id: 'a3', nodeId: 'gone', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z', text: '找不到节点', replies: [], resolved: true, orphaned: false },
{ id: 'bad' },
],
revisions: [{ id: 'v1', nodeId: 's1', kind: 'replace', before: '旧', after: '新', author: { id: 'u1', displayName: '张三' }, createdAt: '2026-10-01T00:00:00Z' }, { id: 'v2', kind: 'explode' }],
decisions: [{ actor: { id: 'u3', displayName: '王五' }, duty: 'reviewer', decision: 'approve', at: '2026-10-03T00:00:00Z' }],
})
it('归一化丢弃不完整条目', () => {
expect(view.annotations.map(a => a.id)).toEqual(['a1', 'a2', 'a3'])
expect(view.annotations[0].replies).toHaveLength(1)
expect(view.revisions.map(r => r.id)).toEqual(['v1'])
expect(view.decisions).toHaveLength(1)
expect(normalizeReviewView(undefined)).toEqual({ annotations: [], revisions: [], decisions: [] })
})
it('孤立或节点不存在的批注进入「无法定位」,绝不挂到其他节点', () => {
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['s1', 's2']))
expect(byNode.get('s1')?.map(a => a.id)).toEqual(['a1'])
expect(orphaned.map(a => a.id)).toEqual(['a2', 'a3'])
})
it('批注分组渲染:回复缩进、无法定位单独成组', () => {
const { byNode, orphaned } = partitionAnnotations(view.annotations, new Set(['s1']))
const html = renderToStaticMarkup(createElement(AnnotationGroups, { byNode, orphaned, nodeTitle: () => '第一章', onSelectNode: () => {} }))
expect(html).toContain('无法定位(2)')
expect(html).toContain('已补')
expect(html).toContain('李四')
expect(html.indexOf('第一章')).toBeLessThan(html.indexOf('无法定位'))
})
it('载荷只含业务字段,身份与时间由服务端填写', () => {
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: ' 好 ', operationId: 'op_1' }))
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '好', operationId: 'op_1' })
expect(annotatePayload({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '回', operationId: 'op_2', replyTo: 'a1' }))
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '回', operationId: 'op_2', replyTo: 'a1' })
expect(revisePayload({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'insert', after: '新增', operationId: 'op_3' }))
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'insert', after: '新增', operationId: 'op_3' })
expect(revisePayload({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'replace', before: 'a', after: 'b', operationId: 'op_4' }))
.toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'replace', before: 'a', after: 'b', operationId: 'op_4' })
})
it('文本校验', () => {
expect(validateReviewText(' ')).toBe('请输入内容')
expect(validateReviewText('x'.repeat(4001))).toContain('4000')
expect(validateReviewText('好')).toBeUndefined()
})
})
describe('交付记录与下载', () => {
const page = normalizeDeliveries({
items: [
{ deliveryId: 'd2', mode: 'approved', submissionId: 'sub1', profileId: 'doc', profileVersion: '2', createdAt: '2026-10-03T00:00:00Z', requestedBy: { id: 'u1', displayName: '张三' }, status: 'succeeded', availableKinds: ['manifest', 'docx', 'review-docx', 'markdown', 'evil'], diagnosticsSummary: { errors: 0, warnings: 1 } },
{ deliveryId: 'd1', mode: 'preview', profileId: 'doc', profileVersion: '2', createdAt: '2026-10-01T00:00:00Z', status: 'succeeded', availableKinds: ['docx', 'diagnostics'] },
{ mode: 'preview' },
],
nextCursor: 'c2',
})
it('归一化与分页合并去重', () => {
expect(page.items.map(d => d.deliveryId)).toEqual(['d2', 'd1'])
expect(page.nextCursor).toBe('c2')
expect(page.items[1].diagnosticsSummary).toEqual({ errors: 0, warnings: 0 })
const merged = mergeDeliveries(page.items, [{ ...page.items[1] }, { ...page.items[0], deliveryId: 'd0' }])
expect(merged.map(d => d.deliveryId)).toEqual(['d2', 'd1', 'd0'])
expect(latestSubmissionId(page.items)).toBe('sub1')
expect(latestSubmissionId([page.items[1]])).toBeUndefined()
})
it('下载选项按固定顺序,批准模式标注批准稿,忽略未知类型', () => {
expect(downloadOptions(page.items[0]).map(o => [o.kind, o.label])).toEqual([
['markdown', 'Markdown'], ['docx', '批准稿 DOCX'], ['review-docx', '审阅留痕 DOCX'], ['manifest', '清单(manifest)'],
])
expect(downloadOptions(page.items[1]).map(o => o.label)).toEqual(['预览 DOCX', '诊断报告'])
})
it('下载载荷接受 contentType/filename 或 mime/fileName;缺数据报错', () => {
expect(normalizeDownload({ filename: 'a.md', contentType: 'text/markdown', dataBase64: 'QQ==', sha256: 'x' }))
.toEqual({ fileName: 'a.md', contentType: 'text/markdown', dataBase64: 'QQ==', sha256: 'x' })
expect(normalizeDownload({ fileName: 'b.docx', mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', dataBase64: 'QQ==', sha256: 'y' }).contentType)
.toContain('wordprocessingml')
expect(() => normalizeDownload({ fileName: 'c', sha256: 'z' })).toThrow('下载内容不完整')
expect(() => normalizeDownload({ dataBase64: 'QQ==', sha256: 'z' })).toThrow('下载内容不完整')
expect(normalizeDownload({ fileName: 'c', dataBase64: 'QQ==', sha256: 'z' }).contentType).toBe('application/octet-stream')
})
it('base64 解码', () => {
expect([...decodeBase64('QUJD')]).toEqual([65, 66, 67])
})
it('长文件名中间截断并保留扩展名', () => {
const long = '研发项目设计说明书-第三轮审阅留痕-包含全部批注与修订记录-最终版本.docx'
const short = truncateMiddle(long, 24)
expect(short.length).toBeLessThanOrEqual(24)
expect(short.endsWith('.docx')).toBe(true)
expect(short).toContain('…')
expect(truncateMiddle('a.md', 24)).toBe('a.md')
})
it('下载按钮:截断名 + title 全名', () => {
const html = renderToStaticMarkup(createElement(DownloadButtons, {
delivery: { ...page.items[0], deliveryId: 'd2' }, busyKind: null, onDownload: () => {},
}))
expect(html).toContain('批准稿 DOCX')
expect(html).toContain('审阅留痕 DOCX')
expect(html).not.toContain('evil')
})
it('时间格式化:非法时间为空串', () => {
expect(formatRenderTime('not-a-date')).toBe('')
expect(formatRenderTime('2026-10-01T00:00:00Z')).not.toBe('')
})
})
describe('规则列表渲染', () => {
it('显示来源、版本、状态与不可用原因;受控说明可见', () => {
const html = renderToStaticMarkup(createElement(ProfileOptionList, {
profiles: profiles(), selectedKey: 'document:doc@2', onSelect: () => {},
}))
for (const s of ['文档规则', '项目规则', '模板规则', '公司默认规则', 'v2', '已发布', '草稿', '资产校验未通过', '不会回退到公司默认规则']) {
expect(html).toContain(s)
}
expect(html).toContain('disabled')
})
it('refusal 以可读失败显示', () => {
const html = renderToStaticMarkup(createElement(ProfileOptionList, {
profiles: profiles({ options: [], refusal: '{"weird":true}' }), selectedKey: '', onSelect: () => {},
}))
expect(html).toContain('操作失败,请稍后重试')
expect(html).toContain('原始回执')
})
})