Locate review items and render trace by tree node id and mask audit-only trace

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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-10-18 00:34:04 +00:00
co-authored by Cursor
parent 6727cdd30f
commit 12553f53eb
4 changed files with 443 additions and 53 deletions
+107 -31
View File
@@ -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<string, ReviewAnnotation[]>
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 <div className="empty-note">暂无批注</div>
return (
<div className="drw-annos">
{[...byNode].map(([nodeId, list]) => (
<div key={nodeId} className="drw-anno-group">
<button type="button" className="drw-anno-node" onClick={() => onSelectNode(nodeId)}>{nodeTitle(nodeId) ?? nodeId}</button>
{list.map(a => <AnnotationItem key={a.id} a={a} onReply={onReply} />)}
<button type="button" className="drw-anno-node" data-locate={nodeId} onClick={() => onSelectNode(nodeId)}>{nodeTitle(nodeId) ?? nodeId}</button>
{list.map(a => <AnnotationItem key={a.id} a={a} onReply={onReply && (x => onReply(x, nodeId))} />)}
</div>
))}
{orphaned.length > 0 && (
@@ -231,6 +233,81 @@ function RevisionItem({ r }: { r: ReviewRevision }) {
)
}
export function RevisionGroups({ byNode, orphaned, total, nodeTitle, onSelectNode }: {
byNode: Map<string, ReviewRevision[]>
orphaned: ReviewRevision[]
total: number
nodeTitle: (id: string) => string | undefined
onSelectNode: (id: string) => void
}) {
if (byNode.size === 0 && orphaned.length === 0) return null
return (
<Collapsible id="drw-revisions" defaultOpen triggerClassName="drw-group-trigger"
trigger={<span className="drw-group-label">修订({total})</span>}>
{[...byNode].map(([nodeId, list]) => (
<div key={nodeId} className="drw-anno-group">
<button type="button" className="drw-anno-node" data-locate={nodeId} onClick={() => onSelectNode(nodeId)}>{nodeTitle(nodeId) ?? nodeId}</button>
{list.map(r => <RevisionItem key={r.id} r={r} />)}
</div>
))}
{orphaned.length > 0 && (
<div className="drw-anno-group">
<span className="drw-group-label">无法定位({orphaned.length})</span>
{orphaned.map(r => <RevisionItem key={r.id} r={r} />)}
</div>
)}
</Collapsible>
)
}
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 (
<li className={`drw-trace${entry.masked ? ' masked' : ''}`}>
<span className="drw-trace-type">{traceTypeLabel(entry)}</span>
<span className="drw-trace-subject" title={subject.id}>{traceEndpointLabel(subject)}</span>
{subject.details.map(d => <Chip key={d} tone="plain">{d}</Chip>)}
{target && (at
? <button type="button" className="drw-diag-node" data-locate={at} title={nodeTitle(at)} onClick={() => onSelectNode(at)}>{nodeTitle(at)}</button>
: <span className="drw-diag-node unlocated">未定位节点</span>)}
{entry.masked && <span className="drw-trace-masked">内容已遮蔽</span>}
{(subject.actor || subject.at) && <Byline name={subject.actor ?? ''} at={subject.at ?? ''} />}
{entry.body && <span className="drw-trace-body">{entry.body}</span>}
</li>
)
}
/** 渲染链条:遮蔽条目只显示元数据并标「内容已遮蔽」;批注锚定与批注/修订按树节点定位,未知节点显示为未定位。 */
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 <div className="empty-note">暂无渲染链条记录</div>
return (
<div className="drw-traces">
{trace.masked && <div className="drw-notice">当前身份只能查看渲染链条元数据,正文、批注与修订文字已遮蔽</div>}
<ul className="drw-diag-list">
{trace.entries.slice(0, shown).map((e, i) => <TraceRow key={`${e.rawType}-${e.to.id}-${i}`} entry={e} nodeTitle={nodeTitle} onSelectNode={onSelectNode} />)}
{trace.entries.length > shown && (
<li><button type="button" className="btn v-ghost drw-more" onClick={() => setShown(s => s + TRACE_PAGE)}>
显示更多(还有 {trace.entries.length - shown} 条)
</button></li>
)}
</ul>
{trace.truncated && <div className="drw-muted">已截断(数量上限)</div>}
</div>
)
}
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<string, unknown> : {})
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
</div>
{review.error ? <RenderFailure error={review.error} id="review" /> : !review.data ? <div className="empty-note">加载中…</div> : (
<>
<AnnotationGroups byNode={annotations.byNode} orphaned={annotations.orphaned} nodeTitle={nodeTitle} onSelectNode={selectNode}
onReply={caps.annotate ? a => { setReplyTo(a); selectNode(a.nodeId) } : undefined} />
{(revisions.byNode.size > 0 || revisions.orphaned.length > 0) && (
<Collapsible id="drw-revisions" defaultOpen triggerClassName="drw-group-trigger"
trigger={<span className="drw-group-label">修订({review.data.revisions.length})</span>}>
{[...revisions.byNode].map(([nodeId, list]) => (
<div key={nodeId} className="drw-anno-group">
<button type="button" className="drw-anno-node" onClick={() => selectNode(nodeId)}>{nodeTitle(nodeId) ?? nodeId}</button>
{list.map(r => <RevisionItem key={r.id} r={r} />)}
</div>
))}
{revisions.orphaned.length > 0 && (
<div className="drw-anno-group">
<span className="drw-group-label">无法定位({revisions.orphaned.length})</span>
{revisions.orphaned.map(r => <RevisionItem key={r.id} r={r} />)}
</div>
)}
</Collapsible>
)}
<AnnotationGroups byNode={annotations.byNode} orphaned={annotations.orphaned} nodeTitle={nodeTitle} onSelectNode={revealNode}
onReply={caps.annotate ? (a, treeNodeId) => { setReplyTo(a); revealNode(treeNodeId) } : undefined} />
<RevisionGroups byNode={revisions.byNode} orphaned={revisions.orphaned} total={review.data.revisions.length}
nodeTitle={nodeTitle} onSelectNode={revealNode} />
{review.data.decisions.length > 0 && (
<div className="drw-decisions">
{review.data.decisions.map((d, i) => (
@@ -942,6 +1009,15 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
</section>
)}
<section className="drw-panel">
<div className="drw-row drw-row-between">
<SectionLabel>渲染链条</SectionLabel>
<button type="button" className="drw-link" onClick={trace.reload}>刷新</button>
</div>
{trace.error ? <RenderFailure error={trace.error} id="trace" /> : !trace.data ? <div className="empty-note">加载中…</div>
: <RenderTraceList trace={trace.data} nodeTitle={nodeTitle} onSelectNode={revealNode} />}
</section>
<section className="drw-panel">
<div className="drw-row drw-row-between">
<SectionLabel>交付记录</SectionLabel>
+175 -16
View File
@@ -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<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 = 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<T extends { nodeId: string; orphaned?: boolean }>(list: T[], known: Set<string>) {
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) {
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<string>) {
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<string, unknown> {
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<string, unknown> {
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<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 {
+7
View File
@@ -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; }
+154 -6
View File
@@ -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: [