// 统一渲染 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 | undefined => (v && typeof v === 'object' && !Array.isArray(v) ? v as Record : 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 = { document: '文档规则', project: '项目规则', template: '模板规则', 'organization-default': '公司默认规则', } const PROFILE_STATE_LABELS: Record = { 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 = { 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 { const out: Record = {} 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; 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 = { [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; review: Set } export function visibleNodeIds(tree: DocTree, filter: DocFilter, marks: NodeMarks): Set { const text = filter.text.trim().toLowerCase() const visible = new Set([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[] { 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): MindMapLayout { const { colWidth, rowHeight, nodeWidth, nodeHeight, pad } = MIND_MAP const pos = new Map() 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 | 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 => !!v) const latest = versions.reduce | 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 = {} 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 = { 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(list: T[], known: Set) { const byNode = new Map() 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) { return partitionByNode(list, known) } export function partitionRevisions(list: ReviewRevision[], known: Set) { 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 { 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 { 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 = { 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 = { 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 { 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 }) }