// 统一渲染 Task 11 的纯展示逻辑:规则选择、预览诊断、文档树(结构画布 + 阅读视图)、 // 审阅批注/修订、交付下载。服务端字段一律视为可能被裁剪——缺失即不显示,不补造; // 身份与时间只由服务端填写,载荷构造不含任何身份字段。 import { docApprovePayload, docFinalPayload, docRejectPayload } from './rdmManagement' // ---- 能力门控(与 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', 'rdm.review.doc.approve', 'rdm.review.doc.reject', 'rdm.review.doc.final', 'rdm.render.profile.bind', 'rdm.render.profile.unbind', ] as const export interface RenderCapabilities { ready: boolean submit: boolean review: boolean annotate: boolean revise: boolean reviewExport: boolean approvedExport: boolean /** 整文审签动作:各自独立门控,入口另需服务端冻结职责允许。 */ approve: boolean reject: boolean final: boolean /** 规则绑定/解绑命令可用;项目设置权限另由 rdm.perm.check 判定。 */ bind: boolean unbind: boolean } export const NO_RENDER_CAPABILITIES: RenderCapabilities = { ready: false, submit: false, review: false, annotate: false, revise: false, reviewExport: false, approvedExport: false, approve: false, reject: false, final: false, bind: false, unbind: 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'), approve: on('rdm.review.doc.approve'), reject: on('rdm.review.doc.reject'), final: on('rdm.review.doc.final'), bind: on('rdm.render.profile.bind'), unbind: on('rdm.render.profile.unbind'), } } // ---- 通用:安全文本 ---- 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 } /** 服务端稳定拒绝码 → 可读文案(码本身不作为正文显示;原始回执仍在折叠区)。 */ const REFUSAL_MESSAGES: { code: string; message: string }[] = [ { code: 'controlled_requires_exact_profile', message: '受控文档只能使用其精确绑定的规则,不能回退到项目或公司默认规则' }, { code: 'profile_not_published', message: '该规则尚未发布,不能用于绑定或生成' }, { code: 'binding_mismatch', message: '当前绑定已变化,请刷新规则列表后重试' }, // 基座正式写命令的来源校验(cmdbus projectWorkPreflight):来源消息已记录,但尚未被接受为正式来源。 { code: 'project source preflight', message: '审签来源尚未通过校验(来源消息已记录),请稍后重试;审签未生效' }, ] /** rdm.render.review.annotate 的范围校验失败(消息以 review_anchor_invalid: 开头;成对缺失另有固定文案)。 */ const ANCHOR_FAILURES: { match: RegExp; message: string }[] = [ { match: /has no frozen text a range can anchor/, message: '该节点没有可按范围批注的正文(如章节容器、图片或空字段),请改为批注整个节点' }, { match: /quote does not match/, message: '所选文字与提交审阅时冻结的正文不一致,请改为批注整个节点或重新选择' }, { match: /quote occurs more than once/, message: '所选文字在该节点中出现多次,无法唯一定位,请重新选择' }, { match: /quote is not in the frozen text/, message: '所选文字不在该节点冻结的正文中,请改为批注整个节点或重新选择' }, { match: /out of range/, message: '所选范围超出该节点冻结正文的长度,请改为批注整个节点或重新选择' }, { match: /reply must address the parent/, message: '回复只能针对原批注所在的节点,回复沿用原批注的范围' }, ] function anchorFailureMessage(raw: string): string | undefined { if (raw.includes('textStart and textEnd must be supplied together')) return '批注范围不完整(起止位置须成对提供),请重新选择文字' if (!raw.includes('review_anchor_invalid:')) return undefined return ANCHOR_FAILURES.find(f => f.match.test(raw))?.message ?? '批注范围无效,请重新选择文字或改为批注整个节点' } 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 anchor = anchorFailureMessage(raw) if (anchor) return { message: anchor, raw } const refusal = REFUSAL_MESSAGES.find(r => raw.includes(r.code)) if (refusal) return { message: refusal.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 } } // ---- 规则选择(五级来源;与服务端 RenderProfileOptionsFor 的发出顺序一致) ---- /** 服务端解析优先级:文档 > 文档类型 > 项目默认 > 公司默认;模板规则只作为受控文档的显式选择。 */ const PROFILE_SOURCES = ['document', 'document-type', 'template', 'project-default', 'organization-default'] as const type ProfileSource = (typeof PROFILE_SOURCES)[number] const PROFILE_SOURCE_LABELS: Record = { document: '文档规则', 'document-type': '文档类型规则', template: '模板规则', 'project-default': '项目默认规则', 'organization-default': '公司默认规则', } const FALLBACK_SOURCES: readonly ProfileSource[] = ['project-default', 'organization-default'] const PROFILE_STATE_LABELS: Record = { published: '已发布', validated: '已验证', draft: '草稿', missing: '无快照', blocked: '已停用', retired: '已废弃', deprecated: '已废弃', } /** 有可用快照、服务端允许预览的状态;blockedReason 对它们只限制正式交付。 */ const PREVIEWABLE_STATES = ['published', 'validated', 'draft'] 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) ?? '' } const refusal = obj(r?.refusal) const refusalText = str(refusal?.message) ?? str(refusal?.code) if (refusalText) out.refusal = refusalText 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 || '未标明状态'), } const serverReason = displayText(option.blockedReason) if (FALLBACK_SOURCES.includes(option.source) && (option.controlled || docControlled) && !option.fallbackAllowed) { return { ...base, previewAllowed: false, formalAllowed: false, reason: serverReason ?? '受控文档不回退到项目或公司默认规则,请使用文档、文档类型或模板中明确指定的规则' } } if (!PREVIEWABLE_STATES.includes(option.state) && (option.state === 'blocked' || serverReason)) { return { ...base, previewAllowed: false, formalAllowed: false, reason: serverReason ?? '该规则已停用,不能用于生成' } } if (option.state === 'draft') { return { ...base, previewAllowed: true, formalAllowed: false, reason: serverReason ?? '草稿规则只能用于预览,不能用于正式审阅或批准交付' } } if (option.state !== 'published' || !option.publicationAllowed || serverReason) { return { ...base, previewAllowed: true, formalAllowed: false, reason: serverReason ?? '该规则未获准用于正式交付,只能用于预览' } } 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 /** 渲染器 canonical/映射节点 id(如 pp.cover.document-number.value),不保证是工作区树节点。 */ nodeId?: string /** RDM 回填的工作区树节点 id(dn-…);定位优先用它。 */ treeNodeId?: 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 } : {}), ...(str(d.treeNodeId) ? { treeNodeId: d.treeNodeId } : {}), ...(suggestion ? { suggestion } : {}), }) } return out } interface DiagnosticGroup { severity: DiagnosticSeverity; label: string; items: RenderDiagnostic[] } const SEVERITY_LABELS: Record = { error: '错误', warning: '警告', info: '提示' } /** 渲染器稳定诊断代码的中文短标签;未知代码原样显示,不猜测含义。 */ const DIAGNOSTIC_CODE_LABELS: Record = { required_content_missing: '缺少必填内容', intentionally_empty: '有意留空', template_placeholder_left: '模板占位符未替换', mapping_fallback: '映射已回退', signature_data_missing: '缺少签署数据', mapping_unresolved: '映射未解析', mapping_ambiguous: '映射存在歧义', approval_invalid: '审签记录无效', } export function diagnosticCodeLabel(code: string): string { return DIAGNOSTIC_CODE_LABELS[code] ?? code } 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(' · ') : '无错误或警告' } /** 定位到工作区树节点(诊断、审阅条目、渲染链条共用):先 treeNodeId,缺省或不在树中再回退 nodeId;都不在树中即未定位,不猜测。 */ export function locateNodeId(ids: { treeNodeId?: string; nodeId?: string }, isKnown: (id: string) => boolean): string | undefined { for (const id of [ids.treeNodeId, ids.nodeId]) { if (id && isKnown(id)) return id } return undefined } export function locateDiagnostic(d: RenderDiagnostic, nodeTitle: (id: string) => string | undefined): { id: string; title: string } | undefined { const id = locateNodeId(d, x => !!nodeTitle(x)) return id ? { id, title: nodeTitle(id)! } : undefined } export function diagnosticCountsByNode(list: RenderDiagnostic[], isKnown: (id: string) => boolean = () => true): Record { const out: Record = {} for (const d of list) { if (d.severity === 'info') continue const id = locateNodeId(d, isKnown) if (!id) continue const c = out[id] ?? (out[id] = { errors: 0, warnings: 0 }) if (d.severity === 'error') c.errors++ else c.warnings++ } return out } type ChipTone = 'ok' | 'info' | 'plain' | 'warn' | 'danger' const RECEIPT_STATES: Record = { ready: { label: '已生成', tone: 'ok' }, running: { label: '生成中', tone: 'info' }, frozen: { label: '排队中', tone: 'plain' }, 'generation-failed': { label: '生成失败', tone: 'danger' }, cancelled: { label: '已取消', tone: 'warn' }, } export function receiptStateView(state: unknown): { label: string; tone: ChipTone } { return (typeof state === 'string' && RECEIPT_STATES[state]) || { label: '状态未知', tone: 'plain' } } type RdmCall = (command: string, params: Record) => Promise type DiagnosticsLoad = { deliveryId: string; diagnostics: RenderDiagnostic[] } | { deliveryId: string; error: string } /** 生成回执不带诊断;按 deliveryId 另取 diagnostics 交付物。守卫失效(过期)返回 null,失败返回可读文案而不抛出。 */ export async function loadDeliveryDiagnostics(call: RdmCall, projectId: string, receipt: unknown, live: () => boolean): Promise { const deliveryId = str(obj(receipt)?.deliveryId) if (!deliveryId) return { deliveryId: '', error: '回执缺少交付编号,无法读取诊断' } let raw: unknown try { raw = await call('rdm.render.download', { projectId, deliveryId, kind: 'diagnostics' }) } catch (e) { return live() ? { deliveryId, error: describeRenderFailure(e).message } : null } if (!live()) return null let data: string try { data = normalizeDownload(raw).dataBase64 } catch (e) { return { deliveryId, error: describeRenderFailure(e).message } } try { return { deliveryId, diagnostics: normalizeDiagnostics(JSON.parse(new TextDecoder('utf-8').decode(decodeBase64(data)))) } } catch { return { deliveryId, error: '诊断报告无法解析' } } } // ---- 操作编号与请求守卫(异步状态唯一所有者 + 过期响应丢弃) ---- export type OperationKind = 'preview' | 'submit' | 'review-export' | 'approved-export' | 'annotate' | 'revise' | 'approve' | 'reject' | 'final' | 'bind' | 'unbind' 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 createSingleFlight() { let inFlight = false return { busy: () => inFlight, run(task: () => Promise): Promise { inFlight = true let started: Promise try { started = task() } catch (e) { started = Promise.reject(e) } return started.finally(() => { inFlight = false }) }, } } export function renderScope(identity: number, projectId: string, docId: string): string { return JSON.stringify([identity, projectId, docId]) } // ---- 文档树 ---- interface DocTreeItem { kind: string id: string title: string state?: string level?: number depth?: number parentId?: string parent?: string hasChildren?: boolean } export const DOC_TREE_LIMITS = { maxDepth: 12, maxNodes: 2000, concurrency: 4, maxPages: 20 } as const type DocTreeLimits = { [K in keyof typeof DOC_TREE_LIMITS]: number } interface DocTreeShell { items?: unknown[]; nextCursor?: string; hasMore?: boolean } const NODE_KIND_LABELS: Record = { section: '章节', paragraph: '段落', field: '字段', table: '表格', row: '表格行', cell: '单元格', list: '列表', item: '列表项', figure: '图', } /** 模板实例中封面、签署表、字段等块没有标题:以块类型与模板块编号标识,画布与阅读视图不出现空白节点。 */ function untitledLabel(kind: string, templateBlockId: string | undefined, id: string): string { return `${NODE_KIND_LABELS[kind] ?? kind}(${templateBlockId ?? id})` } function treeItemOf(raw: unknown, parent: string): DocTreeItem | undefined { const o = obj(raw) if (!o || !str(o.id) || typeof o.title !== 'string') return undefined const kind = str(o.kind) ?? 'section' return { kind, id: o.id, title: o.title.trim() ? o.title : untitledLabel(kind, str(o.templateBlockId), o.id), parentId: parent, ...(str(o.state) ? { state: o.state } : {}), ...(typeof o.hasChildren === 'boolean' ? { hasChildren: o.hasChildren } : {}), } } /** * rdm.inspect.tree 每次只返回 parent 的直接子节点:逐层广度展开(有界深度/节点数/并发), * 再按文档顺序(先序)输出,父节点总在子节点之前。live() 失效即停止发请求并返回 null。 */ export async function walkDocTree( fetchLevel: (parent: string, cursor: string) => Promise, docId: string, live: () => boolean, limits: DocTreeLimits = DOC_TREE_LIMITS, ): Promise<{ items: DocTreeItem[]; truncated: boolean } | null> { const seen = new Set([docId]) const children = new Map() let count = 0 let truncated = false let stale = false let level = [docId] for (let depth = 0; level.length > 0; depth++) { const fetched = new Map() const readParent = async (parent: string) => { const found: DocTreeItem[] = [] let cursor = '' for (let page = 0; ; page++) { if (page >= limits.maxPages) { truncated = true; break } if (!live()) { stale = true; return } const shell = await fetchLevel(parent, cursor) if (!live()) { stale = true; return } for (const raw of arr(shell?.items)) { const item = treeItemOf(raw, parent) if (item) found.push(item) } if (!shell?.hasMore || !shell.nextCursor) break cursor = shell.nextCursor } fetched.set(parent, found) } let cursorIdx = 0 const current = level const worker = async () => { while (!stale && cursorIdx < current.length) await readParent(current[cursorIdx++]) } await Promise.all(Array.from({ length: Math.min(limits.concurrency, current.length) }, worker)) if (stale) return null const next: string[] = [] for (const parent of current) { const accepted: DocTreeItem[] = [] for (const item of fetched.get(parent) ?? []) { if (seen.has(item.id)) continue if (count >= limits.maxNodes) { truncated = true; break } seen.add(item.id) count++ accepted.push(item) if (item.hasChildren === false) continue if (depth + 1 < limits.maxDepth) next.push(item.id) else if (item.hasChildren) truncated = true } children.set(parent, accepted) } if (count >= limits.maxNodes && next.length > 0) { truncated = true; break } level = next } const items: DocTreeItem[] = [] const emit = (parent: string) => { for (const item of children.get(parent) ?? []) { items.push(item); emit(item.id) } } emit(docId) return { items, truncated } } 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 /** 渲染器规范节点 id(审阅快照写入值)。 */ nodeId: string /** 审阅人所选的工作区树节点 id;分组与定位优先用它。 */ treeNodeId?: string author: ReviewPerson createdAt: string text: string resolved: boolean orphaned: boolean } export interface ReviewAnnotation extends ReviewReply { replies: ReviewReply[] /** 服务端按冻结文本截取的批注范围摘录。 */ quote?: string /** 节点仍在但范围或哈希不再匹配冻结文本:显示旧摘录,不自动迁移。 */ needsReanchor?: true } type RevisionKind = 'insert' | 'delete' | 'replace' export interface ReviewRevision { id: string nodeId: string treeNodeId?: 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, ...(str(a.treeNodeId) ? { treeNodeId: a.treeNodeId } : {}), } } 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 a = obj(item) const replies = arr(a?.replies).map(reply).filter((x): x is ReviewReply => !!x) const quote = displayText(a?.quote, 1000) annotations.push({ ...base, replies, ...(quote ? { quote } : {}), ...(a?.needsReanchor === true ? { needsReanchor: true as const } : {}) }) } 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) ?? '', ...(str(v.treeNodeId) ? { treeNodeId: v.treeNodeId } : {}), ...(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) { const at = item.orphaned ? undefined : locateNodeId(item, id => known.has(id)) if (!at) { orphaned.push(item); continue } const bucket = byNode.get(at) if (bucket) bucket.push(item) else byNode.set(at, [item]) } return { byNode, orphaned } } /** 按树节点分组(treeNodeId 优先、nodeId 回退);孤立或都不在当前文档树中的进入「无法定位」,绝不挂到其他节点。 */ 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 } /** 从树上审阅的目标:选中的工作区树节点(文档根不是审阅目标)。 */ export function reviewTargetNode(tree: DocTree | null, selectedId: string | null): DocNode | undefined { const node = tree && selectedId ? tree.nodes[selectedId] : undefined return node && node.kind !== 'doc' ? node : undefined } /** RDM 的 nodeId 参数接受规范节点或工作区树节点;从树上审阅一律发送所选树节点 id(dn-/sec-/legacy-)。 */ interface ReviewTarget { projectId: string; submissionId: string; treeNodeId: string; operationId: string } /** * 批注范围只随根批注、且只在范围属于目标节点时发送:回复继承父批注锚点,RDM 拒绝回复带范围; * textHash 由 RDM 从冻结节点全文计算,载荷绝不含 textHash/range。 */ export function annotatePayload(input: ReviewTarget & { text: string; replyTo?: string; range?: TextAnchor | null }): Record { const { projectId, submissionId, treeNodeId, operationId, replyTo, range } = input const anchor = !replyTo && range && range.nodeId === treeNodeId ? { textStart: range.textStart, textEnd: range.textEnd, quote: range.quote } : {} return { projectId, submissionId, nodeId: treeNodeId, text: input.text.trim(), operationId, ...(replyTo ? { replyTo } : {}), ...anchor } } // ---- 批注范围(06 §2:nodeId + textStart + textEnd + textHash) ---- /** 码点半开区间(JS Array.from 下标,即 RDM 的 rune 偏移)与区间原文。 */ export interface TextAnchor { nodeId: string; textStart: number; textEnd: number; quote: string } const isHighSurrogate = (c: number) => c >= 0xd800 && c <= 0xdbff const isLowSurrogate = (c: number) => c >= 0xdc00 && c <= 0xdfff const splitsPair = (text: string, i: number) => i > 0 && i < text.length && isHighSurrogate(text.charCodeAt(i - 1)) && isLowSurrogate(text.charCodeAt(i)) /** UTF-16 选区 → 码点范围:落在代理对中间的端点外扩成完整字符,首尾空白不计入;无内容返回 undefined。 */ export function codePointRange(text: string, utf16Start: number, utf16End: number): Omit | undefined { let start = Math.max(0, Math.min(utf16Start, utf16End)) let end = Math.min(text.length, Math.max(utf16Start, utf16End)) if (splitsPair(text, start)) start-- if (splitsPair(text, end)) end++ while (start < end && /\s/.test(text[start])) start++ while (end > start && /\s/.test(text[end - 1])) end-- if (start >= end) return undefined const quote = text.slice(start, end) const textStart = Array.from(text.slice(0, start)).length return { textStart, textEnd: textStart + Array.from(quote).length, quote } } /** 选区与某个节点正文元素的交集(UTF-16 偏移,已裁到该元素文本内)。 */ export interface SelectionHit { nodeId: string; text: string; start: number; end: number } /** 阅读视图选区判定:null 为无选区或折叠;只落在一个节点正文内才记录范围,其余给出说明且不记录范围。 */ export function selectionAnchor(hits: SelectionHit[] | null): | { kind: 'none' } | { kind: 'range'; anchor: TextAnchor } | { kind: 'multi-node' | 'outside-text'; notice: string } { if (hits === null) return { kind: 'none' } const touched = hits.filter(h => h.end > h.start) if (touched.length > 1) return { kind: 'multi-node', notice: '所选文字跨越多个节点,范围批注只能选在一个节点的正文内;未记录范围,提交将批注整个节点' } const range = touched.length === 1 ? codePointRange(touched[0].text, touched[0].start, touched[0].end) : undefined if (!range) return { kind: 'outside-text', notice: '所选内容不在节点正文内;未记录范围,提交将批注整个节点' } return { kind: 'range', anchor: { nodeId: touched[0].nodeId, ...range } } } export function revisePayload(input: ReviewTarget & { kind: RevisionKind; before?: string; after?: string }): Record { const { projectId, submissionId, treeNodeId, kind, operationId } = input return { projectId, submissionId, nodeId: treeNodeId, kind, ...(input.before !== undefined ? { before: input.before } : {}), ...(input.after !== undefined ? { after: input.after } : {}), operationId, } } // ---- 渲染链条(rdm.inspect.relations 中 layer=render 的关系) ---- const RENDER_TRACE_TYPES = [ 'selected-profile', 'profile-binding-changed', 'delivered', 'rendered-from', 'rendered-with', 'reviewed-by', 'cites', 'restricted-basis', 'submitted', 'annotated', 'anchored-to', 'revised', 'signed-off', ] as const type RenderTraceType = (typeof RENDER_TRACE_TYPES)[number] const RENDER_TRACE_TYPE_LABELS: Record = { 'selected-profile': '选用规则', 'profile-binding-changed': '规则绑定变更', delivered: '生成交付', 'rendered-from': '渲染源版本', 'rendered-with': '规则快照', 'reviewed-by': '审阅快照', cites: '引用来源', 'restricted-basis': '受限依据', submitted: '提交审阅', annotated: '批注', 'anchored-to': '批注锚定', revised: '修订', 'signed-off': '签署', } const TRACE_KIND_LABELS: Record = { doc: '文档', 'render-profile': '渲染规则', 'render-profile-change': '规则变更', 'render-delivery': '交付', 'source-version': '源版本', 'profile-snapshot': '规则快照', 'review-snapshot': '审阅快照', source: '来源', 'restricted-source': '受限来源', 'render-submission': '审阅提交', 'review-annotation': '批注', 'review-revision': '修订', 'sign-off': '签署', 'document-node': '文档节点', } const SIGN_OFF_ROLE_LABELS: Record = { reviewer: '审核人', approver: '批准人' } /** 端点正文字段(RDM renderTraceContentKeys 同集);遮蔽条目绝不读取。 */ const TRACE_BODY_KEYS = ['text', 'excerpt', 'bodyMarkdown', 'after', 'afterText', 'before', 'beforeText', 'reason', 'note'] as const interface TraceEndpoint { kind: string id: string nodeId?: string treeNodeId?: string actor?: string at?: string details: string[] } export interface RenderTraceEntry { type: RenderTraceType | 'other' rawType: string from: TraceEndpoint to: TraceEndpoint /** 审计读者(无正文职责):只有元数据,正文、批注与修订文字已被服务端剔除。 */ masked: boolean /** 仅未遮蔽条目可能携带的正文摘录。 */ body?: string } export interface RenderTrace { entries: RenderTraceEntry[]; masked: boolean; truncated: boolean } const isTraceType = (v: unknown): v is RenderTraceType => RENDER_TRACE_TYPES.includes(v as RenderTraceType) function traceDetails(e: Record): string[] { const mode = str(e.mode) const source = str(e.source) const role = str(e.role) const revisionKind = str(e.revisionKind) return [ mode && (DELIVERY_MODE_LABELS[mode] ?? mode), source && (isSource(source) ? PROFILE_SOURCE_LABELS[source] : source), role && (SIGN_OFF_ROLE_LABELS[role] ?? role), revisionKind && (revisionKind in REVISION_KIND_LABELS ? REVISION_KIND_LABELS[revisionKind as RevisionKind] : revisionKind), str(e.decision), str(e.state), str(e.action), ].filter((x): x is string => !!x) } function traceEndpoint(raw: unknown): TraceEndpoint | undefined { const e = obj(raw) const kind = str(e?.kind) const id = str(e?.id) if (!e || !kind || !id) return undefined const actor = str(e.displayName) ?? str(e.actorId) return { kind, id, details: traceDetails(e), ...(str(e.nodeId) ? { nodeId: e.nodeId } : {}), ...(str(e.treeNodeId) ? { treeNodeId: e.treeNodeId } : {}), ...(actor ? { actor } : {}), ...(str(e.at) ? { at: e.at } : {}), } } function traceBody(raw: unknown): string | undefined { const e = obj(raw) for (const key of TRACE_BODY_KEYS) { const text = displayText(e?.[key], 500) if (text) return text } return undefined } export function normalizeRenderTrace(raw: unknown): RenderTrace { const r = obj(raw) const entries: RenderTraceEntry[] = [] for (const item of arr(r?.items)) { const o = obj(item) if (o?.layer !== 'render') continue const rawType = str(o.type) const from = traceEndpoint(o.from) const to = traceEndpoint(o.to) if (!rawType || !from || !to) continue const masked = o.contentMasked === true const body = masked ? undefined : traceBody(o.to) entries.push({ type: isTraceType(rawType) ? rawType : 'other', rawType, from, to, masked, ...(body ? { body } : {}) }) } return { entries, masked: entries.some(e => e.masked), truncated: r?.truncated === true } } /** 条目的主体端点与可定位的树节点:anchored-to 的 to.id 即树节点 id(不回退规范 id);批注/修订条目 treeNodeId 优先、nodeId 回退。 */ export function traceAnchor(e: RenderTraceEntry): { subject: TraceEndpoint; target?: { treeNodeId?: string; nodeId?: string } } { switch (e.type) { case 'anchored-to': return { subject: e.from, ...(e.to.kind === 'document-node' ? { target: { treeNodeId: e.to.id } } : {}) } case 'annotated': case 'revised': return { subject: e.to, target: { treeNodeId: e.to.treeNodeId, nodeId: e.to.nodeId } } case 'selected-profile': case 'profile-binding-changed': case 'delivered': case 'rendered-from': case 'rendered-with': case 'reviewed-by': case 'cites': case 'restricted-basis': case 'submitted': case 'signed-off': case 'other': return { subject: e.to } default: { const exhaustive: never = e.type throw new Error(`unknown trace type: ${String(exhaustive)}`) } } } export function traceTypeLabel(e: RenderTraceEntry): string { return e.type === 'other' ? e.rawType : RENDER_TRACE_TYPE_LABELS[e.type] } export function traceEndpointLabel(p: TraceEndpoint): string { const kind = TRACE_KIND_LABELS[p.kind] ?? p.kind return p.kind === 'restricted-source' ? '存在受限依据' : `${kind} ${truncateMiddle(p.id, 24)}` } // ---- 交付记录与下载 ---- 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 } /** rdm.render.submit 回执即 DocSubmissionRec,提交编号字段名为 submissionId。 */ export function submissionIdOf(receipt: unknown): string | undefined { return str(obj(receipt)?.submissionId) } /** 另一会话(如编制人提交、审核人打开)提交且尚无导出时,交付记录里没有提交编号,以 rdm.review.doc.status 为准。 */ export function currentSubmissionId(choice: string | null, docStatus: unknown, deliveries: RenderDelivery[]): string | null { return choice ?? submissionIdOf(obj(docStatus)?.submission) ?? latestSubmissionId(deliveries) ?? null } export const DELIVERY_MODE_LABELS: Record = { preview: '预览', review: '审阅', approved: '批准' } const DOWNLOAD_ORDER = ['markdown', 'docx', 'review-docx', 'manifest', 'diagnostics', 'package'] as const export type DownloadKind = (typeof DOWNLOAD_ORDER)[number] /** * 完整包由服务端按需打包(不在 availableKinds 中出现),只在交付已生成(ready)时提供; * 权限在下载时由服务端重新鉴权,与单件下载同一路径。 */ 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: '诊断报告', package: '完整交付包(ZIP)', } const available = (k: DownloadKind) => (k === 'package' ? d.status === 'ready' : d.availableKinds.includes(k)) return DOWNLOAD_ORDER.filter(available).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 }) } // ---- 整文审签:入口由服务端冻结职责与会话身份共同决定;载荷只含 projectId/docId 与 note|reason|round ---- export type DecisionKind = 'approve' | 'reject' | 'final' /** rdm.review.doc.status 的在途整文提交(只保留入口判定与展示需要的字段)。 */ export interface DocSubmissionView { submissionId: string round: number state: string author: string reviewers: string[] approver: string reviews: Record } /** 服务端 domain.Roles 无 json 标签,字段以 Go 名称输出(Author/Reviewers/Approver),兼容小写。 */ export function normalizeDocSubmission(raw: unknown): DocSubmissionView | null { const rec = obj(obj(raw)?.submission) const submissionId = str(rec?.submissionId) const round = typeof rec?.round === 'number' && Number.isInteger(rec.round) && rec.round > 0 ? rec.round : 0 if (!rec || !submissionId || round === 0) return null const duties = obj(rec.duties) ?? {} const reviewers = arr(duties.Reviewers ?? duties.reviewers).filter((v): v is string => typeof v === 'string' && v !== '') const reviews: Record = {} for (const [who, ok] of Object.entries(obj(rec.reviews) ?? {})) reviews[who] = ok === true return { submissionId, round, state: str(rec.state) ?? '', author: str(duties.Author) ?? str(duties.author) ?? '', approver: str(duties.Approver) ?? str(duties.approver) ?? '', reviewers, reviews, } } export interface DecisionEntries { approve: boolean; reject: boolean; final: boolean } const NO_DECISIONS: DecisionEntries = { approve: false, reject: false, final: false } /** * 入口 = 命令可用 ∧ 冻结状态为在途 ∧ 会话身份在本轮冻结名单内(与服务端判定同规则: * 审核人未审可通过、编制人不可自审、驳回取审核人/批准人/编制人、终批须批准人且全员已审)。 * 服务端仍逐次复核;这里只决定是否显示入口,身份取自会话,不由客户端写入载荷。 */ export function decisionEntries( sub: DocSubmissionView | null, me: string | undefined, caps: Pick, ): DecisionEntries { if (!sub || !me || sub.state !== 'submitted') return NO_DECISIONS const isReviewer = sub.reviewers.includes(me) || Object.prototype.hasOwnProperty.call(sub.reviews, me) const allApproved = Object.values(sub.reviews).every(Boolean) return { approve: caps.approve && isReviewer && sub.reviews[me] !== true && me !== sub.author, reject: caps.reject && (isReviewer || me === sub.author || me === sub.approver), final: caps.final && me === sub.approver && allApproved, } } /** 审签载荷:approve/final 的说明可选,reject 的原因由调用方先校验非空;round 绑定到界面所示轮次。 */ export function decisionPayload(kind: DecisionKind, projectId: string, docId: string, text: string, round: number): { command: string; params: Record } { const t = text.trim() switch (kind) { case 'approve': return { command: 'rdm.review.doc.approve', params: docApprovePayload(projectId, docId, t || undefined) } case 'reject': return { command: 'rdm.review.doc.reject', params: docRejectPayload(projectId, docId, t || undefined) } case 'final': return { command: 'rdm.review.doc.final', params: { ...docFinalPayload(projectId, docId, round), ...(t ? { reason: t } : {}) } } default: { const exhaustive: never = kind throw new Error(`unknown decision kind: ${String(exhaustive)}`) } } } // ---- 规则绑定:三个作用域;候选 = 服务端选项中已发布项 ∪ 项目已发布候选(去重) ---- export type BindSource = 'document' | 'document-type' | 'project-default' export const BIND_SOURCE_LABELS: Record = { document: '本文档', 'document-type': '文档类型', 'project-default': '项目默认', } export interface RuleRef { profileId: string; version: string } export interface BindableRule extends RuleRef { label: string } /** 当前绑定:只取服务端 rdm.render.profiles 选项中的绑定来源(已绑定才会出现)。 */ export function currentBindings(p: RenderProfiles): Partial> { const out: Partial> = {} for (const o of p.options) { if (o.source === 'document' || o.source === 'document-type' || o.source === 'project-default') { if (!out[o.source]) out[o.source] = { profileId: o.profileId, version: o.version } } } return out } /** rdm.render.profile.candidate.list:发布记录(publication)存在即为项目已发布的规则快照。 */ export function normalizeCandidateRules(raw: unknown): RuleRef[] { const out: RuleRef[] = [] for (const item of arr(obj(raw)?.candidates)) { const pub = obj(obj(item)?.publication) const profileId = str(pub?.profileId) const version = versionOf(pub?.version) if (profileId && version) out.push({ profileId, version }) } return out } export function ruleLabel(r: RuleRef): string { return `${r.profileId}@${r.version}` } export function bindableRules(p: RenderProfiles, candidates: RuleRef[]): BindableRule[] { const seen = new Set() const out: BindableRule[] = [] const add = (r: RuleRef) => { const key = ruleLabel(r) if (seen.has(key)) return seen.add(key) out.push({ profileId: r.profileId, version: r.version, label: key }) } for (const o of p.options) { if (o.state === 'published' && o.publicationAllowed) add({ profileId: o.profileId, version: o.version }) } for (const c of candidates) add(c) return out } /** 作用域对应的 key:文档=docId,文档类型=字典类型键,项目默认=projectId;缺失则无法绑定。 */ function bindingKey(source: BindSource, ctx: { projectId: string; docId: string; typeKey?: string }): string | undefined { if (source === 'document') return ctx.docId || undefined if (source === 'document-type') return ctx.typeKey || undefined return ctx.projectId || undefined } export function profileBindPayload(input: { projectId: string; source: BindSource; docId: string; typeKey?: string; rule: RuleRef }): Record | undefined { const key = bindingKey(input.source, input) if (!key) return undefined return { projectId: input.projectId, source: input.source, key, profileId: input.rule.profileId, version: input.rule.version } } /** 解绑的前置条件(profileId+version 成对):服务端据此拒绝「界面所示绑定已变化」的旧请求。 */ export function profileUnbindPayload(input: { projectId: string; source: BindSource; docId: string; typeKey?: string; precondition?: RuleRef }): Record | undefined { const key = bindingKey(input.source, input) if (!key) return undefined const pre = input.precondition return { projectId: input.projectId, source: input.source, key, ...(pre ? { profileId: pre.profileId, version: pre.version } : {}) } } /** rdm.inspect.object(kind=doc)的字典文档类型键,文档类型规则绑定的作用域 key。 */ export function docTypeKeyOf(raw: unknown): string | undefined { return str(obj(raw)?.typeKey) } /** rdm.perm.check 矩阵:仅 project.settings 且 allowed=true 才放行规则绑定入口。 */ export function projectSettingsAllowed(raw: unknown): boolean { return arr(obj(raw)?.actions).some(item => { const a = obj(item) return a?.action === 'project.settings' && a.allowed === true }) }