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:
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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('原始回执')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user