From 12553f53eb09af75b4eaf1eebd2f0d5043b77460 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 18 Oct 2026 00:34:04 +0000 Subject: [PATCH] Locate review items and render trace by tree node id and mask audit-only trace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: RDM be80ad4 (docs/design/render-node-id-contract.md) accepts workspace tree node ids (dn-/sec-/legacy-) for review annotate/revise, adds treeNodeId to receipts, rdm.render.review.view items and trace entries while nodeId stays canonical, points anchored-to at the tree node id, and marks audit-reader trace entries with contentMasked=true. Grouping review items by the canonical nodeId left them all unlocated, and the workspace showed no render trace at all. Evidence: npx tsc --noEmit exit 0; npx vitest run 39 files / 456 tests passed (12 new in test/document-render-workspace.test.ts: treeNodeId grouping with nodeId fallback and orphan rules for annotations and revisions, data-locate targets, annotate/revise payload carrying the selected tree node id, layer=render filtering, anchored-to against tree nodes without canonical fallback, masked entries never reading body fields, empty state); npm run build ok; node scripts/check-knip.mjs 51 <= 51; e2e/document-renderer.playwright.ts type-checks (tsc exit 0) and npx playwright test --list loads 13 tests. Impact: locateNodeId (treeNodeId first, nodeId fallback, otherwise unlocated) is shared by diagnostics, review grouping and trace. Clicking an annotation or revision group, a reply, or a trace node locates and re-scrolls like diagnostics. Annotate and revise send the selected tree node id as nodeId. A 渲染链条 panel reads rdm.inspect.relations (kind=doc) through the single guarded resource owner (stale responses dropped) and reloads after preview, submit, export, review and decision actions. Masked entries show metadata only with a 内容已遮蔽 marker and a panel notice. The e2e spec is unchanged: §11-5b already asserts that the group title is the selected tree node, and the leak scan is untouched. Not run against a live backend or browser here. Refs: RDM be80ad4 docs/design/render-node-id-contract.md; unified renderer spec 10 §11 items 4, 5 and 7; DESIGN.md §8, §9.2, §11. Co-authored-by: Cursor --- src/components/DocumentRenderWorkspace.tsx | 138 +++++++++++---- src/documentRenderPresentation.ts | 191 +++++++++++++++++++-- src/styles.css | 7 + test/document-render-workspace.test.ts | 160 ++++++++++++++++- 4 files changed, 443 insertions(+), 53 deletions(-) diff --git a/src/components/DocumentRenderWorkspace.tsx b/src/components/DocumentRenderWorkspace.tsx index 2c90f79..a006b39 100644 --- a/src/components/DocumentRenderWorkspace.tsx +++ b/src/components/DocumentRenderWorkspace.tsx @@ -13,16 +13,17 @@ import { DELIVERY_MODE_LABELS, DOC_TREE_LIMITS, MIND_MAP, REVISION_KIND_LABELS, annotatePayload, buildDocTree, controlledNotice, createRequestGuard, currentSubmissionId, decodeBase64, defaultProfileKey, diagnosticCountsByNode, diagnosticsSummaryText, downloadOptions, formatRenderTime, - groupDiagnostics, hasFacts, layoutMindMap, loadDeliveryDiagnostics, locateDiagnostic, mergeDeliveries, moveSelection, - nextOperation, nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDownload, normalizeProfiles, + groupDiagnostics, hasFacts, layoutMindMap, loadDeliveryDiagnostics, locateDiagnostic, locateNodeId, mergeDeliveries, moveSelection, + nextOperation, nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDownload, normalizeProfiles, normalizeRenderTrace, normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, receiptStateView, renderScope, - revisePayload, submissionIdOf, truncateMiddle, validateReviewText, visibleNodeIds, visibleOrder, walkDocTree, + reviewTargetNode, revisePayload, submissionIdOf, traceAnchor, traceEndpointLabel, traceTypeLabel, truncateMiddle, + validateReviewText, visibleNodeIds, visibleOrder, walkDocTree, decisionEntries, decisionPayload, normalizeDocSubmission, currentBindings, bindableRules, normalizeCandidateRules, profileBindPayload, profileUnbindPayload, projectSettingsAllowed, docTypeKeyOf, diagnosticCodeLabel, createSingleFlight, type BindSource, type DecisionKind, type RuleRef, type DocFilter, type DocTree, type DownloadKind, type NodeFacts, type OperationKind, - type RenderDelivery, type RenderDiagnostic, type RenderOperation, type RenderProfiles, type ReviewAnnotation, - type ReviewRevision, + type RenderDelivery, type RenderDiagnostic, type RenderOperation, type RenderProfiles, type RenderTrace, + type RenderTraceEntry, type ReviewAnnotation, type ReviewRevision, } from '../documentRenderPresentation' // ---- 文件保存(交付物与审计导出共用;类型取服务端 contentType,不在客户端重算校验) ---- @@ -190,20 +191,21 @@ function AnnotationItem({ a, onReply }: { a: ReviewAnnotation; onReply?: (a: Rev ) } +/** byNode 的键是已定位的树节点 id;点击节点名定位(重复点击也重新滚动),回复以该树节点为审阅目标。 */ export function AnnotationGroups({ byNode, orphaned, nodeTitle, onSelectNode, onReply }: { byNode: Map orphaned: ReviewAnnotation[] nodeTitle: (id: string) => string | undefined onSelectNode: (id: string) => void - onReply?: (a: ReviewAnnotation) => void + onReply?: (a: ReviewAnnotation, treeNodeId: string) => void }) { if (byNode.size === 0 && orphaned.length === 0) return
暂无批注
return (
{[...byNode].map(([nodeId, list]) => (
- - {list.map(a => )} + + {list.map(a => onReply(x, nodeId))} />)}
))} {orphaned.length > 0 && ( @@ -231,6 +233,81 @@ function RevisionItem({ r }: { r: ReviewRevision }) { ) } +export function RevisionGroups({ byNode, orphaned, total, nodeTitle, onSelectNode }: { + byNode: Map + orphaned: ReviewRevision[] + total: number + nodeTitle: (id: string) => string | undefined + onSelectNode: (id: string) => void +}) { + if (byNode.size === 0 && orphaned.length === 0) return null + return ( + 修订({total})}> + {[...byNode].map(([nodeId, list]) => ( +
+ + {list.map(r => )} +
+ ))} + {orphaned.length > 0 && ( +
+ 无法定位({orphaned.length}) + {orphaned.map(r => )} +
+ )} +
+ ) +} + +const TRACE_PAGE = 50 + +function TraceRow({ entry, nodeTitle, onSelectNode }: { + entry: RenderTraceEntry + nodeTitle: (id: string) => string | undefined + onSelectNode: (id: string) => void +}) { + const { subject, target } = traceAnchor(entry) + const at = target ? locateNodeId(target, id => !!nodeTitle(id)) : undefined + return ( +
  • + {traceTypeLabel(entry)} + {traceEndpointLabel(subject)} + {subject.details.map(d => {d})} + {target && (at + ? + : 未定位节点)} + {entry.masked && 内容已遮蔽} + {(subject.actor || subject.at) && } + {entry.body && {entry.body}} +
  • + ) +} + +/** 渲染链条:遮蔽条目只显示元数据并标「内容已遮蔽」;批注锚定与批注/修订按树节点定位,未知节点显示为未定位。 */ +export function RenderTraceList({ trace, nodeTitle, onSelectNode }: { + trace: RenderTrace + nodeTitle: (id: string) => string | undefined + onSelectNode: (id: string) => void +}) { + const [shown, setShown] = useState(TRACE_PAGE) + if (trace.entries.length === 0) return
    暂无渲染链条记录
    + return ( +
    + {trace.masked &&
    当前身份只能查看渲染链条元数据,正文、批注与修订文字已遮蔽
    } +
      + {trace.entries.slice(0, shown).map((e, i) => )} + {trace.entries.length > shown && ( +
    • + )} +
    + {trace.truncated &&
    已截断(数量上限)
    } +
    + ) +} + export function DownloadButtons({ delivery, busyKind, disabled = false, onDownload }: { delivery: RenderDelivery busyKind: DownloadKind | null @@ -683,6 +760,8 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works const submissionId = currentSubmissionId(submissionChoice, docStatus.data, deliveries.items) const review = useGuardedResource(caps.review && submissionId ? `${scope}:review:${submissionId}` : null, identity, () => api.rdmCommand('rdm.render.review.view', { projectId, submissionId }).then(normalizeReviewView)) + const trace = useGuardedResource(caps.ready ? `${scope}:trace` : null, identity, + () => api.rdmCommand('rdm.inspect.relations', { projectId, kind: 'doc', id: docId, depth: 1 }).then(normalizeRenderTrace)) const profileData = profiles.data const views = useMemo(() => (profileData ? profileOptionViews(profileData) : []), [profileData]) @@ -730,6 +809,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works setReceipt(r && typeof r === 'object' ? r as Record : {}) receiptDiagnostics.load(r) deliveries.reload() + trace.reload() } const generatePreview = () => { const p = profileParams() @@ -743,6 +823,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works const id = submissionIdOf(r) if (id) setSubmissionChoice(id) deliveries.reload() + trace.reload() }) } const exportFor = (kind: 'review-export' | 'approved-export') => { @@ -751,16 +832,17 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works renderOp.run(kind, operationId => api.rdmCommand(command, { projectId, docId, operationId, submissionId }), acceptReceipt) } const selectedNode = tree && selectedId ? tree.nodes[selectedId] : undefined - const reviewTarget = selectedNode && selectedNode.kind !== 'doc' ? selectedNode : undefined + const reviewTarget = reviewTargetNode(tree, selectedId) + const reviewDone = () => { review.reload(); trace.reload() } const annotate = (text: string, reply?: string) => { if (!submissionId || !reviewTarget) return reviewOp.run('annotate', operationId => api.rdmCommand('rdm.render.review.annotate', - annotatePayload({ projectId, submissionId, nodeId: reviewTarget.id, text, operationId, replyTo: reply })), () => { setReplyTo(null); review.reload() }) + annotatePayload({ projectId, submissionId, treeNodeId: reviewTarget.id, text, operationId, replyTo: reply })), () => { setReplyTo(null); reviewDone() }) } const revise = (kind: 'insert' | 'delete' | 'replace', before?: string, after?: string) => { if (!submissionId || !reviewTarget) return reviewOp.run('revise', operationId => api.rdmCommand('rdm.render.review.revise', - revisePayload({ projectId, submissionId, nodeId: reviewTarget.id, kind, before, after, operationId })), () => review.reload()) + revisePayload({ projectId, submissionId, treeNodeId: reviewTarget.id, kind, before, after, operationId })), reviewDone) } // 审签动作:载荷只含 projectId/docId 与 note|reason|round,轮次取界面所示的在途轮次;结果后刷新审签状态与批注。 @@ -771,7 +853,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works const { command, params } = decisionPayload(kind, projectId, docId, text, round) decisionOp.run(kind, operationId => commitDecision(api, { host: hostProject, operationId, decision: kind, doc: { id: docId, title: docTitle }, round, note: text, command, params, - }), () => { docStatus.reload(); review.reload() }) + }), () => { docStatus.reload(); reviewDone() }) } // 规则绑定:绑定/解绑成功后刷新 rdm.render.profiles(当前绑定与选项以服务端为准)。 @@ -890,25 +972,10 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
    {review.error ? : !review.data ?
    加载中…
    : ( <> - { setReplyTo(a); selectNode(a.nodeId) } : undefined} /> - {(revisions.byNode.size > 0 || revisions.orphaned.length > 0) && ( - 修订({review.data.revisions.length})}> - {[...revisions.byNode].map(([nodeId, list]) => ( -
    - - {list.map(r => )} -
    - ))} - {revisions.orphaned.length > 0 && ( -
    - 无法定位({revisions.orphaned.length}) - {revisions.orphaned.map(r => )} -
    - )} -
    - )} + { setReplyTo(a); revealNode(treeNodeId) } : undefined} /> + {review.data.decisions.length > 0 && (
    {review.data.decisions.map((d, i) => ( @@ -942,6 +1009,15 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works )} +
    +
    + 渲染链条 + +
    + {trace.error ? : !trace.data ?
    加载中…
    + : } +
    +
    交付记录 diff --git a/src/documentRenderPresentation.ts b/src/documentRenderPresentation.ts index 3a44633..70641ed 100644 --- a/src/documentRenderPresentation.ts +++ b/src/documentRenderPresentation.ts @@ -323,20 +323,24 @@ export function diagnosticsSummaryText(s: { errors: number; warnings: number }): return parts.length ? parts.join(' · ') : '无错误或警告' } -/** 诊断定位到工作区树节点:先 treeNodeId,缺省或不在树中再回退 nodeId;都不在树中即未定位,不猜测。 */ -export function locateDiagnostic(d: RenderDiagnostic, nodeTitle: (id: string) => string | undefined): { id: string; title: string } | undefined { - for (const id of [d.treeNodeId, d.nodeId]) { - const title = id ? nodeTitle(id) : undefined - if (id && title) return { id, title } +/** 定位到工作区树节点(诊断、审阅条目、渲染链条共用):先 treeNodeId,缺省或不在树中再回退 nodeId;都不在树中即未定位,不猜测。 */ +export function locateNodeId(ids: { treeNodeId?: string; nodeId?: string }, isKnown: (id: string) => boolean): string | undefined { + for (const id of [ids.treeNodeId, ids.nodeId]) { + if (id && isKnown(id)) return id } return undefined } +export function locateDiagnostic(d: RenderDiagnostic, nodeTitle: (id: string) => string | undefined): { id: string; title: string } | undefined { + const id = locateNodeId(d, x => !!nodeTitle(x)) + return id ? { id, title: nodeTitle(id)! } : undefined +} + export function diagnosticCountsByNode(list: RenderDiagnostic[], isKnown: (id: string) => boolean = () => true): Record { const out: Record = {} for (const d of list) { if (d.severity === 'info') continue - const id = locateDiagnostic(d, x => (isKnown(x) ? x : undefined))?.id + const id = locateNodeId(d, isKnown) if (!id) continue const c = out[id] ?? (out[id] = { errors: 0, warnings: 0 }) if (d.severity === 'error') c.errors++ @@ -762,7 +766,10 @@ interface ReviewPerson { id: string; displayName: string } export interface ReviewReply { id: string + /** 渲染器规范节点 id(审阅快照写入值)。 */ nodeId: string + /** 审阅人所选的工作区树节点 id;分组与定位优先用它。 */ + treeNodeId?: string author: ReviewPerson createdAt: string text: string @@ -775,6 +782,7 @@ type RevisionKind = 'insert' | 'delete' | 'replace' export interface ReviewRevision { id: string nodeId: string + treeNodeId?: string kind: RevisionKind before?: string after?: string @@ -801,6 +809,7 @@ function reply(v: unknown): ReviewReply | 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 } : {}), } } @@ -823,6 +832,7 @@ export function normalizeReviewView(raw: unknown): ReviewView { 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 } : {}), }) } @@ -837,19 +847,20 @@ export function normalizeReviewView(raw: unknown): ReviewView { return { annotations, revisions, decisions } } -function partitionByNode(list: T[], known: Set) { +function partitionByNode(list: T[], known: Set) { const byNode = new Map() const orphaned: T[] = [] for (const item of list) { - if (item.orphaned || !item.nodeId || !known.has(item.nodeId)) { orphaned.push(item); continue } - const bucket = byNode.get(item.nodeId) + 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(item.nodeId, [item]) + else byNode.set(at, [item]) } return { byNode, orphaned } } -/** 孤立批注或节点已不在当前文档树中的批注进入「无法定位」,绝不挂到其他节点。 */ +/** 按树节点分组(treeNodeId 优先、nodeId 回退);孤立或都不在当前文档树中的进入「无法定位」,绝不挂到其他节点。 */ export function partitionAnnotations(list: ReviewAnnotation[], known: Set) { return partitionByNode(list, known) } @@ -867,23 +878,171 @@ export function validateReviewText(text: string): string | undefined { return undefined } -interface ReviewTarget { projectId: string; submissionId: string; nodeId: string; operationId: string } +/** 从树上审阅的目标:选中的工作区树节点(文档根不是审阅目标)。 */ +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 } export function annotatePayload(input: ReviewTarget & { text: string; replyTo?: string }): Record { - const { projectId, submissionId, nodeId, operationId } = input - return { projectId, submissionId, nodeId, text: input.text.trim(), operationId, ...(input.replyTo ? { replyTo: input.replyTo } : {}) } + const { projectId, submissionId, treeNodeId, operationId } = input + return { projectId, submissionId, nodeId: treeNodeId, text: input.text.trim(), operationId, ...(input.replyTo ? { replyTo: input.replyTo } : {}) } } export function revisePayload(input: ReviewTarget & { kind: RevisionKind; before?: string; after?: string }): Record { - const { projectId, submissionId, nodeId, kind, operationId } = input + const { projectId, submissionId, treeNodeId, kind, operationId } = input return { - projectId, submissionId, nodeId, kind, + projectId, submissionId, nodeId: treeNodeId, kind, ...(input.before !== undefined ? { before: input.before } : {}), ...(input.after !== undefined ? { after: input.after } : {}), operationId, } } +// ---- 渲染链条(rdm.inspect.relations 中 layer=render 的关系) ---- + +const RENDER_TRACE_TYPES = [ + 'selected-profile', 'profile-binding-changed', 'delivered', 'rendered-from', 'rendered-with', 'reviewed-by', 'cites', + 'restricted-basis', 'submitted', 'annotated', 'anchored-to', 'revised', 'signed-off', +] as const +type RenderTraceType = (typeof RENDER_TRACE_TYPES)[number] + +const RENDER_TRACE_TYPE_LABELS: Record = { + 'selected-profile': '选用规则', 'profile-binding-changed': '规则绑定变更', delivered: '生成交付', 'rendered-from': '渲染源版本', + 'rendered-with': '规则快照', 'reviewed-by': '审阅快照', cites: '引用来源', 'restricted-basis': '受限依据', submitted: '提交审阅', + annotated: '批注', 'anchored-to': '批注锚定', revised: '修订', 'signed-off': '签署', +} + +const TRACE_KIND_LABELS: Record = { + doc: '文档', 'render-profile': '渲染规则', 'render-profile-change': '规则变更', 'render-delivery': '交付', 'source-version': '源版本', + 'profile-snapshot': '规则快照', 'review-snapshot': '审阅快照', source: '来源', 'restricted-source': '受限来源', + 'render-submission': '审阅提交', 'review-annotation': '批注', 'review-revision': '修订', 'sign-off': '签署', 'document-node': '文档节点', +} + +const SIGN_OFF_ROLE_LABELS: Record = { reviewer: '审核人', approver: '批准人' } + +/** 端点正文字段(RDM renderTraceContentKeys 同集);遮蔽条目绝不读取。 */ +const TRACE_BODY_KEYS = ['text', 'excerpt', 'bodyMarkdown', 'after', 'afterText', 'before', 'beforeText', 'reason', 'note'] as const + +interface TraceEndpoint { + kind: string + id: string + nodeId?: string + treeNodeId?: string + actor?: string + at?: string + details: string[] +} + +export interface RenderTraceEntry { + type: RenderTraceType | 'other' + rawType: string + from: TraceEndpoint + to: TraceEndpoint + /** 审计读者(无正文职责):只有元数据,正文、批注与修订文字已被服务端剔除。 */ + masked: boolean + /** 仅未遮蔽条目可能携带的正文摘录。 */ + body?: string +} + +export interface RenderTrace { entries: RenderTraceEntry[]; masked: boolean; truncated: boolean } + +const isTraceType = (v: unknown): v is RenderTraceType => RENDER_TRACE_TYPES.includes(v as RenderTraceType) + +function traceDetails(e: Record): string[] { + const mode = str(e.mode) + const source = str(e.source) + const role = str(e.role) + const revisionKind = str(e.revisionKind) + return [ + mode && (DELIVERY_MODE_LABELS[mode] ?? mode), + source && (isSource(source) ? PROFILE_SOURCE_LABELS[source] : source), + role && (SIGN_OFF_ROLE_LABELS[role] ?? role), + revisionKind && (revisionKind in REVISION_KIND_LABELS ? REVISION_KIND_LABELS[revisionKind as RevisionKind] : revisionKind), + str(e.decision), str(e.state), str(e.action), + ].filter((x): x is string => !!x) +} + +function traceEndpoint(raw: unknown): TraceEndpoint | undefined { + const e = obj(raw) + const kind = str(e?.kind) + const id = str(e?.id) + if (!e || !kind || !id) return undefined + const actor = str(e.displayName) ?? str(e.actorId) + return { + kind, id, details: traceDetails(e), + ...(str(e.nodeId) ? { nodeId: e.nodeId } : {}), + ...(str(e.treeNodeId) ? { treeNodeId: e.treeNodeId } : {}), + ...(actor ? { actor } : {}), + ...(str(e.at) ? { at: e.at } : {}), + } +} + +function traceBody(raw: unknown): string | undefined { + const e = obj(raw) + for (const key of TRACE_BODY_KEYS) { + const text = displayText(e?.[key], 500) + if (text) return text + } + return undefined +} + +export function normalizeRenderTrace(raw: unknown): RenderTrace { + const r = obj(raw) + const entries: RenderTraceEntry[] = [] + for (const item of arr(r?.items)) { + const o = obj(item) + if (o?.layer !== 'render') continue + const rawType = str(o.type) + const from = traceEndpoint(o.from) + const to = traceEndpoint(o.to) + if (!rawType || !from || !to) continue + const masked = o.contentMasked === true + const body = masked ? undefined : traceBody(o.to) + entries.push({ type: isTraceType(rawType) ? rawType : 'other', rawType, from, to, masked, ...(body ? { body } : {}) }) + } + return { entries, masked: entries.some(e => e.masked), truncated: r?.truncated === true } +} + +/** 条目的主体端点与可定位的树节点:anchored-to 的 to.id 即树节点 id(不回退规范 id);批注/修订条目 treeNodeId 优先、nodeId 回退。 */ +export function traceAnchor(e: RenderTraceEntry): { subject: TraceEndpoint; target?: { treeNodeId?: string; nodeId?: string } } { + switch (e.type) { + case 'anchored-to': + return { subject: e.from, ...(e.to.kind === 'document-node' ? { target: { treeNodeId: e.to.id } } : {}) } + case 'annotated': + case 'revised': + return { subject: e.to, target: { treeNodeId: e.to.treeNodeId, nodeId: e.to.nodeId } } + case 'selected-profile': + case 'profile-binding-changed': + case 'delivered': + case 'rendered-from': + case 'rendered-with': + case 'reviewed-by': + case 'cites': + case 'restricted-basis': + case 'submitted': + case 'signed-off': + case 'other': + return { subject: e.to } + default: { + const exhaustive: never = e.type + throw new Error(`unknown trace type: ${String(exhaustive)}`) + } + } +} + +export function traceTypeLabel(e: RenderTraceEntry): string { + return e.type === 'other' ? e.rawType : RENDER_TRACE_TYPE_LABELS[e.type] +} + +export function traceEndpointLabel(p: TraceEndpoint): string { + const kind = TRACE_KIND_LABELS[p.kind] ?? p.kind + return p.kind === 'restricted-source' ? '存在受限依据' : `${kind} ${truncateMiddle(p.id, 24)}` +} + // ---- 交付记录与下载 ---- export interface RenderDelivery { diff --git a/src/styles.css b/src/styles.css index afc907d..1c38468 100644 --- a/src/styles.css +++ b/src/styles.css @@ -748,6 +748,13 @@ h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); } .drw-anno-replies { margin-top: 6px; padding-left: 12px; border-left: 2px solid var(--border-soft); display: flex; flex-direction: column; gap: 6px; } .drw-anno-orphan-note { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 4px; } .drw-rev .drw-diff-del, .drw-rev .drw-diff-add { margin-top: 4px; } +.drw-traces { display: flex; flex-direction: column; gap: 6px; } +.drw-trace { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 6px 8px; border-left: 2px solid var(--border); font-size: var(--fs-ui-sm); } +.drw-trace.masked { border-left-style: dashed; } +.drw-trace-type { font-weight: 600; color: var(--text); } +.drw-trace-subject { min-width: 0; color: var(--text-dim); overflow-wrap: anywhere; } +.drw-trace-masked { padding: 0 6px; border: 1px dashed var(--border-strong); border-radius: var(--radius-xs); color: var(--text-dim); } +.drw-trace-body { flex-basis: 100%; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; } .drw-decisions { display: flex; flex-direction: column; gap: 4px; } .drw-decision { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); } .drw-downloads { display: flex; gap: 6px; flex-wrap: wrap; } diff --git a/test/document-render-workspace.test.ts b/test/document-render-workspace.test.ts index 81f515a..07d13e6 100644 --- a/test/document-render-workspace.test.ts +++ b/test/document-render-workspace.test.ts @@ -11,14 +11,15 @@ import { nextOperation, createRequestGuard, renderScope, buildDocTree, visibleNodeIds, visibleOrder, layoutMindMap, moveSelection, nodeAnchorId, nodeFacts, hasFacts, - normalizeReviewView, partitionAnnotations, annotatePayload, revisePayload, validateReviewText, + normalizeReviewView, partitionAnnotations, partitionRevisions, annotatePayload, revisePayload, validateReviewText, + locateNodeId, reviewTargetNode, normalizeRenderTrace, traceAnchor, traceTypeLabel, traceEndpointLabel, normalizeDeliveries, mergeDeliveries, downloadOptions, normalizeDownload, decodeBase64, truncateMiddle, latestSubmissionId, formatRenderTime, submissionIdOf, currentSubmissionId, loadDeliveryDiagnostics, receiptStateView, walkDocTree, DOC_TREE_LIMITS, type RenderProfiles, type DocTree, } from '../src/documentRenderPresentation' import { - ProfileOptionList, DiagnosticsList, AnnotationGroups, DownloadButtons, RenderReceipt, + ProfileOptionList, DiagnosticsList, AnnotationGroups, RevisionGroups, RenderTraceList, DownloadButtons, RenderReceipt, } from '../src/components/DocumentRenderWorkspace' import { RenderFailure } from '../src/components/renderFeedback' @@ -674,13 +675,13 @@ describe('审阅:批注、修订与载荷', () => { expect(html.indexOf('第一章')).toBeLessThan(html.indexOf('无法定位')) }) it('载荷只含业务字段,身份与时间由服务端填写', () => { - expect(annotatePayload({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: ' 好 ', operationId: 'op_1' })) + expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', text: ' 好 ', operationId: 'op_1' })) .toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '好', operationId: 'op_1' }) - expect(annotatePayload({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '回', operationId: 'op_2', replyTo: 'a1' })) + expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', text: '回', operationId: 'op_2', replyTo: 'a1' })) .toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', text: '回', operationId: 'op_2', replyTo: 'a1' }) - expect(revisePayload({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'insert', after: '新增', operationId: 'op_3' })) + expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', kind: 'insert', after: '新增', operationId: 'op_3' })) .toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'insert', after: '新增', operationId: 'op_3' }) - expect(revisePayload({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'replace', before: 'a', after: 'b', operationId: 'op_4' })) + expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 's1', kind: 'replace', before: 'a', after: 'b', operationId: 'op_4' })) .toEqual({ projectId: 'p', submissionId: 'sub', nodeId: 's1', kind: 'replace', before: 'a', after: 'b', operationId: 'op_4' }) }) it('文本校验', () => { @@ -690,6 +691,153 @@ describe('审阅:批注、修订与载荷', () => { }) }) +describe('审阅按工作区树节点分组与定位(treeNodeId 优先,nodeId 回退)', () => { + // RDM 契约:nodeId 恒为渲染器规范节点(pp.…),treeNodeId 为审阅人所选树节点(dn-/sec-/legacy-)。 + const wsTree = buildDocTree({ id: 'doc1', title: '文档' }, [ + { kind: 'section', id: 'dn-head', title: '项目内容、目标及意义' }, + { kind: 'field', id: 'dn-leaf', title: '目标', parentId: 'dn-head' }, + { kind: 'section', id: 'sec-2', title: '第二章' }, + ]) + const known = new Set(wsTree.order) + const person = { id: 'u1', displayName: '审核人' } + const view = normalizeReviewView({ + annotations: [ + { id: 'a1', nodeId: 'pp.goal.value', treeNodeId: 'dn-head', author: person, createdAt: '', text: '按所选标题', replies: [] }, + { id: 'a2', nodeId: 'pp.goal.leaf', treeNodeId: 'dn-head', author: person, createdAt: '', text: '同一树节点', replies: [] }, + { id: 'a3', nodeId: 'sec-2', author: person, createdAt: '', text: '旧条目只有 nodeId', replies: [] }, + { id: 'a4', nodeId: 'dn-leaf', treeNodeId: 'dn-gone', author: person, createdAt: '', text: 'treeNodeId 不在树中回退 nodeId', replies: [] }, + { id: 'a5', nodeId: 'pp.x', treeNodeId: 'dn-gone', author: person, createdAt: '', text: '都不在树中', replies: [] }, + { id: 'a6', nodeId: 'pp.y', treeNodeId: 'dn-head', author: person, createdAt: '', text: '服务端标孤立', replies: [], orphaned: true }, + { id: 'a7', nodeId: 'pp.z', treeNodeId: '', author: person, createdAt: '', text: '空 treeNodeId 不保留', replies: [] }, + ], + revisions: [ + { id: 'v1', nodeId: 'pp.goal.value', treeNodeId: 'dn-leaf', kind: 'replace', before: '旧', after: '新', author: person, createdAt: '' }, + { id: 'v2', nodeId: 'pp.unknown', kind: 'insert', after: '无处可挂', author: person, createdAt: '' }, + ], + }) + + it('归一化保留 treeNodeId,nodeId 仍为规范节点', () => { + expect(view.annotations[0]).toMatchObject({ nodeId: 'pp.goal.value', treeNodeId: 'dn-head' }) + expect(view.annotations.find(a => a.id === 'a7')).not.toHaveProperty('treeNodeId') + expect(view.revisions[0]).toMatchObject({ nodeId: 'pp.goal.value', treeNodeId: 'dn-leaf' }) + }) + it('批注按 treeNodeId 分组,缺省或不在树中回退 nodeId,都不在或孤立进入无法定位', () => { + const { byNode, orphaned } = partitionAnnotations(view.annotations, known) + expect([...byNode.keys()]).toEqual(['dn-head', 'sec-2', 'dn-leaf']) + expect(byNode.get('dn-head')?.map(a => a.id)).toEqual(['a1', 'a2']) + expect(byNode.get('dn-leaf')?.map(a => a.id)).toEqual(['a4']) + expect(orphaned.map(a => a.id)).toEqual(['a5', 'a6', 'a7']) + }) + it('修订同规则分组', () => { + const { byNode, orphaned } = partitionRevisions(view.revisions, known) + expect([...byNode.keys()]).toEqual(['dn-leaf']) + expect(orphaned.map(r => r.id)).toEqual(['v2']) + }) + it('分组标题是树节点名并带定位目标(点击由工作区滚动到该节点)', () => { + const nodeTitle = (id: string) => wsTree.nodes[id]?.title + const anno = partitionAnnotations(view.annotations, known) + const html = renderToStaticMarkup(createElement(AnnotationGroups, { ...anno, nodeTitle, onSelectNode: () => {}, onReply: () => {} })) + expect(html).toContain('data-locate="dn-head"') + expect(html).toContain('项目内容、目标及意义') + expect(html).not.toContain('pp.goal.value') + const rev = partitionRevisions(view.revisions, known) + const revHtml = renderToStaticMarkup(createElement(RevisionGroups, { ...rev, total: view.revisions.length, nodeTitle, onSelectNode: () => {} })) + expect(revHtml).toContain('data-locate="dn-leaf"') + expect(revHtml).toContain('修订(2)') + expect(revHtml).toContain('无法定位(1)') + }) + it('从树上批注/修订发送所选树节点 id;文档根不是审阅目标', () => { + const target = reviewTargetNode(wsTree, 'dn-leaf') + expect(target?.id).toBe('dn-leaf') + expect(reviewTargetNode(wsTree, 'doc1')).toBeUndefined() + expect(reviewTargetNode(wsTree, null)).toBeUndefined() + expect(reviewTargetNode(null, 'dn-leaf')).toBeUndefined() + expect(annotatePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: target!.id, text: '批', operationId: 'op' }).nodeId).toBe('dn-leaf') + expect(revisePayload({ projectId: 'p', submissionId: 'sub', treeNodeId: 'sec-2', kind: 'delete', before: '删', operationId: 'op' }).nodeId).toBe('sec-2') + }) + it('locateNodeId 与诊断定位同规则', () => { + expect(locateNodeId({ treeNodeId: 'dn-head', nodeId: 'sec-2' }, id => known.has(id))).toBe('dn-head') + expect(locateNodeId({ treeNodeId: 'dn-gone', nodeId: 'sec-2' }, id => known.has(id))).toBe('sec-2') + expect(locateNodeId({ nodeId: 'pp.x' }, id => known.has(id))).toBeUndefined() + }) +}) + +describe('渲染链条(rdm.inspect.relations layer=render)', () => { + const wsTree = buildDocTree({ id: 'doc1', title: '文档' }, [ + { kind: 'section', id: 'dn-head', title: '项目内容、目标及意义' }, + { kind: 'section', id: 'sec-2', title: '第二章' }, + ]) + const nodeTitle = (id: string) => wsTree.nodes[id]?.title + const sub = { kind: 'render-submission', id: 'sub-1' } + const full = normalizeRenderTrace({ + items: [ + { layer: 'general', type: 'derives', from: { kind: 'section', id: 'a' }, to: { kind: 'section', id: 'b' } }, + { layer: 'render', type: 'selected-profile', from: { kind: 'doc', id: 'doc1' }, to: { kind: 'render-profile', id: 'company.dhf@1', source: 'template' } }, + { layer: 'render', type: 'annotated', from: sub, to: { kind: 'review-annotation', id: 'ann-1', nodeId: 'pp.goal.value', treeNodeId: 'dn-head', displayName: '审核人', at: '2026-10-17T00:00:00Z', text: '请补充' } }, + { layer: 'render', type: 'anchored-to', from: { kind: 'review-annotation', id: 'ann-1' }, to: { kind: 'document-node', id: 'dn-head' } }, + { layer: 'render', type: 'anchored-to', from: { kind: 'review-annotation', id: 'ann-2' }, to: { kind: 'document-node', id: 'dn-gone' } }, + { layer: 'render', type: 'revised', from: sub, to: { kind: 'review-revision', id: 'rev-1', nodeId: 'sec-2', revisionKind: 'replace' } }, + { layer: 'render', type: 'restricted-basis', from: { kind: 'render-delivery', id: 'd1' }, to: { kind: 'restricted-source', id: 'restricted' } }, + { layer: 'render', type: 'future-edge', from: { kind: 'render-delivery', id: 'd1' }, to: { kind: 'x', id: 'y' } }, + { layer: 'render', type: 'delivered', from: { kind: 'doc', id: 'doc1' } }, + ], + truncated: true, + }) + + it('只取 layer=render 且端点完整的条目,未知类型保留原名', () => { + expect(full.entries.map(e => e.rawType)).toEqual(['selected-profile', 'annotated', 'anchored-to', 'anchored-to', 'revised', 'restricted-basis', 'future-edge']) + expect(full.entries[6].type).toBe('other') + expect(traceTypeLabel(full.entries[6])).toBe('future-edge') + expect(full.truncated).toBe(true) + expect(full.masked).toBe(false) + expect(normalizeRenderTrace(null)).toEqual({ entries: [], masked: false, truncated: false }) + }) + it('anchored-to 的目标是树节点 id(不回退规范 id);批注/修订 treeNodeId 优先、nodeId 回退', () => { + const [, annotated, anchored, anchoredGone, revised, restricted] = full.entries + expect(traceAnchor(anchored)).toMatchObject({ subject: { kind: 'review-annotation', id: 'ann-1' }, target: { treeNodeId: 'dn-head' } }) + expect(traceAnchor(anchored).target).not.toHaveProperty('nodeId') + expect(traceAnchor(anchoredGone).target).toEqual({ treeNodeId: 'dn-gone' }) + expect(traceAnchor(annotated).target).toEqual({ treeNodeId: 'dn-head', nodeId: 'pp.goal.value' }) + expect(traceAnchor(revised).target).toEqual({ treeNodeId: undefined, nodeId: 'sec-2' }) + expect(traceAnchor(restricted).target).toBeUndefined() + expect(traceEndpointLabel(restricted.to)).toBe('存在受限依据') + }) + it('渲染:已定位的树节点可点击定位,未知树节点显示未定位', () => { + const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: full, nodeTitle, onSelectNode: () => {} })) + expect(html).toContain('批注锚定') + expect(html.match(/data-locate="dn-head"/g)).toHaveLength(2) + expect(html).toContain('data-locate="sec-2"') + expect(html).toContain('未定位节点') + expect(html).not.toContain('dn-gone"') + expect(html).toContain('模板规则') + expect(html).toContain('替换') + expect(html).toContain('请补充') + expect(html).toContain('已截断') + expect(html).not.toContain('内容已遮蔽') + }) + it('遮蔽条目只显示元数据:标「内容已遮蔽」,正文字段即使误发也不读取', () => { + const masked = normalizeRenderTrace({ + items: [ + { layer: 'render', type: 'annotated', contentMasked: true, from: sub, to: { kind: 'review-annotation', id: 'ann-1', treeNodeId: 'dn-head', displayName: '审核人', text: 'SECRET-TEXT', excerpt: 'SECRET-EXCERPT' } }, + { layer: 'render', type: 'revised', contentMasked: true, from: sub, to: { kind: 'review-revision', id: 'rev-1', treeNodeId: 'sec-2', revisionKind: 'replace', before: 'SECRET-BEFORE', after: 'SECRET-AFTER' } }, + { layer: 'render', type: 'anchored-to', contentMasked: true, from: { kind: 'review-annotation', id: 'ann-1' }, to: { kind: 'document-node', id: 'dn-head' } }, + ], + }) + expect(masked.masked).toBe(true) + expect(masked.entries.every(e => e.masked && e.body === undefined)).toBe(true) + const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: masked, nodeTitle, onSelectNode: () => {} })) + expect(html.match(/内容已遮蔽/g)).toHaveLength(3) + expect(html).toContain('正文、批注与修订文字已遮蔽') + expect(html).toContain('审核人') + expect(html).toContain('data-locate="dn-head"') + expect(html).not.toContain('SECRET') + }) + it('空链条有明确空态', () => { + const html = renderToStaticMarkup(createElement(RenderTraceList, { trace: normalizeRenderTrace({ items: [] }), nodeTitle, onSelectNode: () => {} })) + expect(html).toContain('暂无渲染链条记录') + }) +}) + describe('交付记录与下载', () => { const page = normalizeDeliveries({ items: [