Files
Agent-System-Frontend/src/documentRenderPresentation.ts
T
weichenandCursor 85428b4633 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>
2026-10-15 23:44:39 +00:00

770 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 统一渲染 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 })
}