From 85428b46336812e71c034cc7ecdc8e7833e8f87b Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 23:44:39 +0000 Subject: [PATCH] Add document render workspace with profiles, preview, review and downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- e2e/document-renderer.playwright.ts | 179 ++++ src/components/DocumentRenderWorkspace.tsx | 896 +++++++++++++++++++++ src/components/RdmAuditWorkspace.tsx | 49 +- src/documentRenderPresentation.ts | 769 ++++++++++++++++++ src/store.ts | 9 +- src/styles.css | 124 +++ test/document-render-workspace.test.ts | 432 ++++++++++ 7 files changed, 2431 insertions(+), 27 deletions(-) create mode 100644 e2e/document-renderer.playwright.ts create mode 100644 src/components/DocumentRenderWorkspace.tsx create mode 100644 src/documentRenderPresentation.ts create mode 100644 test/document-render-workspace.test.ts diff --git a/e2e/document-renderer.playwright.ts b/e2e/document-renderer.playwright.ts new file mode 100644 index 0000000..75c7730 --- /dev/null +++ b/e2e/document-renderer.playwright.ts @@ -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(' { + 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 }) + }) +}) diff --git a/src/components/DocumentRenderWorkspace.tsx b/src/components/DocumentRenderWorkspace.tsx new file mode 100644 index 0000000..10d851d --- /dev/null +++ b/src/components/DocumentRenderWorkspace.tsx @@ -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 ( +
+
{f.message}
+ 原始回执}> + {f.raw} + +
+ ) +} + +function FileName({ name }: { name: string }) { + return {truncateMiddle(name, 36)} +} + +export function ProfileOptionList({ profiles, selectedKey, onSelect }: { + profiles: RenderProfiles + selectedKey: string + onSelect: (key: string) => void +}) { + const views = profileOptionViews(profiles) + const notice = controlledNotice(profiles) + return ( +
+ {profiles.refusal && } + {notice &&
{notice}
} + {views.length === 0 && !profiles.refusal &&
没有可用的渲染规则
} +
+ {views.map(v => ( + + ))} +
+
+ ) +} + +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 ( +
    + {items.slice(0, shown).map((d, i) => { + const title = d.nodeId ? nodeTitle(d.nodeId) : undefined + return ( +
  • + {d.nodeId && title ? ( + + ) : 未定位节点} + {d.message} + {d.code && {d.code}} + {d.suggestion && 建议:{d.suggestion}} +
  • + ) + })} + {items.length > shown && ( +
  • + )} +
+ ) +} + +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
没有诊断问题
+ return ( +
+ {groups.map((g, i) => ( + {g.label}({g.items.length})}> + + + ))} +
+ ) +} + +function Byline({ name, at }: { name: string; at: string }) { + const time = formatRenderTime(at) + return ( + + {name && {name}} + {time && {time}} + + ) +} + +function AnnotationItem({ a, onReply }: { a: ReviewAnnotation; onReply?: (a: ReviewAnnotation) => void }) { + return ( +
+
+ + {a.resolved && 已解决} + {onReply && } +
+
{a.text}
+ {a.replies.length > 0 && ( +
+ {a.replies.map(r => ( +
+ +
{r.text}
+
+ ))} +
+ )} +
+ ) +} + +export function AnnotationGroups({ byNode, orphaned, nodeTitle, onSelectNode, onReply }: { + byNode: Map + orphaned: ReviewAnnotation[] + nodeTitle: (id: string) => string | undefined + onSelectNode: (id: string) => void + onReply?: (a: ReviewAnnotation) => void +}) { + if (byNode.size === 0 && orphaned.length === 0) return
暂无批注
+ return ( +
+ {[...byNode].map(([nodeId, list]) => ( +
+ + {list.map(a => )} +
+ ))} + {orphaned.length > 0 && ( + 无法定位({orphaned.length})}> +
原位置已变更或不在当前文档中,未挂到任何节点
+ {orphaned.map(a => )} +
+ )} +
+ ) +} + +function RevisionItem({ r }: { r: ReviewRevision }) { + return ( +
+
+ {REVISION_KIND_LABELS[r.kind]} + + {r.decision && {r.decision}} +
+ {r.before &&
{r.before}
} + {r.after &&
{r.after}
} +
+ ) +} + +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 暂无可下载文件 + return ( +
+ {options.map(o => ( + + ))} +
+ ) +} + +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(['冲突', {facts.conflict}]) + if (facts.author || facts.at) rows.push(['作者', ]) + if (facts.impacts.length) rows.push(['影响', facts.impacts.join('、')]) + if (facts.change?.impact) rows.push(['变更影响', facts.change.impact]) + return ( +
+ {rows.map(([k, v]) => ( +
{k}{v}
+ ))} + {(facts.change?.before || facts.change?.after) && ( +
+ 变更 + + {facts.change.before &&
{facts.change.before}
} + {facts.change.after &&
{facts.change.after}
} +
+
+ )} +
+ ) +} + +// ---- 异步所有者 ---- + +const errText = (e: unknown) => (e instanceof Error ? e.message : String(e)) + +/** 单一资源加载:requestKey 变化或 reload 发起新请求,旧请求的响应一律丢弃。 */ +function useGuardedResource(requestKey: string | null, identity: number, load: () => Promise) { + 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; onDone?: (r: unknown) => void } | null>(null) + const [state, setState] = useState({ op: null, status: 'idle', error: '' }) + useEffect(() => { const g = guard.current; return () => g.cancel() }, []) + const start = (op: RenderOperation, exec: (operationId: string) => Promise, 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, 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 { + 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>({}) + const requested = useRef(new Set()) + 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 + selectedId: string | null + onSelect: (id: string) => void + badges: (id: string) => ReactNode + facts: Record +} + +function useFocusSelected(root: React.RefObject, selectedId: string | null) { + const wantFocus = useRef(false) + useEffect(() => { + if (!selectedId || !root.current) return + const el = root.current.querySelector(`[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(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) => { + 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) => { + 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 ( +
+
+ + {layout.nodes.map(n => { + const node = tree.nodes[n.id] + return ( + + ) + })} +
+
+ ) +} + +function ReaderView({ tree, order, visible, selectedId, onSelect, badges, facts }: ViewProps) { + const ref = useRef(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 ( +
+ {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 ( +
+ + + + {badges(id)} + {n.state && {n.state}} + + {f && typeof f === 'object' && } +
+ ) + })} +
+ ) +} + +// ---- 审阅输入 ---- + +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
先在文档树中选择一个章节,再添加批注或修订
+ 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 ( +
+
+ 目标:{nodeTitle} + {canAnnotate && canRevise && ( + + )} +
+ {mode === 'comment' ? ( + <> + {replyTo && ( +
+ 回复 {replyTo.author.displayName || '批注'} + +
+ )} +