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>
770 lines
29 KiB
TypeScript
770 lines
29 KiB
TypeScript
// 统一渲染 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 })
|
||
}
|