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

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

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

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

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

1161 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 统一渲染 Task 11:文档阅读与交付工作区(rdm.inspect.* 读结构 + rdm.render.* 生成/审阅/下载)。
// 结构画布与阅读视图共用同一选中节点与同一过滤状态;每个异步状态只有一个所有者,
// 响应按请求守卫与身份 epoch 校验,过期即丢弃。令牌驱动(DESIGN.md):折叠只用 Collapsible。
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react'
import { Btn, Chip, Collapsible, SectionLabel } from '@ui'
import { api } from '../api'
import { store, useStore } from '../store'
import { permCheckPayload } from '../rdmManagement'
import { commitDecision, hostProjectFor } from '../documentDecision'
import { ReviewDecisionPanel, ProfileBindingPanel, ACTION_OP_LABELS } from './DocumentRenderActions'
import { OperationStatus, RenderFailure, type OperationState } from './renderFeedback'
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, locateNodeId, mergeDeliveries, moveSelection,
nextOperation, nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDownload, normalizeProfiles, normalizeRenderTrace,
normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, receiptStateView, renderScope,
reviewTargetNode, revisePayload, selectionAnchor, 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 RenderTrace,
type RenderTraceEntry, type ReviewAnnotation, type ReviewRevision, type SelectionHit, type TextAnchor,
} from '../documentRenderPresentation'
// ---- 文件保存(交付物与审计导出共用;类型取服务端 contentType,不在客户端重算校验) ----
export function saveBase64File(fileName: string, contentType: string, dataBase64: string) {
const blob = new Blob([decodeBase64(dataBase64)], { type: contentType || 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}
// ---- 可复用展示件(纯 props;测试直接 SSR) ----
function FileName({ name }: { name: string }) {
return <span className="drw-filename" title={name}>{truncateMiddle(name, 36)}</span>
}
export function ProfileOptionList({ profiles, selectedKey, onSelect }: {
profiles: RenderProfiles
selectedKey: string
onSelect: (key: string) => void
}) {
const views = profileOptionViews(profiles)
const notice = controlledNotice(profiles)
return (
<div className="drw-profiles">
{profiles.refusal && <RenderFailure error={profiles.refusal} id="profiles-refusal" />}
{notice && <div className="drw-notice">{notice}</div>}
{views.length === 0 && !profiles.refusal && <div className="empty-note">没有可用的渲染规则</div>}
<div className="drw-profile-list" role="radiogroup" aria-label="渲染规则">
{views.map(v => (
<label key={v.key} className={`drw-profile${selectedKey === v.key ? ' sel' : ''}${v.previewAllowed ? '' : ' off'}`}>
<input type="radio" name="drw-profile" value={v.key} checked={selectedKey === v.key}
disabled={!v.previewAllowed} onChange={() => onSelect(v.key)} />
<span className="drw-profile-main">
<span className="drw-profile-head">
<span className="drw-profile-source">{v.sourceLabel}</span>
<span className="drw-profile-id" title={v.option.profileId}>{v.option.profileId}</span>
<span className="drw-profile-ver">v{v.option.version}</span>
<Chip tone={v.option.state === 'published' ? 'ok' : v.option.state === 'blocked' ? 'danger' : 'warn'}>{v.stateLabel}</Chip>
{v.formalAllowed ? <Chip tone="info">可正式交付</Chip> : v.previewAllowed ? <Chip tone="plain">仅预览</Chip> : null}
</span>
{v.reason && <span className="drw-profile-reason">{v.reason}</span>}
</span>
</label>
))}
</div>
</div>
)
}
const DIAGNOSTIC_PAGE = 50
function DiagnosticGroupBody({ items, nodeTitle, onSelectNode }: {
items: RenderDiagnostic[]
nodeTitle: (id: string) => string | undefined
onSelectNode: (id: string) => void
}) {
const [shown, setShown] = useState(DIAGNOSTIC_PAGE)
return (
<ul className="drw-diag-list">
{items.slice(0, shown).map((d, i) => {
const at = locateDiagnostic(d, nodeTitle)
return (
<li key={`${d.code}-${i}`} className={`drw-diag sev-${d.severity}`}>
{at ? (
<button type="button" className="drw-diag-node" onClick={() => onSelectNode(at.id)} title={at.title}>{at.title}</button>
) : <span className="drw-diag-node unlocated">未定位节点</span>}
<span className="drw-diag-msg">{d.message}</span>
{d.code && <span className="drw-diag-code" title={d.code}>{diagnosticCodeLabel(d.code)}</span>}
{d.suggestion && <span className="drw-diag-hint">建议:{d.suggestion}</span>}
</li>
)
})}
{items.length > shown && (
<li><button type="button" className="btn v-ghost drw-more" onClick={() => setShown(s => s + DIAGNOSTIC_PAGE)}>
显示更多(还有 {items.length - shown} 条)
</button></li>
)}
</ul>
)
}
export function DiagnosticsList({ diagnostics, nodeTitle, onSelectNode, idPrefix }: {
diagnostics: RenderDiagnostic[]
nodeTitle: (id: string) => string | undefined
onSelectNode: (id: string) => void
idPrefix: string
}) {
const groups = groupDiagnostics(diagnostics)
if (groups.length === 0) return <div className="empty-note">没有诊断问题</div>
return (
<div className="drw-diags">
{groups.map((g, i) => (
<Collapsible key={g.severity} id={`drw-diag-${idPrefix}-${g.severity}`} defaultOpen={i === 0}
triggerClassName="drw-group-trigger" trigger={<span className={`drw-group-label sev-${g.severity}`}>{g.label}({g.items.length})</span>}>
<DiagnosticGroupBody items={g.items} nodeTitle={nodeTitle} onSelectNode={onSelectNode} />
</Collapsible>
))}
</div>
)
}
type ReceiptDiagnostics = { status: 'loading' } | { status: 'ready'; items: RenderDiagnostic[] } | { status: 'error'; error: string }
export function RenderReceipt({ receipt, diagnostics, nodeTitle, onSelectNode }: {
receipt: Record<string, unknown>
diagnostics: ReceiptDiagnostics
nodeTitle: (id: string) => string | undefined
onSelectNode: (id: string) => void
}) {
const state = receiptStateView(receipt.state)
const items = diagnostics.status === 'ready' ? diagnostics.items : []
return (
<div className="drw-receipt">
<div className="drw-row">
<Chip tone={state.tone}>{state.label}</Chip>
{typeof receipt.fileName === 'string' && receipt.fileName && <FileName name={receipt.fileName} />}
</div>
{diagnostics.status === 'loading' && <div className="drw-muted"><span className="mk-sweep">诊断读取中</span></div>}
{diagnostics.status === 'error' && <RenderFailure error={diagnostics.error} id="receipt-diagnostics" />}
{diagnostics.status === 'ready' && (
<>
<div className="drw-muted">{diagnosticsSummaryText({ errors: items.filter(d => d.severity === 'error').length, warnings: items.filter(d => d.severity === 'warning').length })}</div>
<DiagnosticsList diagnostics={items} nodeTitle={nodeTitle} onSelectNode={onSelectNode} idPrefix={String(receipt.deliveryId ?? 'preview')} />
</>
)}
</div>
)
}
function Byline({ name, at }: { name: string; at: string }) {
const time = formatRenderTime(at)
return (
<span className="drw-byline">
{name && <span className="drw-byline-name">{name}</span>}
{time && <span className="drw-byline-time">{time}</span>}
</span>
)
}
function AnnotationItem({ a, onReply }: { a: ReviewAnnotation; onReply?: (a: ReviewAnnotation) => void }) {
return (
<div className={`drw-anno${a.resolved ? ' resolved' : ''}${a.needsReanchor ? ' needs-reanchor' : ''}`}>
<div className="drw-anno-head">
<Byline name={a.author.displayName} at={a.createdAt} />
{a.needsReanchor && <Chip tone="warn">需重新锚定</Chip>}
{a.resolved && <Chip tone="ok">已解决</Chip>}
{onReply && <button type="button" className="drw-link" onClick={() => onReply(a)}>回复</button>}
</div>
{a.quote && <blockquote className="drw-anno-quote" title={a.quote}>{a.quote}</blockquote>}
{a.needsReanchor && (
<div className="drw-anno-reanchor">
原文已变化,批注范围不再匹配{a.quote ? ';上方为旧摘录' : ''}。留痕稿将其列入孤立批注附录,不会自动迁移
</div>
)}
<div className="drw-anno-text">{a.text}</div>
{a.replies.length > 0 && (
<div className="drw-anno-replies">
{a.replies.map(r => (
<div key={r.id} className="drw-anno-reply">
<Byline name={r.author.displayName} at={r.createdAt} />
<div className="drw-anno-text">{r.text}</div>
</div>
))}
</div>
)}
</div>
)
}
/** 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, 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" 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 && (
<Collapsible id="drw-anno-orphaned" defaultOpen triggerClassName="drw-group-trigger"
trigger={<span className="drw-group-label">无法定位({orphaned.length})</span>}>
<div className="drw-anno-orphan-note">原位置已变更或不在当前文档中,未挂到任何节点</div>
{orphaned.map(a => <AnnotationItem key={a.id} a={a} />)}
</Collapsible>
)}
</div>
)
}
function RevisionItem({ r }: { r: ReviewRevision }) {
return (
<div className="drw-rev">
<div className="drw-anno-head">
<Chip tone="plain">{REVISION_KIND_LABELS[r.kind]}</Chip>
<Byline name={r.author.displayName} at={r.createdAt} />
{r.decision && <Chip tone={r.decision === 'accepted' || r.decision === 'accept' ? 'ok' : 'plain'}>{r.decision}</Chip>}
</div>
{r.before && <div className="drw-diff-del">{r.before}</div>}
{r.after && <div className="drw-diff-add">{r.after}</div>}
</div>
)
}
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
disabled?: boolean
onDownload: (kind: DownloadKind) => void
}) {
const options = downloadOptions(delivery)
if (options.length === 0) return <span className="drw-muted">暂无可下载文件</span>
return (
<div className="drw-downloads">
{options.map(o => (
<button key={o.kind} type="button" className="btn v-ghost drw-download" disabled={disabled || busyKind !== null}
title={`下载${o.label}`} onClick={() => onDownload(o.kind)}>
{busyKind === o.kind ? <span className="mk-sweep">下载中</span> : o.label}
</button>
))}
</div>
)
}
function NodeFactsView({ facts }: { facts: NodeFacts }) {
if (!hasFacts(facts)) return null
const rows: [string, ReactNode][] = []
if (facts.sources.length) rows.push(['来源', facts.sources.join('、')])
if (facts.confidence) rows.push(['置信度', facts.confidence])
if (facts.conflict) rows.push(['冲突', <span className="drw-conflict">{facts.conflict}</span>])
if (facts.author || facts.at) rows.push(['作者', <Byline name={facts.author ?? ''} at={facts.at ?? ''} />])
if (facts.impacts.length) rows.push(['影响', facts.impacts.join('、')])
if (facts.change?.impact) rows.push(['变更影响', facts.change.impact])
return (
<div className="drw-facts">
{rows.map(([k, v]) => (
<div key={k} className="drw-fact"><span className="drw-fact-k">{k}</span><span className="drw-fact-v">{v}</span></div>
))}
{(facts.change?.before || facts.change?.after) && (
<div className="drw-fact drw-fact-change">
<span className="drw-fact-k">变更</span>
<span className="drw-fact-v">
{facts.change.before && <div className="drw-diff-del">{facts.change.before}</div>}
{facts.change.after && <div className="drw-diff-add">{facts.change.after}</div>}
</span>
</div>
)}
</div>
)
}
// ---- 异步所有者 ----
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e))
/** 单一资源加载:requestKey 变化或 reload 发起新请求,旧请求的响应一律丢弃。 */
function useGuardedResource<T>(requestKey: string | null, identity: number, load: (live: () => boolean) => Promise<T>) {
const guard = useRef(createRequestGuard())
const loadRef = useRef(load)
loadRef.current = load
const [tick, setTick] = useState(0)
const [state, setState] = useState<{ key: string | null; data: T | null; error: string }>({ key: null, data: null, error: '' })
useEffect(() => {
const g = guard.current
if (requestKey === null) return
const ticket = g.begin(requestKey)
const live = () => g.current(ticket, requestKey) && identity === store.getIdentityEpoch()
setState(s => (s.key === requestKey ? { ...s, error: '' } : { key: requestKey, data: null, error: '' }))
loadRef.current(live).then(
data => { if (live()) setState({ key: requestKey, data, error: '' }) },
e => { if (live()) setState(s => ({ key: requestKey, data: s.key === requestKey ? s.data : null, error: errText(e) })) },
)
return () => g.cancel()
}, [requestKey, identity, tick])
const current = requestKey !== null && state.key === requestKey
return {
data: current ? state.data : null,
error: current ? state.error : '',
reload: () => setTick(t => t + 1),
}
}
/** 写操作:每次动作新 operationId,重试复用;同一时刻只认最新一次的结果。 */
function useOperationRunner(identity: number) {
const guard = useRef(createRequestGuard())
const last = useRef<{ op: RenderOperation; exec: (operationId: string) => Promise<unknown>; onDone?: (r: unknown) => void } | null>(null)
const [state, setState] = useState<OperationState>({ op: null, status: 'idle', error: '' })
const flight = useRef(createSingleFlight())
useEffect(() => { const g = guard.current; return () => g.cancel() }, [])
const start = (op: RenderOperation, exec: (operationId: string) => Promise<unknown>, onDone?: (r: unknown) => void) => {
// 单一所有者:上一动作未结束时忽略新的触发(双击不会铸第二个编号、不会第二次写来源)。
if (flight.current.busy()) return
last.current = { op, exec, onDone }
const scope = String(identity)
const ticket = guard.current.begin(scope)
const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch()
setState({ op, status: 'running', error: '' })
flight.current.run(() => exec(op.operationId)).then(
result => { if (!live()) return; setState({ op, status: 'done', error: '' }); onDone?.(result) },
e => { if (live()) setState({ op, status: 'failed', error: errText(e) }) },
)
}
return {
state,
run: (kind: OperationKind, exec: (operationId: string) => Promise<unknown>, onDone?: (r: unknown) => void) =>
start(nextOperation(kind, undefined, last.current?.op ?? null), exec, onDone),
retry: () => {
const l = last.current
if (l) start(nextOperation(l.op.kind, undefined, l.op, true), l.exec, l.onDone)
},
}
}
/** walkDocTree 只在守卫失效时返回 null;转为拒绝后同样被守卫丢弃,绝不写入空树。 */
async function loadDocTree(projectId: string, docId: string, live: () => boolean) {
const out = await walkDocTree(
(parent, cursor) => api.rdmCommand('rdm.inspect.tree', { projectId, kind: 'docs', parent, cursor }), docId, live)
if (!out) throw new Error('stale document tree')
return out
}
/** 预览/导出回执的诊断:按 deliveryId 另取 diagnostics 交付物;同一守卫串行化,过期即丢弃。 */
function useReceiptDiagnostics(projectId: string, identity: number) {
const guard = useRef(createRequestGuard())
const [state, setState] = useState<ReceiptDiagnostics | null>(null)
useEffect(() => { const g = guard.current; return () => g.cancel() }, [])
const load = (receipt: unknown) => {
const scope = String(identity)
const ticket = guard.current.begin(scope)
const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch()
setState({ status: 'loading' })
loadDeliveryDiagnostics((command, params) => api.rdmCommand(command, params), projectId, receipt, live).then(out => {
if (!out || !live()) return
setState('diagnostics' in out ? { status: 'ready', items: out.diagnostics } : { status: 'error', error: out.error })
})
}
return { state, load }
}
/** 节点事实缓存:按节点 id 归档,只写回发起请求的那个节点;组件按作用域重挂载即整体失效。 */
function useNodeFacts(projectId: string, identity: number, tree: DocTree | null, nodeId: string | null) {
const [facts, setFacts] = useState<Record<string, NodeFacts | 'loading' | 'error'>>({})
const requested = useRef(new Set<string>())
const alive = useRef(true)
useEffect(() => { alive.current = true; return () => { alive.current = false } }, [])
const node = tree && nodeId ? tree.nodes[nodeId] : undefined
useEffect(() => {
if (!node || node.kind === 'doc' || requested.current.has(node.id)) return
const id = node.id
const live = () => alive.current && identity === store.getIdentityEpoch()
requested.current.add(id)
setFacts(f => ({ ...f, [id]: 'loading' }))
api.rdmCommand('rdm.inspect.object', { projectId, kind: node.kind, id })
.then(d => { if (live()) setFacts(f => ({ ...f, [id]: nodeFacts(d) })) })
.catch(() => {
if (!live()) return
requested.current.delete(id)
setFacts(f => ({ ...f, [id]: 'error' }))
})
}, [node, projectId, identity])
return facts
}
/** 交付记录:首屏 + 分页追加,同一守卫串行化;作用域变化随组件重挂载失效。 */
function useDeliveries(projectId: string, docId: string, identity: number, enabled: boolean) {
const guard = useRef(createRequestGuard())
const [state, setState] = useState<{ items: RenderDelivery[]; nextCursor?: string; error: string; loading: boolean }>({ items: [], error: '', loading: enabled })
const fetchPage = (cursor: string | undefined, append: boolean) => {
const scope = String(identity)
const ticket = guard.current.begin(scope)
const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch()
setState(s => ({ ...s, loading: true, error: '' }))
api.rdmCommand('rdm.render.deliveries', { projectId, docId, limit: 20, ...(cursor ? { cursor } : {}) })
.then(raw => {
if (!live()) return
const page = normalizeDeliveries(raw)
setState(s => ({ items: append ? mergeDeliveries(s.items, page.items) : page.items, nextCursor: page.nextCursor, error: '', loading: false }))
})
.catch(e => { if (live()) setState(s => ({ ...s, error: errText(e), loading: false })) })
}
useEffect(() => {
const g = guard.current
if (enabled) fetchPage(undefined, false)
return () => g.cancel()
}, [enabled])
return { ...state, reload: () => fetchPage(undefined, false), loadMore: () => state.nextCursor && fetchPage(state.nextCursor, true) }
}
function useDownloader(projectId: string, identity: number) {
const guard = useRef(createRequestGuard())
const [state, setState] = useState<{ busy: { deliveryId: string; kind: DownloadKind } | null; error: string; lastFile: string }>({ busy: null, error: '', lastFile: '' })
useEffect(() => { const g = guard.current; return () => g.cancel() }, [])
const download = (deliveryId: string, kind: DownloadKind) => {
const scope = String(identity)
const ticket = guard.current.begin(scope)
const live = () => guard.current.current(ticket, scope) && identity === store.getIdentityEpoch()
setState(s => ({ ...s, busy: { deliveryId, kind }, error: '' }))
api.rdmCommand('rdm.render.download', { projectId, deliveryId, kind })
.then(raw => {
if (!live()) return
const p = normalizeDownload(raw)
saveBase64File(p.fileName, p.contentType, p.dataBase64)
setState({ busy: null, error: '', lastFile: p.fileName })
})
.catch(e => { if (live()) setState(s => ({ ...s, busy: null, error: errText(e) })) })
}
return { ...state, download }
}
// ---- 两种树视图(共用选中与过滤) ----
interface ViewProps {
tree: DocTree
order: string[]
visible: Set<string>
selectedId: string | null
onSelect: (id: string) => void
badges: (id: string) => ReactNode
facts: Record<string, NodeFacts | 'loading' | 'error'>
/** 侧栏定位(诊断)每次点击递增;选中未变也重新滚动到节点。 */
revealSeq: number
}
function useFocusSelected(root: React.RefObject<HTMLDivElement | null>, selectedId: string | null, revealSeq: number) {
const wantFocus = useRef(false)
useEffect(() => {
if (!selectedId || !root.current) return
const el = root.current.querySelector<HTMLElement>(`[data-node-id="${CSS.escape(selectedId)}"]`)
if (!el) return
el.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
if (wantFocus.current) { el.focus({ preventScroll: true }); wantFocus.current = false }
}, [root, selectedId, revealSeq])
return wantFocus
}
function StructureCanvas({ tree, order, visible, selectedId, onSelect, badges, revealSeq }: ViewProps) {
const ref = useRef<HTMLDivElement>(null)
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)
const tabStop = selectedId && visible.has(selectedId) ? selectedId : tree.rootId
const onKey = (e: KeyboardEvent) => {
const next = moveSelection(tree, order, selectedId, e.key)
if (next === null) return
e.preventDefault()
wantFocus.current = true
onSelect(next)
}
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest('.drw-canvas-node') || !ref.current) return
pan.current = { x: e.clientX, y: e.clientY, left: ref.current.scrollLeft, top: ref.current.scrollTop }
ref.current.setPointerCapture?.(e.pointerId)
setPanning(true)
}
const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!pan.current || !ref.current) return
ref.current.scrollLeft = pan.current.left - (e.clientX - pan.current.x)
ref.current.scrollTop = pan.current.top - (e.clientY - pan.current.y)
}
const endPan = () => { pan.current = null; setPanning(false) }
return (
<div ref={ref} className={`drw-canvas${panning ? ' is-panning' : ''}`} role="tree" aria-label="文档结构画布"
onKeyDown={onKey} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endPan} onPointerCancel={endPan}>
<div className="drw-canvas-stage" style={{ width: layout.width, height: layout.height }}>
<svg className="drw-canvas-edges" width={layout.width} height={layout.height} aria-hidden="true">
{layout.edges.map(e => <path key={e.to} d={e.d} className="drw-canvas-edge" />)}
</svg>
{layout.nodes.map(n => {
const node = tree.nodes[n.id]
return (
<button key={n.id} type="button" role="treeitem" data-node-id={n.id}
aria-selected={selectedId === n.id} aria-level={n.depth + 1}
tabIndex={tabStop === n.id ? 0 : -1}
className={`drw-canvas-node depth-${Math.min(n.depth, 3)}${selectedId === n.id ? ' sel' : ''}`}
style={{ left: n.x, top: n.y, width: MIND_MAP.nodeWidth, height: MIND_MAP.nodeHeight }}
title={node.title} onClick={() => onSelect(n.id)}>
<span className="drw-canvas-title">{node.title}</span>
{badges(n.id)}
</button>
)
})}
</div>
</div>
)
}
/** 选区与各 [data-anchor-node] 正文元素的交集;端点在元素外时裁到元素首尾。 */
function selectionHits(root: HTMLElement, range: Range): SelectionHit[] {
const hits: SelectionHit[] = []
for (const el of root.querySelectorAll<HTMLElement>('[data-anchor-node]')) {
if (!range.intersectsNode(el)) continue
const text = el.textContent ?? ''
const whole = document.createRange()
whole.selectNodeContents(el)
const offsetOf = (node: Node, offset: number) => {
const side = whole.comparePoint(node, offset)
if (side !== 0) return side < 0 ? 0 : text.length
const head = document.createRange()
head.setStart(el, 0)
head.setEnd(node, offset)
return head.toString().length
}
hits.push({ nodeId: el.dataset.anchorNode ?? '', text, start: offsetOf(range.startContainer, range.startOffset), end: offsetOf(range.endContainer, range.endOffset) })
}
return hits
}
type TextSelection = ReturnType<typeof selectionAnchor>
// 标题用 role=button 的 span 而非 <button>:浏览器不允许拖选按钮内文字,范围批注需要选中标题正文。
function ReaderView({ tree, order, visible, selectedId, onSelect, badges, facts, revealSeq, onTextSelect }: ViewProps & {
/** 提供时节点正文可选中并按选区记录批注范围(有审阅提交且可批注)。 */
onTextSelect?: (selection: TextSelection) => void
}) {
const ref = useRef<HTMLDivElement>(null)
const wantFocus = useFocusSelected(ref, selectedId, revealSeq)
const onKey = (e: KeyboardEvent) => {
const next = moveSelection(tree, order, selectedId, e.key)
if (next === null) return
e.preventDefault()
wantFocus.current = true
onSelect(next)
}
const onMouseUp = () => {
if (!onTextSelect || !ref.current) return
const sel = window.getSelection()
onTextSelect(selectionAnchor(!sel || sel.rangeCount === 0 || sel.isCollapsed ? null : selectionHits(ref.current, sel.getRangeAt(0))))
}
return (
<div ref={ref} className="drw-reader" onKeyDown={onKey} onMouseUp={onMouseUp}>
{visibleOrder(tree, visible).map(id => {
const n = tree.nodes[id]
const level = Math.min(n.depth + 2, 5)
const Heading = `h${level}` as 'h2' | 'h3' | 'h4' | 'h5'
const f = facts[id]
const activate = (e: KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
onSelect(id)
}
return (
<section key={id} id={nodeAnchorId(id)} className={`drw-reader-node${selectedId === id ? ' sel' : ''}`}>
<Heading className="drw-reader-heading">
<span role="button" className="drw-reader-title" data-node-id={id} aria-current={selectedId === id ? 'true' : undefined}
data-anchor-node={onTextSelect && n.kind !== 'doc' ? id : undefined}
tabIndex={selectedId === id || (!selectedId && id === tree.rootId) ? 0 : -1} onClick={() => onSelect(id)} onKeyDown={activate}>
{n.title}
</span>
<a className="drw-anchor" href={`#${nodeAnchorId(id)}`} aria-label={`${n.title} 的锚点`}></a>
{badges(id)}
{n.state && <Chip tone={n.state === 'baselined' ? 'ok' : 'plain'}>{n.state}</Chip>}
</Heading>
{f && typeof f === 'object' && <NodeFactsView facts={f} />}
</section>
)
})}
</div>
)
}
// ---- 审阅输入 ----
/** 批注范围:回复沿用父批注范围;有选区显示摘录,否则为整个节点;选区无效时给出说明。 */
export function AnnotationScope({ anchor, notice, replying, onClear }: {
anchor: TextAnchor | null
notice: string
replying: boolean
onClear: () => void
}) {
if (replying) return <div className="drw-composer-scope drw-muted">回复沿用原批注的范围</div>
return (
<>
<div className="drw-composer-scope">
<span className="drw-muted">范围</span>
{anchor ? (
<>
<span className="drw-composer-quote" title={anchor.quote}>「{anchor.quote}」</span>
<span className="drw-muted">第 {anchor.textStart + 1}–{anchor.textEnd} 字</span>
<button type="button" className="drw-link" onClick={onClear}>改为整个节点</button>
</>
) : <span className="drw-muted">整个节点(在阅读视图中选中该节点的文字,可只批注所选部分)</span>}
</div>
{notice && <div className="drw-anchor-notice" role="status">{notice}</div>}
</>
)
}
function ReviewComposer({ nodeTitle, replyTo, onCancelReply, anchor, anchorNotice, onClearAnchor, canAnnotate, canRevise, busy, onAnnotate, onRevise }: {
nodeTitle: string | undefined
replyTo: ReviewAnnotation | null
onCancelReply: () => void
anchor: TextAnchor | null
anchorNotice: string
onClearAnchor: () => void
canAnnotate: boolean
canRevise: boolean
busy: boolean
onAnnotate: (text: string, replyTo?: string) => void
onRevise: (kind: 'insert' | 'delete' | 'replace', before?: string, after?: string) => void
}) {
const [mode, setMode] = useState<'comment' | 'revise'>(canAnnotate ? 'comment' : 'revise')
const [text, setText] = useState('')
const [kind, setKind] = useState<'insert' | 'delete' | 'replace'>('replace')
const [before, setBefore] = useState('')
const [after, setAfter] = useState('')
const [hint, setHint] = useState('')
if (!nodeTitle) return <div className="empty-note">先在文档树中选择一个章节,再添加批注或修订</div>
const submit = () => {
if (mode === 'comment') {
const bad = validateReviewText(text)
if (bad) { setHint(bad); return }
onAnnotate(text, replyTo?.id)
setText('')
} else {
const bad = (kind !== 'insert' ? validateReviewText(before) : undefined) ?? (kind !== 'delete' ? validateReviewText(after) : undefined)
if (bad) { setHint(bad); return }
onRevise(kind, kind !== 'insert' ? before : undefined, kind !== 'delete' ? after : undefined)
setBefore(''); setAfter('')
}
setHint('')
}
return (
<div className="drw-composer">
<div className="drw-composer-head">
<span className="drw-muted">目标:</span><span className="drw-composer-node" title={nodeTitle}>{nodeTitle}</span>
{canAnnotate && canRevise && (
<select className="ui-input drw-select" aria-label="审阅动作" value={mode} onChange={e => setMode(e.target.value as 'comment' | 'revise')}>
<option value="comment">批注</option>
<option value="revise">修订</option>
</select>
)}
</div>
{mode === 'comment' ? (
<>
{replyTo && (
<div className="drw-composer-reply">
回复 {replyTo.author.displayName || '批注'}
<button type="button" className="drw-link" onClick={onCancelReply}>取消回复</button>
</div>
)}
<AnnotationScope anchor={anchor} notice={anchorNotice} replying={!!replyTo} onClear={onClearAnchor} />
<textarea className="ui-input drw-textarea" aria-label="批注内容" value={text} onChange={e => setText(e.target.value)} placeholder="写下批注…" />
</>
) : (
<>
<select className="ui-input drw-select" aria-label="修订类型" value={kind} onChange={e => setKind(e.target.value as 'insert' | 'delete' | 'replace')}>
{(['replace', 'insert', 'delete'] as const).map(k => <option key={k} value={k}>{REVISION_KIND_LABELS[k]}</option>)}
</select>
{kind !== 'insert' && <textarea className="ui-input drw-textarea" aria-label="修订前" value={before} onChange={e => setBefore(e.target.value)} placeholder="原文…" />}
{kind !== 'delete' && <textarea className="ui-input drw-textarea" aria-label="修订后" value={after} onChange={e => setAfter(e.target.value)} placeholder="修改为…" />}
</>
)}
{hint && <div className="drw-inline-err">{hint}</div>}
<div className="drw-row">
<Btn variant="ghost" disabled={busy} onClick={submit}>{mode === 'comment' ? '添加批注' : '提交修订'}</Btn>
</div>
</div>
)
}
// ---- 工作区 ----
const OP_LABELS: Record<string, string> = {
preview: '生成预览', submit: '提交审阅', 'review-export': '生成留痕稿', 'approved-export': '生成批准稿', annotate: '添加批注', revise: '提交修订',
}
interface WorkspaceProps { projectId: string; docId: string; docTitle: string; onBack: () => void }
export function DocumentRenderWorkspace(props: WorkspaceProps) {
const identity = useStore(() => store.getIdentityEpoch())
return <ScopedWorkspace key={renderScope(identity, props.projectId, props.docId)} identity={identity} {...props} />
}
function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: WorkspaceProps & { identity: number }) {
const caps = useStore(s => s.rdmRender)
const scope = renderScope(identity, projectId, docId)
const [view, setView] = useState<'canvas' | 'reader'>('canvas')
const [selectedId, setSelectedId] = useState<string | null>(null)
const [revealSeq, setRevealSeq] = useState(0)
const [filter, setFilter] = useState<DocFilter>({ text: '', mode: 'all' })
const [profileKeyChoice, setProfileKeyChoice] = useState<string | null>(null)
const [submissionChoice, setSubmissionChoice] = useState<string | null>(null)
const [replyTo, setReplyTo] = useState<ReviewAnnotation | null>(null)
// 阅读视图选区记录的批注范围或无效说明;只对记录时的那个节点生效。
const [textPick, setTextPick] = useState<{ nodeId: string; anchor: TextAnchor | null; notice: string } | null>(null)
const [receipt, setReceipt] = useState<Record<string, any> | null>(null)
const sections = useGuardedResource(`${scope}:tree`, identity, live => loadDocTree(projectId, docId, live))
const profiles = useGuardedResource(caps.ready ? `${scope}:profiles` : null, identity,
() => api.rdmCommand('rdm.render.profiles', { projectId, docId }).then(normalizeProfiles))
const deliveries = useDeliveries(projectId, docId, identity, caps.ready)
const renderOp = useOperationRunner(identity)
const reviewOp = useOperationRunner(identity)
const downloader = useDownloader(projectId, identity)
const receiptDiagnostics = useReceiptDiagnostics(projectId, identity)
const decisionOp = useOperationRunner(identity)
const bindOp = useOperationRunner(identity)
// 审签来源写入宿主对话项目(研发项目 rdm.projectId 绑定的那个项目)。
const projects = useStore(s => s.projects)
const hostProject = useMemo(() => hostProjectFor(projects, projectId), [projects, projectId])
// 身份只取会话;入口与绑定权限分别来自服务端冻结职责(docStatus)与 rdm.perm.check 的 project.settings。
const me = useStore(s => s.me?.owner ?? '')
const perm = useGuardedResource(caps.ready && caps.bind && me ? `${scope}:perm` : null, identity,
() => api.rdmCommand('rdm.perm.check', permCheckPayload(projectId, me)).then(projectSettingsAllowed))
const bindingOpen = caps.ready && caps.bind && perm.data === true
const docType = useGuardedResource(bindingOpen ? `${scope}:doc-type` : null, identity,
() => api.rdmCommand('rdm.inspect.object', { projectId, kind: 'doc', id: docId }).then(docTypeKeyOf))
const candidates = useGuardedResource(bindingOpen ? `${scope}:candidates` : null, identity,
() => api.rdmCommand('rdm.render.profile.candidate.list', { projectId, limit: 100 }).then(normalizeCandidateRules))
const tree = useMemo(() => (sections.data ? buildDocTree({ id: docId, title: docTitle }, sections.data.items) : null), [sections.data, docId, docTitle])
const facts = useNodeFacts(projectId, identity, tree, selectedId)
const decisionCaps = caps.approve || caps.reject || caps.final
const docStatus = useGuardedResource(caps.review || decisionCaps ? `${scope}:submission` : null, identity,
() => api.rdmCommand('rdm.review.doc.status', { projectId, docId }).catch(() => null))
const submission = normalizeDocSubmission(docStatus.data)
const entries = decisionEntries(submission, me || undefined, caps)
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])
const selectedProfileKey = profileKeyChoice ?? (profileData ? defaultProfileKey(profileData) : '')
const selectedProfile = views.find(v => v.key === selectedProfileKey)
const diagState = receiptDiagnostics.state
const diagnostics = useMemo(() => (diagState?.status === 'ready' ? diagState.items : []), [diagState])
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])
const marks = useMemo(() => ({
issues: new Set(Object.keys(diagCounts)),
review: new Set([...annotations.byNode.keys(), ...revisions.byNode.keys()]),
}), [diagCounts, annotations, revisions])
const visible = useMemo(() => (tree ? visibleNodeIds(tree, filter, marks) : new Set<string>()), [tree, filter, marks])
const order = useMemo(() => (tree ? visibleOrder(tree, visible) : []), [tree, visible])
const nodeTitle = (id: string) => tree?.nodes[id]?.title
const selectNode = (id: string) => {
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)
if (!d && !notes) return null
return (
<span className="drw-badges">
{d?.errors ? <span className="drw-badge sev-error" title={`${d.errors} 个错误`}>{d.errors}</span> : null}
{d?.warnings ? <span className="drw-badge sev-warning" title={`${d.warnings} 个警告`}>{d.warnings}</span> : null}
{notes ? <span className="drw-badge review" title={`${notes} 条审阅意见`}>{notes}</span> : null}
</span>
)
}
const profileParams = () => selectedProfile && { profileId: selectedProfile.option.profileId, profileVersion: selectedProfile.option.version }
const acceptReceipt = (r: unknown) => {
setReceipt(r && typeof r === 'object' ? r as Record<string, unknown> : {})
receiptDiagnostics.load(r)
deliveries.reload()
trace.reload()
}
const generatePreview = () => {
const p = profileParams()
if (!p) return
renderOp.run('preview', operationId => api.rdmCommand('rdm.render.preview', { projectId, docId, operationId, ...p }), acceptReceipt)
}
const submitReview = () => {
const p = profileParams()
if (!p || !selectedProfile?.formalAllowed) return
renderOp.run('submit', operationId => api.rdmCommand('rdm.render.submit', { projectId, docId, operationId, ...p }), r => {
const id = submissionIdOf(r)
if (id) setSubmissionChoice(id)
deliveries.reload()
trace.reload()
})
}
const exportFor = (kind: 'review-export' | 'approved-export') => {
if (!submissionId) return
const command = kind === 'review-export' ? 'rdm.render.review.export' : 'rdm.render.approved.export'
renderOp.run(kind, operationId => api.rdmCommand(command, { projectId, docId, operationId, submissionId }), acceptReceipt)
}
const selectedNode = tree && selectedId ? tree.nodes[selectedId] : undefined
const reviewTarget = reviewTargetNode(tree, selectedId)
const reviewDone = () => { review.reload(); trace.reload() }
const pick = textPick && reviewTarget && textPick.nodeId === reviewTarget.id ? textPick : null
const acceptTextSelection = (selection: TextSelection) => {
switch (selection.kind) {
case 'none':
setTextPick(null)
return
case 'range':
selectNode(selection.anchor.nodeId)
setReplyTo(null)
setTextPick({ nodeId: selection.anchor.nodeId, anchor: selection.anchor, notice: '' })
return
case 'multi-node':
case 'outside-text':
setTextPick(reviewTarget ? { nodeId: reviewTarget.id, anchor: null, notice: selection.notice } : null)
return
default: {
const unknown: never = selection
return unknown
}
}
}
const annotate = (text: string, reply?: string) => {
if (!submissionId || !reviewTarget) return
const range = reply ? null : pick?.anchor ?? null
reviewOp.run('annotate', operationId => api.rdmCommand('rdm.render.review.annotate',
annotatePayload({ projectId, submissionId, treeNodeId: reviewTarget.id, text, operationId, replyTo: reply, range })),
() => { setReplyTo(null); setTextPick(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, treeNodeId: reviewTarget.id, kind, before, after, operationId })), reviewDone)
}
// 审签动作:载荷只含 projectId/docId 与 note|reason|round,轮次取界面所示的在途轮次;结果后刷新审签状态与批注。
// 先在宿主项目的审签记录对话写入已提交的来源消息,再以它为锚点分发命令;任一步失败不分发。
const decide = (kind: DecisionKind, text: string) => {
if (!submission) return
const round = submission.round
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(); reviewDone() })
}
// 规则绑定:绑定/解绑成功后刷新 rdm.render.profiles(当前绑定与选项以服务端为准)。
const bindings: Partial<Record<BindSource, RuleRef>> = profileData ? currentBindings(profileData) : {}
const rules = profileData ? bindableRules(profileData, candidates.data ?? []) : []
const bindRule = (source: BindSource, rule: RuleRef) => {
const payload = profileBindPayload({ projectId, source, docId, typeKey: docType.data ?? undefined, rule })
if (!payload) return
bindOp.run('bind', () => api.rdmCommand('rdm.render.profile.bind', payload), () => { setProfileKeyChoice(null); profiles.reload() })
}
const unbindScope = (source: BindSource) => {
const payload = profileUnbindPayload({ projectId, source, docId, typeKey: docType.data ?? undefined, precondition: bindings[source] })
if (!payload) return
bindOp.run('unbind', () => api.rdmCommand('rdm.render.profile.unbind', payload), () => { setProfileKeyChoice(null); profiles.reload() })
}
const running = renderOp.state.status === 'running'
const selectedFacts = selectedId ? facts[selectedId] : undefined
return (
<div className="drw">
<div className="drw-header">
<button type="button" className="btn v-ghost" onClick={onBack}>← 返回文档树</button>
<strong className="drw-title" title={docTitle}>{docTitle}</strong>
<div className="tabs drw-view-tabs" role="tablist" aria-label="文档视图">
<button type="button" role="tab" aria-selected={view === 'canvas'} className={`tab${view === 'canvas' ? ' active' : ''}`} onClick={() => setView('canvas')}>结构画布</button>
<button type="button" role="tab" aria-selected={view === 'reader'} className={`tab${view === 'reader' ? ' active' : ''}`} onClick={() => setView('reader')}>阅读视图</button>
</div>
</div>
<div className="drw-body">
<div className="drw-main">
<div className="drw-filters">
<input className="ui-input drw-search" type="search" aria-label="过滤节点" placeholder="过滤节点…"
value={filter.text} onChange={e => setFilter(f => ({ ...f, text: e.target.value }))} />
<select className="ui-input drw-select" aria-label="节点范围" value={filter.mode}
onChange={e => setFilter(f => ({ ...f, mode: e.target.value as DocFilter['mode'] }))}>
<option value="all">全部节点</option>
<option value="issues">有诊断问题</option>
<option value="review">有审阅意见</option>
</select>
</div>
{sections.error ? <RenderFailure error={sections.error} id="tree" /> : !tree ? <div className="empty-note">加载中…</div> : (
<>
{sections.data?.truncated && <div className="drw-notice">结构过大,已截断:仅显示前 {tree.order.length - 1} 个节点(最多 {DOC_TREE_LIMITS.maxDepth} 层)</div>}
{tree.order.length === 1 && <div className="empty-note">该文档暂无章节</div>}
{order.length <= 1 && tree.order.length > 1 && <div className="empty-note">没有符合过滤条件的节点</div>}
{view === 'canvas'
? <StructureCanvas tree={tree} order={order} visible={visible} selectedId={selectedId} onSelect={selectNode} badges={badges} facts={facts} revealSeq={revealSeq} />
: <ReaderView tree={tree} order={order} visible={visible} selectedId={selectedId} onSelect={selectNode} badges={badges} facts={facts} revealSeq={revealSeq}
onTextSelect={caps.review && caps.annotate && submissionId ? acceptTextSelection : undefined} />}
{selectedNode && (
<div className="drw-selected">
<SectionLabel>当前节点</SectionLabel>
<div className="drw-selected-title" title={selectedNode.title}>{selectedNode.title}</div>
{selectedFacts === 'loading' && <div className="empty-note">加载中…</div>}
{selectedFacts === 'error' && <div className="drw-muted">节点详情暂不可用</div>}
{selectedFacts && typeof selectedFacts === 'object' && (hasFacts(selectedFacts)
? <NodeFactsView facts={selectedFacts} />
: <div className="drw-muted">服务端未提供该节点的来源或变更信息</div>)}
</div>
)}
</>
)}
</div>
<aside className="drw-side">
{!caps.ready ? (
<div className="drw-muted">当前服务未提供文档渲染能力,仅可阅读文档结构</div>
) : (
<>
<section className="drw-panel">
<SectionLabel>渲染规则</SectionLabel>
{profiles.error ? <RenderFailure error={profiles.error} id="profiles" />
: !profileData ? <div className="empty-note">加载中…</div>
: <ProfileOptionList profiles={profileData} selectedKey={selectedProfileKey} onSelect={setProfileKeyChoice} />}
</section>
{bindingOpen && (
<section className="drw-panel">
{perm.error && <RenderFailure error={perm.error} id="perm" />}
{docType.error && <RenderFailure error={docType.error} id="doc-type" />}
{candidates.error && <RenderFailure error={candidates.error} id="candidates" />}
<ProfileBindingPanel rules={rules} bindings={bindings} typeKey={docType.data ?? undefined}
busy={bindOp.state.status === 'running'} status={bindOp.state} onRetry={bindOp.retry}
onBind={bindRule} onUnbind={unbindScope} />
{bindOp.state.status === 'done' && bindOp.state.op && <div className="drw-ok">{ACTION_OP_LABELS[bindOp.state.op.kind]}已完成</div>}
</section>
)}
<section className="drw-panel">
<SectionLabel>预览与提交</SectionLabel>
<div className="drw-row">
<Btn variant="primary" disabled={running || !selectedProfile?.previewAllowed} onClick={generatePreview}>生成预览</Btn>
{caps.submit && (
<Btn variant="ghost" disabled={running || !selectedProfile?.formalAllowed} onClick={submitReview}
title={selectedProfile && !selectedProfile.formalAllowed ? selectedProfile.reason : undefined}>提交审阅</Btn>
)}
</div>
{selectedProfile && !selectedProfile.formalAllowed && selectedProfile.reason && <div className="drw-muted">{selectedProfile.reason}</div>}
<OperationStatus state={renderOp.state} onRetry={renderOp.retry} labels={OP_LABELS} />
{renderOp.state.status === 'done' && renderOp.state.op && renderOp.state.op.kind !== 'preview' && (
<div className="drw-ok">{OP_LABELS[renderOp.state.op.kind]}已完成</div>
)}
{receipt && diagState && <RenderReceipt receipt={receipt} diagnostics={diagState} nodeTitle={nodeTitle} onSelectNode={revealNode} />}
</section>
{caps.review && (
<section className="drw-panel">
<SectionLabel>审阅</SectionLabel>
{!submissionId ? <div className="empty-note">暂无审阅提交</div> : (
<>
<div className="drw-row">
<span className="drw-muted">提交</span><span className="drw-mono" title={submissionId}>{truncateMiddle(submissionId, 24)}</span>
{caps.reviewExport && <Btn variant="ghost" disabled={running} onClick={() => exportFor('review-export')}>生成留痕稿</Btn>}
{caps.approvedExport && <Btn variant="ghost" disabled={running} onClick={() => exportFor('approved-export')}>生成批准稿</Btn>}
</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={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) => (
<div key={i} className="drw-decision">
<Byline name={d.actor.displayName} at={d.at} />
{d.duty && <Chip tone="plain">{d.duty}</Chip>}
<Chip tone={/approve/i.test(d.decision) ? 'ok' : /reject/i.test(d.decision) ? 'danger' : 'plain'}>{d.decision}</Chip>
{d.reason && <span className="drw-muted">{d.reason}</span>}
</div>
))}
</div>
)}
</>
)}
{(caps.annotate || caps.revise) && (
<ReviewComposer nodeTitle={reviewTarget?.title} replyTo={replyTo} onCancelReply={() => setReplyTo(null)}
anchor={pick?.anchor ?? null} anchorNotice={pick?.notice ?? ''} onClearAnchor={() => setTextPick(null)}
canAnnotate={caps.annotate} canRevise={caps.revise} busy={reviewOp.state.status === 'running'}
onAnnotate={annotate} onRevise={revise} />
)}
<OperationStatus state={reviewOp.state} onRetry={reviewOp.retry} labels={OP_LABELS} />
</>
)}
</section>
)}
{decisionCaps && submission && (
<section className="drw-panel">
<ReviewDecisionPanel sub={submission} entries={entries} busy={decisionOp.state.status === 'running'}
status={decisionOp.state} onRetry={decisionOp.retry} onDecide={decide} />
{decisionOp.state.status === 'done' && decisionOp.state.op && <div className="drw-ok">{ACTION_OP_LABELS[decisionOp.state.op.kind]}已完成</div>}
</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>
<button type="button" className="drw-link" disabled={deliveries.loading} onClick={deliveries.reload}>刷新</button>
</div>
{deliveries.error && <RenderFailure error={deliveries.error} id="deliveries" />}
{downloader.error && <RenderFailure error={downloader.error} id="download" />}
{downloader.lastFile && <div className="drw-muted">已下载 <FileName name={downloader.lastFile} /></div>}
{!deliveries.error && deliveries.items.length === 0 && (deliveries.loading ? <div className="empty-note">加载中…</div> : <div className="empty-note">暂无交付记录</div>)}
<div className="drw-deliveries">
{deliveries.items.map(d => (
<div key={d.deliveryId} className={`drw-delivery${d.submissionId && d.submissionId === submissionId ? ' sel' : ''}`}>
<div className="drw-row">
<Chip tone={d.mode === 'approved' ? 'ok' : d.mode === 'review' ? 'info' : 'plain'}>{DELIVERY_MODE_LABELS[d.mode] ?? d.mode}</Chip>
<span className="drw-muted">{formatRenderTime(d.createdAt)}</span>
{d.requestedBy?.displayName && <span className="drw-muted">{d.requestedBy.displayName}</span>}
{d.status && <span className="drw-muted">{d.status}</span>}
</div>
<div className="drw-row">
{d.profileId && <span className="drw-mono" title={d.profileId}>{truncateMiddle(d.profileId, 24)} v{d.profileVersion}</span>}
<span className="drw-muted">{diagnosticsSummaryText(d.diagnosticsSummary)}</span>
</div>
<DownloadButtons delivery={d} busyKind={downloader.busy?.deliveryId === d.deliveryId ? downloader.busy.kind : null}
disabled={downloader.busy !== null} onDownload={kind => downloader.download(d.deliveryId, kind)} />
{caps.review && d.submissionId && d.submissionId !== submissionId && (
<button type="button" className="drw-link" onClick={() => setSubmissionChoice(d.submissionId!)}>查看审阅</button>
)}
</div>
))}
</div>
{deliveries.nextCursor && <Btn variant="ghost" disabled={deliveries.loading} onClick={() => { deliveries.loadMore() }}>加载更多</Btn>}
</section>
</>
)}
</aside>
</div>
</div>
)
}