governance / governance (pull_request) Successful in 15s
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>
1390 lines
59 KiB
TypeScript
1390 lines
59 KiB
TypeScript
// 统一渲染 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
|
||
})
|
||
}
|