Files
Agent-System-Frontend/src/documentRenderPresentation.ts
T
weichenandCursor 50d12ce974
governance / governance (pull_request) Successful in 15s
Anchor review annotations on the selected node text range
Why: RDM now anchors annotations on nodeId + textStart + textEnd + textHash
(spec 06 §2) and acceptance 10 §5 requires accurate comment ranges. The
workspace only sent whole-node annotations and ignored quote/needsReanchor.

- Selecting text inside one node's title in the reader view records a
  code-point range (Array.from indices; surrogate pairs and ZWJ emoji are
  counted as runes, half-pair endpoints widen) and sends textStart/textEnd
  plus quote, so RDM rejects rather than misplaces a drifted range.
- No selection keeps whole-node behavior; replies never carry range params;
  textHash/range are never sent; a range is dropped if it belongs to another
  node.
- Selections spanning several nodes or containing no node text record no
  range and show a notice that the whole node will be annotated; the composer
  always shows the scope with a "whole node" escape.
- Annotation items show the server quote and a visible needs-reanchor state;
  review_anchor_invalid: errors map to readable Chinese text.
- Reader titles become role=button spans because browsers do not allow text
  selection inside <button>.

Evidence: npm test 40 files / 486 tests pass (30 new in
test/review-annotation-anchor.test.ts); npm run build exit 0;
node scripts/check-knip.mjs "50 <= 51"; design-guard 7/7; playwright
--list compiles 13 tests; headless Chromium check of the selection reader
(single, multi-node, triple-click clamp, emoji offsets).

Impact: document review UI only; base-gate decision source anchoring is
unchanged. Offsets are computed on the displayed node title (no frozen-text
read endpoint exists), guarded by quote equality on the server.

Refs: unified renderer plan Task 13; phase1 F2; spec 06 §2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 06:27:22 +00:00

1390 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 统一渲染 Task 11 的纯展示逻辑:规则选择、预览诊断、文档树(结构画布 + 阅读视图)、
// 审阅批注/修订、交付下载。服务端字段一律视为可能被裁剪——缺失即不显示,不补造;
// 身份与时间只由服务端填写,载荷构造不含任何身份字段。
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<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
}
/** 服务端稳定拒绝码 → 可读文案(码本身不作为正文显示;原始回执仍在折叠区)。 */
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<ProfileSource, string> = {
document: '文档规则',
'document-type': '文档类型规则',
template: '模板规则',
'project-default': '项目默认规则',
'organization-default': '公司默认规则',
}
const FALLBACK_SOURCES: readonly ProfileSource[] = ['project-default', 'organization-default']
const PROFILE_STATE_LABELS: Record<string, string> = {
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<DiagnosticSeverity, string> = { error: '错误', warning: '警告', info: '提示' }
/** 渲染器稳定诊断代码的中文短标签;未知代码原样显示,不猜测含义。 */
const DIAGNOSTIC_CODE_LABELS: Record<string, string> = {
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<string, { errors: number; warnings: number }> {
const out: Record<string, { errors: number; warnings: number }> = {}
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<string, { label: string; tone: ChipTone }> = {
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<string, unknown>) => Promise<unknown>
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<DiagnosticsLoad | null> {
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<T>(task: () => Promise<T>): Promise<T> {
inFlight = true
let started: Promise<T>
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<string, string> = {
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<DocTreeShell>,
docId: string,
live: () => boolean,
limits: DocTreeLimits = DOC_TREE_LIMITS,
): Promise<{ items: DocTreeItem[]; truncated: boolean } | null> {
const seen = new Set([docId])
const children = new Map<string, DocTreeItem[]>()
let count = 0
let truncated = false
let stale = false
let level = [docId]
for (let depth = 0; level.length > 0; depth++) {
const fetched = new Map<string, DocTreeItem[]>()
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<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
/** 渲染器规范节点 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<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,
...(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<T extends { nodeId: string; treeNodeId?: string; orphaned?: boolean }>(list: T[], known: Set<string>) {
const byNode = new Map<string, T[]>()
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<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
}
/** 从树上审阅的目标:选中的工作区树节点(文档根不是审阅目标)。 */
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<string, unknown> {
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<TextAnchor, 'nodeId'> | 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<string, unknown> {
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<RenderTraceType, string> = {
'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<string, string> = {
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<string, string> = { 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, any>): 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<string, string> = { 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<DownloadKind, string> = {
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<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 })
}
// ---- 整文审签:入口由服务端冻结职责与会话身份共同决定;载荷只含 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<string, boolean>
}
/** 服务端 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<string, boolean> = {}
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<RenderCapabilities, 'approve' | 'reject' | 'final'>,
): 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<string, unknown> } {
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<BindSource, string> = {
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<Record<BindSource, RuleRef>> {
const out: Partial<Record<BindSource, RuleRef>> = {}
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<string>()
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<string, unknown> | 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<string, unknown> | 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
})
}