diff --git a/e2e/document-renderer.playwright.ts b/e2e/document-renderer.playwright.ts
index fea5c8d..0c8769b 100644
--- a/e2e/document-renderer.playwright.ts
+++ b/e2e/document-renderer.playwright.ts
@@ -279,8 +279,11 @@ test('§11-3 受控文档:规则来源、版本与不回退说明', async ({ b
await expect(option.locator('.drw-profile-source')).toHaveText('模板规则')
await expect(option.locator('.drw-profile-id')).toHaveText(TEMPLATE_PROFILE)
await expect(option.locator('.drw-profile-ver')).toHaveText(`v${env('RBA_TEMPLATE_VERSION')}`)
- await expect(option.locator('.ui-chip').first()).toHaveText('已验证')
- await expect(option.locator('.drw-profile-reason')).toContainText('未发布')
+ // 44 个 DHF 专用规则均已发布(state=published、publicationAllowed=true):状态为已发布,可正式交付,无未发布原因。
+ await expect(option.locator('.ui-chip').first()).toHaveText('已发布')
+ await expect(option.locator('.ui-chip', { hasText: '可正式交付' })).toBeVisible()
+ await expect(option.locator('input[type="radio"]')).toBeEnabled()
+ await expect(option.locator('.drw-profile-reason')).toHaveCount(0)
await expect(rules.locator('.drw-notice')).toContainText('不会回退到项目或公司默认规则')
await expect(rules.locator('.drw-failure-msg')).toContainText('不得回退到项目或公司默认规则')
await expectReadableFailures(page)
diff --git a/src/components/DocumentRenderWorkspace.tsx b/src/components/DocumentRenderWorkspace.tsx
index c9b839d..2c90f79 100644
--- a/src/components/DocumentRenderWorkspace.tsx
+++ b/src/components/DocumentRenderWorkspace.tsx
@@ -13,7 +13,7 @@ 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, mergeDeliveries, moveSelection,
+ groupDiagnostics, hasFacts, layoutMindMap, loadDeliveryDiagnostics, locateDiagnostic, mergeDeliveries, moveSelection,
nextOperation, nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDownload, normalizeProfiles,
normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, receiptStateView, renderScope,
revisePayload, submissionIdOf, truncateMiddle, validateReviewText, visibleNodeIds, visibleOrder, walkDocTree,
@@ -88,11 +88,11 @@ function DiagnosticGroupBody({ items, nodeTitle, onSelectNode }: {
return (
{items.slice(0, shown).map((d, i) => {
- const title = d.nodeId ? nodeTitle(d.nodeId) : undefined
+ const at = locateDiagnostic(d, nodeTitle)
return (
-
- {d.nodeId && title ? (
-
+ {at ? (
+
) : 未定位节点}
{d.message}
{d.code && {diagnosticCodeLabel(d.code)}}
@@ -446,9 +446,11 @@ interface ViewProps {
onSelect: (id: string) => void
badges: (id: string) => ReactNode
facts: Record
+ /** 侧栏定位(诊断)每次点击递增;选中未变也重新滚动到节点。 */
+ revealSeq: number
}
-function useFocusSelected(root: React.RefObject, selectedId: string | null) {
+function useFocusSelected(root: React.RefObject, selectedId: string | null, revealSeq: number) {
const wantFocus = useRef(false)
useEffect(() => {
if (!selectedId || !root.current) return
@@ -456,13 +458,13 @@ function useFocusSelected(root: React.RefObject, selected
if (!el) return
el.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
if (wantFocus.current) { el.focus({ preventScroll: true }); wantFocus.current = false }
- }, [root, selectedId])
+ }, [root, selectedId, revealSeq])
return wantFocus
}
-function StructureCanvas({ tree, order, visible, selectedId, onSelect, badges }: ViewProps) {
+function StructureCanvas({ tree, order, visible, selectedId, onSelect, badges, revealSeq }: ViewProps) {
const ref = useRef(null)
- const wantFocus = useFocusSelected(ref, selectedId)
+ const wantFocus = useFocusSelected(ref, selectedId, revealSeq)
const layout = useMemo(() => layoutMindMap(tree, visible), [tree, visible])
const pan = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
const [panning, setPanning] = useState(false)
@@ -514,9 +516,9 @@ function StructureCanvas({ tree, order, visible, selectedId, onSelect, badges }:
)
}
-function ReaderView({ tree, order, visible, selectedId, onSelect, badges, facts }: ViewProps) {
+function ReaderView({ tree, order, visible, selectedId, onSelect, badges, facts, revealSeq }: ViewProps) {
const ref = useRef(null)
- const wantFocus = useFocusSelected(ref, selectedId)
+ const wantFocus = useFocusSelected(ref, selectedId, revealSeq)
const onKey = (e: KeyboardEvent) => {
const next = moveSelection(tree, order, selectedId, e.key)
if (next === null) return
@@ -639,6 +641,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
const scope = renderScope(identity, projectId, docId)
const [view, setView] = useState<'canvas' | 'reader'>('canvas')
const [selectedId, setSelectedId] = useState(null)
+ const [revealSeq, setRevealSeq] = useState(0)
const [filter, setFilter] = useState({ text: '', mode: 'all' })
const [profileKeyChoice, setProfileKeyChoice] = useState(null)
const [submissionChoice, setSubmissionChoice] = useState(null)
@@ -688,9 +691,8 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
const diagState = receiptDiagnostics.state
const diagnostics = useMemo(() => (diagState?.status === 'ready' ? diagState.items : []), [diagState])
- const diagCounts = useMemo(() => diagnosticCountsByNode(diagnostics), [diagnostics])
-
const knownIds = useMemo(() => new Set(tree ? tree.order : []), [tree])
+ const diagCounts = useMemo(() => diagnosticCountsByNode(diagnostics, id => knownIds.has(id)), [diagnostics, knownIds])
const annotations = useMemo(() => partitionAnnotations(review.data?.annotations ?? [], knownIds), [review.data, knownIds])
const revisions = useMemo(() => partitionRevisions(review.data?.revisions ?? [], knownIds), [review.data, knownIds])
@@ -706,6 +708,10 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
if (tree && !visible.has(id)) setFilter({ text: '', mode: 'all' })
setSelectedId(id)
}
+ const revealNode = (id: string) => {
+ selectNode(id)
+ setRevealSeq(n => n + 1)
+ }
const badges = (id: string) => {
const d = diagCounts[id]
const notes = (annotations.byNode.get(id)?.length ?? 0) + (revisions.byNode.get(id)?.length ?? 0)
@@ -814,8 +820,8 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
{tree.order.length === 1 &&
该文档暂无章节
}
{order.length <= 1 && tree.order.length > 1 && 没有符合过滤条件的节点
}
{view === 'canvas'
- ?
- : }
+ ?
+ : }
{selectedNode && (
当前节点
@@ -869,7 +875,7 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
{renderOp.state.status === 'done' && renderOp.state.op && renderOp.state.op.kind !== 'preview' && (
{OP_LABELS[renderOp.state.op.kind]}已完成
)}
- {receipt && diagState &&
}
+ {receipt && diagState &&
}
{caps.review && (
diff --git a/src/documentRenderPresentation.ts b/src/documentRenderPresentation.ts
index 1870e93..3a44633 100644
--- a/src/documentRenderPresentation.ts
+++ b/src/documentRenderPresentation.ts
@@ -261,7 +261,10 @@ 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
}
@@ -282,6 +285,7 @@ export function normalizeDiagnostics(raw: unknown): RenderDiagnostic[] {
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 } : {}),
})
}
@@ -319,11 +323,22 @@ export function diagnosticsSummaryText(s: { errors: number; warnings: number }):
return parts.length ? parts.join(' · ') : '无错误或警告'
}
-export function diagnosticCountsByNode(list: RenderDiagnostic[]): Record
{
+/** 诊断定位到工作区树节点:先 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 }
+ }
+ return undefined
+}
+
+export function diagnosticCountsByNode(list: RenderDiagnostic[], isKnown: (id: string) => boolean = () => true): Record {
const out: Record = {}
for (const d of list) {
- if (!d.nodeId || d.severity === 'info') continue
- const c = out[d.nodeId] ?? (out[d.nodeId] = { errors: 0, warnings: 0 })
+ if (d.severity === 'info') continue
+ const id = locateDiagnostic(d, x => (isKnown(x) ? x : undefined))?.id
+ if (!id) continue
+ const c = out[id] ?? (out[id] = { errors: 0, warnings: 0 })
if (d.severity === 'error') c.errors++
else c.warnings++
}
diff --git a/test/document-render-workspace.test.ts b/test/document-render-workspace.test.ts
index d5289c0..81f515a 100644
--- a/test/document-render-workspace.test.ts
+++ b/test/document-render-workspace.test.ts
@@ -7,7 +7,7 @@ import {
RDM_RENDER_COMMANDS, renderCapabilities,
normalizeProfiles, profileOptionViews, controlledNotice, defaultProfileKey, profileKey,
describeRenderFailure, displayText,
- normalizeDiagnostics, groupDiagnostics, diagnosticsSummaryText, diagnosticCountsByNode,
+ normalizeDiagnostics, groupDiagnostics, diagnosticsSummaryText, diagnosticCountsByNode, locateDiagnostic,
nextOperation, createRequestGuard, renderScope,
buildDocTree, visibleNodeIds, visibleOrder, layoutMindMap, moveSelection, nodeAnchorId,
nodeFacts, hasFacts,
@@ -451,6 +451,53 @@ describe('预览诊断', () => {
})
})
+describe('诊断定位:treeNodeId 优先,nodeId 回退', () => {
+ // 渲染器 nodeId 是 canonical/映射节点 id;RDM 另给工作区树节点 id treeNodeId。
+ const known = new Set(['dn-cover', 's1'])
+ const titles: Record = { 'dn-cover': '封面', s1: '第一章' }
+ const nodeTitle = (id: string) => (known.has(id) ? titles[id] : undefined)
+ const raw = [
+ { code: 'required_content_missing', severity: 'warning', message: '缺少文档编号', nodeId: 'pp.cover.document-number.value', treeNodeId: 'dn-cover' },
+ { code: 'W', severity: 'warning', message: '旧载荷:只有树节点 nodeId', nodeId: 's1' },
+ { code: 'W', severity: 'error', message: 'treeNodeId 不在树中时回退 nodeId', nodeId: 's1', treeNodeId: 'dn-gone' },
+ { code: 'W', severity: 'warning', message: '只有映射 id', nodeId: 'pp.revision.entry.c01' },
+ { code: 'W', severity: 'warning', message: '两者都不在树中', nodeId: 'pp.x', treeNodeId: 'dn-gone' },
+ { code: 'W', severity: 'info', message: '无节点' },
+ ]
+ const list = normalizeDiagnostics(raw)
+
+ it('归一化保留 treeNodeId,空串与非字符串丢弃', () => {
+ expect(list[0]).toMatchObject({ nodeId: 'pp.cover.document-number.value', treeNodeId: 'dn-cover' })
+ expect(list[1].treeNodeId).toBeUndefined()
+ const odd = normalizeDiagnostics([{ message: 'm', treeNodeId: '' }, { message: 'm', treeNodeId: 7 }])
+ expect(odd.every(d => !('treeNodeId' in d))).toBe(true)
+ })
+ it('locateDiagnostic:树节点 id 优先,缺省或未知时回退 nodeId,都未知则未定位', () => {
+ expect(list.map(d => locateDiagnostic(d, nodeTitle))).toEqual([
+ { id: 'dn-cover', title: '封面' },
+ { id: 's1', title: '第一章' },
+ { id: 's1', title: '第一章' },
+ undefined,
+ undefined,
+ undefined,
+ ])
+ })
+ it('节点计数按定位到的树节点归档;不在树中的不计入任何节点', () => {
+ const counts = diagnosticCountsByNode(list, id => known.has(id))
+ expect(counts).toEqual({ 'dn-cover': { errors: 0, warnings: 1 }, s1: { errors: 1, warnings: 1 } })
+ })
+ it('诊断列表:两种 id 形式都可点击定位,未定位的明确标出', () => {
+ // 只有首组默认展开(SSR 不渲染折叠组内容),因此只取警告组。
+ const warnings = list.filter(d => d.severity === 'warning')
+ const html = renderToStaticMarkup(createElement(DiagnosticsList, { diagnostics: warnings, nodeTitle, onSelectNode: () => {}, idPrefix: 'loc' }))
+ expect(html).toContain('title="封面"')
+ expect(html).toContain('title="第一章"')
+ expect(html.match(/