governance / governance (pull_request) Successful in 15s
Why: RDM now anchors annotations on nodeId + textStart + textEnd + textHash (spec 06 §2) and acceptance 10 §5 requires accurate comment ranges. The workspace only sent whole-node annotations and ignored quote/needsReanchor. - Selecting text inside one node's title in the reader view records a code-point range (Array.from indices; surrogate pairs and ZWJ emoji are counted as runes, half-pair endpoints widen) and sends textStart/textEnd plus quote, so RDM rejects rather than misplaces a drifted range. - No selection keeps whole-node behavior; replies never carry range params; textHash/range are never sent; a range is dropped if it belongs to another node. - Selections spanning several nodes or containing no node text record no range and show a notice that the whole node will be annotated; the composer always shows the scope with a "whole node" escape. - Annotation items show the server quote and a visible needs-reanchor state; review_anchor_invalid: errors map to readable Chinese text. - Reader titles become role=button spans because browsers do not allow text selection inside <button>. Evidence: npm test 40 files / 486 tests pass (30 new in test/review-annotation-anchor.test.ts); npm run build exit 0; node scripts/check-knip.mjs "50 <= 51"; design-guard 7/7; playwright --list compiles 13 tests; headless Chromium check of the selection reader (single, multi-node, triple-click clamp, emoji offsets). Impact: document review UI only; base-gate decision source anchoring is unchanged. Offsets are computed on the displayed node title (no frozen-text read endpoint exists), guarded by quote equality on the server. Refs: unified renderer plan Task 13; phase1 F2; spec 06 §2 Co-authored-by: Cursor <cursoragent@cursor.com>
1161 lines
61 KiB
TypeScript
1161 lines
61 KiB
TypeScript
// 统一渲染 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>
|
||
)
|
||
}
|