Add review decisions, rule binding and package download to the render workspace
Why: Task 11 requires the document render workspace to offer approve, reject and final decisions, rule bind/unbind for document, document type and project default, and the full delivery package, so reviewers and settings holders no longer need other screens. Evidence: vitest 38 files / 419 tests passed (new test/document-render-actions.test.ts covers decision entries from frozen duties, decision payloads without identity fields, package availability, refusal codes, binding candidates and payloads, perm gating and SSR visibility); tsc --noEmit clean; knip unused exports 51 <= baseline 51. Impact: decision entries show only when the command is enabled and the frozen submission lists the session user; binding needs rdm.perm.check project.settings allowed=true; package appears only for ready deliveries; organization-default binding is not offered from the workspace (domain admin only). Refs: unified renderer plan Task 11, 05 section 9, 06 section 6, 07 sections 1 and 5; RDM plugins/rdm/plugin.go review.doc.* and render.profile.* schemas (7169944, 4b7ac2a, 75ba50c). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// 统一渲染 Task 11:整文审签与规则绑定面板。纯展示:入口、载荷与校验来自 documentRenderPresentation,
|
||||
// 异步操作状态由工作区唯一持有并经 props 注入;入口只在服务端允许(能力 ∧ 冻结职责/项目设置权限)时出现。
|
||||
import { useState } from 'react'
|
||||
import { Btn, Chip, SectionLabel } from '@ui'
|
||||
import {
|
||||
BIND_SOURCE_LABELS, validateReviewText, ruleLabel,
|
||||
type BindSource, type BindableRule, type DecisionEntries, type DecisionKind, type DocSubmissionView, type RuleRef,
|
||||
} from '../documentRenderPresentation'
|
||||
import { OperationStatus, type OperationState } from './renderFeedback'
|
||||
|
||||
export const ACTION_OP_LABELS: Record<string, string> = {
|
||||
approve: '整文审核通过', reject: '整文驳回', final: '整文终批', bind: '绑定规则', unbind: '解绑规则',
|
||||
}
|
||||
|
||||
const DECISION_LABELS: Record<DecisionKind, string> = { approve: '审核通过', reject: '驳回', final: '终批' }
|
||||
const SUBMISSION_STATE: Record<string, { label: string; tone: 'ok' | 'info' | 'danger' | 'plain' }> = {
|
||||
submitted: { label: '审签中', tone: 'info' },
|
||||
rejected: { label: '已驳回', tone: 'danger' },
|
||||
final: { label: '已终批', tone: 'ok' },
|
||||
}
|
||||
|
||||
export function ReviewDecisionPanel({ sub, entries, busy, status, onRetry, onDecide }: {
|
||||
sub: DocSubmissionView
|
||||
entries: DecisionEntries
|
||||
busy: boolean
|
||||
status: OperationState
|
||||
onRetry: () => void
|
||||
onDecide: (kind: DecisionKind, text: string) => void
|
||||
}) {
|
||||
const [text, setText] = useState('')
|
||||
const [hint, setHint] = useState('')
|
||||
const reviewerIds = Object.keys(sub.reviews)
|
||||
const approvedCount = reviewerIds.filter(id => sub.reviews[id] === true).length
|
||||
const stateView = SUBMISSION_STATE[sub.state] ?? { label: '状态未知', tone: 'plain' as const }
|
||||
const anyEntry = entries.approve || entries.reject || entries.final
|
||||
|
||||
const decide = (kind: DecisionKind) => {
|
||||
// 驳回必须给出原因;审核通过与终批的说明可选(服务端同样可选)。
|
||||
if (kind === 'reject') {
|
||||
const bad = validateReviewText(text)
|
||||
if (bad) { setHint(bad); return }
|
||||
}
|
||||
setHint('')
|
||||
onDecide(kind, text)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="drw-row drw-row-between">
|
||||
<SectionLabel>整文审签</SectionLabel>
|
||||
<span className="drw-row">
|
||||
<Chip tone={stateView.tone}>{stateView.label}</Chip>
|
||||
<span className="drw-muted">第 {sub.round} 轮 · 审核 {approvedCount}/{reviewerIds.length}</span>
|
||||
</span>
|
||||
</div>
|
||||
{anyEntry && (
|
||||
<>
|
||||
<textarea className="ui-input drw-textarea" aria-label="审签说明" value={text}
|
||||
onChange={e => setText(e.target.value)} placeholder="说明可留空;驳回时必须填写原因" />
|
||||
{hint && <div className="drw-inline-err">{hint}</div>}
|
||||
<div className="drw-row">
|
||||
{entries.approve && <Btn variant="ghost" disabled={busy} onClick={() => decide('approve')}>{DECISION_LABELS.approve}</Btn>}
|
||||
{entries.reject && <Btn variant="danger" disabled={busy} onClick={() => decide('reject')}>{DECISION_LABELS.reject}</Btn>}
|
||||
{entries.final && <Btn variant="primary" disabled={busy} onClick={() => decide('final')}>{DECISION_LABELS.final}</Btn>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<OperationStatus state={status} onRetry={onRetry} labels={ACTION_OP_LABELS} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SCOPES: BindSource[] = ['document', 'document-type', 'project-default']
|
||||
const ruleKey = (r: RuleRef) => `${r.profileId}@${r.version}`
|
||||
|
||||
export function ProfileBindingPanel({ rules, bindings, typeKey, busy, status, onRetry, onBind, onUnbind }: {
|
||||
rules: BindableRule[]
|
||||
bindings: Partial<Record<BindSource, RuleRef>>
|
||||
typeKey: string | undefined
|
||||
busy: boolean
|
||||
status: OperationState
|
||||
onRetry: () => void
|
||||
onBind: (source: BindSource, rule: RuleRef) => void
|
||||
onUnbind: (source: BindSource) => void
|
||||
}) {
|
||||
const [choice, setChoice] = useState('')
|
||||
const selected = rules.find(r => ruleKey(r) === choice)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionLabel>规则绑定</SectionLabel>
|
||||
{rules.length === 0 ? (
|
||||
<div className="empty-note">没有可绑定的已发布规则</div>
|
||||
) : (
|
||||
<div className="drw-bind-rules" role="radiogroup" aria-label="可绑定规则">
|
||||
{rules.map(r => (
|
||||
<label key={ruleKey(r)} className={`drw-bind-rule${choice === ruleKey(r) ? ' sel' : ''}`}>
|
||||
<input type="radio" name="drw-bind-rule" value={ruleKey(r)} checked={choice === ruleKey(r)} onChange={() => setChoice(ruleKey(r))} />
|
||||
<span className="drw-mono">{ruleLabel(r)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{SCOPES.map(source => {
|
||||
const bound = bindings[source]
|
||||
const needsType = source === 'document-type'
|
||||
const bindDisabled = busy || !selected || (needsType && !typeKey)
|
||||
return (
|
||||
<div key={source} className="drw-bind-row">
|
||||
<div className="drw-row drw-row-between">
|
||||
<span className="drw-profile-source">{BIND_SOURCE_LABELS[source]}</span>
|
||||
<span className="drw-mono">{bound ? ruleLabel(bound) : '未绑定'}</span>
|
||||
</div>
|
||||
<div className="drw-row">
|
||||
<Btn variant="ghost" disabled={bindDisabled} onClick={() => selected && onBind(source, selected)}>绑定所选规则</Btn>
|
||||
{bound && <Btn variant="ghost" disabled={busy} onClick={() => onUnbind(source)}>解绑</Btn>}
|
||||
</div>
|
||||
{needsType && !typeKey && <div className="drw-muted">文档类型未就绪,暂不能绑定文档类型规则</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<OperationStatus state={status} onRetry={onRetry} labels={ACTION_OP_LABELS} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,14 +5,20 @@ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerE
|
||||
import { Btn, Chip, Collapsible, SectionLabel } from '@ui'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { permCheckPayload } from '../rdmManagement'
|
||||
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,
|
||||
describeRenderFailure, diagnosticCountsByNode, diagnosticsSummaryText, downloadOptions, formatRenderTime,
|
||||
diagnosticCountsByNode, diagnosticsSummaryText, downloadOptions, formatRenderTime,
|
||||
groupDiagnostics, hasFacts, layoutMindMap, loadDeliveryDiagnostics, mergeDeliveries, moveSelection,
|
||||
nextOperation, nodeAnchorId, nodeFacts, normalizeDeliveries, normalizeDownload, normalizeProfiles,
|
||||
normalizeReviewView, partitionAnnotations, partitionRevisions, profileOptionViews, receiptStateView, renderScope,
|
||||
revisePayload, submissionIdOf, truncateMiddle, validateReviewText, visibleNodeIds, visibleOrder, walkDocTree,
|
||||
decisionEntries, decisionPayload, normalizeDocSubmission, currentBindings, bindableRules, normalizeCandidateRules,
|
||||
profileBindPayload, profileUnbindPayload, projectSettingsAllowed, docTypeKeyOf, diagnosticCodeLabel,
|
||||
type BindSource, type DecisionKind, type RuleRef,
|
||||
type DocFilter, type DocTree, type DownloadKind, type NodeFacts, type OperationKind,
|
||||
type RenderDelivery, type RenderDiagnostic, type RenderOperation, type RenderProfiles, type ReviewAnnotation,
|
||||
type ReviewRevision,
|
||||
@@ -32,18 +38,6 @@ export function saveBase64File(fileName: string, contentType: string, dataBase64
|
||||
|
||||
// ---- 可复用展示件(纯 props;测试直接 SSR) ----
|
||||
|
||||
export function RenderFailure({ error, id }: { error: unknown; id: string }) {
|
||||
const f = describeRenderFailure(error)
|
||||
return (
|
||||
<div className="drw-failure" role="alert">
|
||||
<div className="drw-failure-msg">{f.message}</div>
|
||||
<Collapsible id={`drw-failure-${id}`} trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
|
||||
<code className="rdm-audit-err-raw">{f.raw}</code>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileName({ name }: { name: string }) {
|
||||
return <span className="drw-filename" title={name}>{truncateMiddle(name, 36)}</span>
|
||||
}
|
||||
@@ -100,7 +94,7 @@ function DiagnosticGroupBody({ items, nodeTitle, onSelectNode }: {
|
||||
<button type="button" className="drw-diag-node" onClick={() => onSelectNode(d.nodeId!)} title={title}>{title}</button>
|
||||
) : <span className="drw-diag-node unlocated">未定位节点</span>}
|
||||
<span className="drw-diag-msg">{d.message}</span>
|
||||
{d.code && <span className="drw-diag-code">{d.code}</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>
|
||||
)
|
||||
@@ -314,8 +308,6 @@ function useGuardedResource<T>(requestKey: string | null, identity: number, load
|
||||
}
|
||||
}
|
||||
|
||||
interface OperationState { op: RenderOperation | null; status: 'idle' | 'running' | 'done' | 'failed'; error: string }
|
||||
|
||||
/** 写操作:每次动作新 operationId,重试复用;同一时刻只认最新一次的结果。 */
|
||||
function useOperationRunner(identity: number) {
|
||||
const guard = useRef(createRequestGuard())
|
||||
@@ -625,21 +617,6 @@ function ReviewComposer({ nodeTitle, replyTo, onCancelReply, canAnnotate, canRev
|
||||
)
|
||||
}
|
||||
|
||||
function OperationStatus({ state, onRetry, labels }: { state: OperationState; onRetry: () => void; labels: Record<string, string> }) {
|
||||
if (state.status === 'idle' || !state.op) return null
|
||||
const label = labels[state.op.kind] ?? '操作'
|
||||
if (state.status === 'running') return <div className="drw-op"><span className="mk-sweep">{label}进行中</span></div>
|
||||
if (state.status === 'failed') {
|
||||
return (
|
||||
<div className="drw-op">
|
||||
<RenderFailure error={state.error} id={`op-${state.op.kind}`} />
|
||||
<Btn variant="ghost" onClick={onRetry}>重试</Btn>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- 工作区 ----
|
||||
|
||||
const OP_LABELS: Record<string, string> = {
|
||||
@@ -672,12 +649,27 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
||||
const reviewOp = useOperationRunner(identity)
|
||||
const downloader = useDownloader(projectId, identity)
|
||||
const receiptDiagnostics = useReceiptDiagnostics(projectId, identity)
|
||||
const decisionOp = useOperationRunner(identity)
|
||||
const bindOp = useOperationRunner(identity)
|
||||
|
||||
// 身份只取会话;入口与绑定权限分别来自服务端冻结职责(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 docStatus = useGuardedResource(caps.review ? `${scope}:submission` : null, identity,
|
||||
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))
|
||||
@@ -758,6 +750,27 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
||||
revisePayload({ projectId, submissionId, nodeId: reviewTarget.id, kind, before, after, operationId })), () => review.reload())
|
||||
}
|
||||
|
||||
// 审签动作:载荷只含 projectId/docId 与 note|reason|round,轮次取界面所示的在途轮次;结果后刷新审签状态与批注。
|
||||
const decide = (kind: DecisionKind, text: string) => {
|
||||
if (!submission) return
|
||||
const { command, params } = decisionPayload(kind, projectId, docId, text, submission.round)
|
||||
decisionOp.run(kind, () => api.rdmCommand(command, params), () => { docStatus.reload(); review.reload() })
|
||||
}
|
||||
|
||||
// 规则绑定:绑定/解绑成功后刷新 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
|
||||
|
||||
@@ -819,6 +832,18 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
||||
: <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">
|
||||
@@ -892,6 +917,14 @@ function ScopedWorkspace({ projectId, docId, docTitle, onBack, identity }: Works
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 统一渲染的失败与操作状态展示:工作区与各面板共用,只渲染,不持有异步状态。
|
||||
// 原始回执只进 Collapsible 折叠区;可读文案由 describeRenderFailure 统一给出。
|
||||
import { Btn, Collapsible } from '@ui'
|
||||
import { describeRenderFailure, type RenderOperation } from '../documentRenderPresentation'
|
||||
|
||||
export interface OperationState { op: RenderOperation | null; status: 'idle' | 'running' | 'done' | 'failed'; error: string }
|
||||
|
||||
export function RenderFailure({ error, id }: { error: unknown; id: string }) {
|
||||
const f = describeRenderFailure(error)
|
||||
return (
|
||||
<div className="drw-failure" role="alert">
|
||||
<div className="drw-failure-msg">{f.message}</div>
|
||||
<Collapsible id={`drw-failure-${id}`} trigger={<span className="rdm-audit-err-toggle">原始回执</span>}>
|
||||
<code className="rdm-audit-err-raw">{f.raw}</code>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OperationStatus({ state, onRetry, labels }: { state: OperationState; onRetry: () => void; labels: Record<string, string> }) {
|
||||
if (state.status === 'idle' || !state.op) return null
|
||||
const label = labels[state.op.kind] ?? '操作'
|
||||
if (state.status === 'running') return <div className="drw-op"><span className="mk-sweep">{label}进行中</span></div>
|
||||
if (state.status === 'failed') {
|
||||
return (
|
||||
<div className="drw-op">
|
||||
<RenderFailure error={state.error} id={`op-${state.op.kind}`} />
|
||||
<Btn variant="ghost" onClick={onRetry}>重试</Btn>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
// 审阅批注/修订、交付下载。服务端字段一律视为可能被裁剪——缺失即不显示,不补造;
|
||||
// 身份与时间只由服务端填写,载荷构造不含任何身份字段。
|
||||
|
||||
import { docApprovePayload, docFinalPayload, docRejectPayload } from './rdmManagement'
|
||||
|
||||
// ---- 能力门控(与 rdmAuditReady 同语义:清单不可用一律 fail-closed) ----
|
||||
|
||||
const CORE_COMMANDS = ['rdm.render.profiles', 'rdm.render.preview', 'rdm.render.deliveries', 'rdm.render.download'] as const
|
||||
@@ -14,6 +16,11 @@ export const RDM_RENDER_COMMANDS = [
|
||||
'rdm.render.review.revise',
|
||||
'rdm.render.review.export',
|
||||
'rdm.render.approved.export',
|
||||
'rdm.review.doc.approve',
|
||||
'rdm.review.doc.reject',
|
||||
'rdm.review.doc.final',
|
||||
'rdm.render.profile.bind',
|
||||
'rdm.render.profile.unbind',
|
||||
] as const
|
||||
|
||||
export interface RenderCapabilities {
|
||||
@@ -24,10 +31,18 @@ export interface RenderCapabilities {
|
||||
revise: boolean
|
||||
reviewExport: boolean
|
||||
approvedExport: boolean
|
||||
/** 整文审签动作:各自独立门控,入口另需服务端冻结职责允许。 */
|
||||
approve: boolean
|
||||
reject: boolean
|
||||
final: boolean
|
||||
/** 规则绑定/解绑命令可用;项目设置权限另由 rdm.perm.check 判定。 */
|
||||
bind: boolean
|
||||
unbind: boolean
|
||||
}
|
||||
|
||||
export const NO_RENDER_CAPABILITIES: RenderCapabilities = {
|
||||
ready: false, submit: false, review: false, annotate: false, revise: false, reviewExport: false, approvedExport: false,
|
||||
approve: false, reject: false, final: false, bind: false, unbind: false,
|
||||
}
|
||||
|
||||
export function renderCapabilities(cmds: { id?: string; enabled?: boolean }[] | null | undefined): RenderCapabilities {
|
||||
@@ -43,6 +58,11 @@ export function renderCapabilities(cmds: { id?: string; enabled?: boolean }[] |
|
||||
revise: on('rdm.render.review.revise'),
|
||||
reviewExport: on('rdm.render.review.export'),
|
||||
approvedExport: on('rdm.render.approved.export'),
|
||||
approve: on('rdm.review.doc.approve'),
|
||||
reject: on('rdm.review.doc.reject'),
|
||||
final: on('rdm.review.doc.final'),
|
||||
bind: on('rdm.render.profile.bind'),
|
||||
unbind: on('rdm.render.profile.unbind'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,10 +95,19 @@ function readable(s: string): string {
|
||||
return t.length > 240 ? `${t.slice(0, 240)}…` : t
|
||||
}
|
||||
|
||||
/** 服务端稳定拒绝码 → 可读文案(码本身不作为正文显示;原始回执仍在折叠区)。 */
|
||||
const REFUSAL_MESSAGES: { code: string; message: string }[] = [
|
||||
{ code: 'controlled_requires_exact_profile', message: '受控文档只能使用其精确绑定的规则,不能回退到项目或公司默认规则' },
|
||||
{ code: 'profile_not_published', message: '该规则尚未发布,不能用于绑定或生成' },
|
||||
{ code: 'binding_mismatch', message: '当前绑定已变化,请刷新规则列表后重试' },
|
||||
]
|
||||
|
||||
export function describeRenderFailure(err: unknown): RenderFailureView {
|
||||
const raw = err instanceof Error ? err.message : String(err ?? '')
|
||||
if (/^40[13]:/.test(raw)) return { message: '登录已失效,请重新登录后再操作', raw }
|
||||
if (/unknown or disabled command|commands disabled/.test(raw)) return { message: '当前服务未提供文档渲染能力', raw }
|
||||
const refusal = REFUSAL_MESSAGES.find(r => raw.includes(r.code))
|
||||
if (refusal) return { message: refusal.message, raw }
|
||||
const m = raw.match(/^\d{3}:\s*([\s\S]*)$/)
|
||||
const body = (m ? m[1] : raw).trim()
|
||||
if (body.startsWith('{') || body.startsWith('[')) {
|
||||
@@ -261,6 +290,22 @@ interface DiagnosticGroup { severity: DiagnosticSeverity; label: string; items:
|
||||
|
||||
const SEVERITY_LABELS: Record<DiagnosticSeverity, string> = { error: '错误', warning: '警告', info: '提示' }
|
||||
|
||||
/** 渲染器稳定诊断代码的中文短标签;未知代码原样显示,不猜测含义。 */
|
||||
const DIAGNOSTIC_CODE_LABELS: Record<string, string> = {
|
||||
required_content_missing: '缺少必填内容',
|
||||
intentionally_empty: '有意留空',
|
||||
template_placeholder_left: '模板占位符未替换',
|
||||
mapping_fallback: '映射已回退',
|
||||
signature_data_missing: '缺少签署数据',
|
||||
mapping_unresolved: '映射未解析',
|
||||
mapping_ambiguous: '映射存在歧义',
|
||||
approval_invalid: '审签记录无效',
|
||||
}
|
||||
|
||||
export function diagnosticCodeLabel(code: string): string {
|
||||
return DIAGNOSTIC_CODE_LABELS[code] ?? code
|
||||
}
|
||||
|
||||
export function groupDiagnostics(list: RenderDiagnostic[]): DiagnosticGroup[] {
|
||||
return (['error', 'warning', 'info'] as const)
|
||||
.map(severity => ({ severity, label: SEVERITY_LABELS[severity], items: list.filter(d => d.severity === severity) }))
|
||||
@@ -327,6 +372,7 @@ export async function loadDeliveryDiagnostics(call: RdmCall, projectId: string,
|
||||
// ---- 操作编号与请求守卫(异步状态唯一所有者 + 过期响应丢弃) ----
|
||||
|
||||
export type OperationKind = 'preview' | 'submit' | 'review-export' | 'approved-export' | 'annotate' | 'revise'
|
||||
| 'approve' | 'reject' | 'final' | 'bind' | 'unbind'
|
||||
export interface RenderOperation { kind: OperationKind; operationId: string }
|
||||
|
||||
function mintOperationId(): string {
|
||||
@@ -875,15 +921,21 @@ export function currentSubmissionId(choice: string | null, docStatus: unknown, d
|
||||
|
||||
export const DELIVERY_MODE_LABELS: Record<string, string> = { preview: '预览', review: '审阅', approved: '批准' }
|
||||
|
||||
const DOWNLOAD_ORDER = ['markdown', 'docx', 'review-docx', 'manifest', 'diagnostics'] as const
|
||||
const DOWNLOAD_ORDER = ['markdown', 'docx', 'review-docx', 'manifest', 'diagnostics', 'package'] as const
|
||||
export type DownloadKind = (typeof DOWNLOAD_ORDER)[number]
|
||||
|
||||
/**
|
||||
* 完整包由服务端按需打包(不在 availableKinds 中出现),只在交付已生成(ready)时提供;
|
||||
* 权限在下载时由服务端重新鉴权,与单件下载同一路径。
|
||||
*/
|
||||
export function downloadOptions(d: RenderDelivery): { kind: DownloadKind; label: string }[] {
|
||||
const docxLabel = d.mode === 'approved' ? '批准稿 DOCX' : d.mode === 'review' ? '审阅稿 DOCX' : '预览 DOCX'
|
||||
const labels: Record<DownloadKind, string> = {
|
||||
markdown: 'Markdown', docx: docxLabel, 'review-docx': '审阅留痕 DOCX', manifest: '清单(manifest)', diagnostics: '诊断报告',
|
||||
package: '完整交付包(ZIP)',
|
||||
}
|
||||
return DOWNLOAD_ORDER.filter(k => d.availableKinds.includes(k)).map(kind => ({ kind, label: labels[kind] }))
|
||||
const available = (k: DownloadKind) => (k === 'package' ? d.status === 'ready' : d.availableKinds.includes(k))
|
||||
return DOWNLOAD_ORDER.filter(available).map(kind => ({ kind, label: labels[kind] }))
|
||||
}
|
||||
|
||||
interface RenderDownloadPayload { fileName: string; contentType: string; dataBase64: string; sha256: string }
|
||||
@@ -917,3 +969,158 @@ export function formatRenderTime(iso: string): string {
|
||||
const t = Date.parse(iso)
|
||||
return Number.isNaN(t) ? '' : new Date(t).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
// ---- 整文审签:入口由服务端冻结职责与会话身份共同决定;载荷只含 projectId/docId 与 note|reason|round ----
|
||||
|
||||
export type DecisionKind = 'approve' | 'reject' | 'final'
|
||||
|
||||
/** rdm.review.doc.status 的在途整文提交(只保留入口判定与展示需要的字段)。 */
|
||||
export interface DocSubmissionView {
|
||||
submissionId: string
|
||||
round: number
|
||||
state: string
|
||||
author: string
|
||||
reviewers: string[]
|
||||
approver: string
|
||||
reviews: Record<string, boolean>
|
||||
}
|
||||
|
||||
/** 服务端 domain.Roles 无 json 标签,字段以 Go 名称输出(Author/Reviewers/Approver),兼容小写。 */
|
||||
export function normalizeDocSubmission(raw: unknown): DocSubmissionView | null {
|
||||
const rec = obj(obj(raw)?.submission)
|
||||
const submissionId = str(rec?.submissionId)
|
||||
const round = typeof rec?.round === 'number' && Number.isInteger(rec.round) && rec.round > 0 ? rec.round : 0
|
||||
if (!rec || !submissionId || round === 0) return null
|
||||
const duties = obj(rec.duties) ?? {}
|
||||
const reviewers = arr(duties.Reviewers ?? duties.reviewers).filter((v): v is string => typeof v === 'string' && v !== '')
|
||||
const reviews: Record<string, boolean> = {}
|
||||
for (const [who, ok] of Object.entries(obj(rec.reviews) ?? {})) reviews[who] = ok === true
|
||||
return {
|
||||
submissionId, round, state: str(rec.state) ?? '',
|
||||
author: str(duties.Author) ?? str(duties.author) ?? '',
|
||||
approver: str(duties.Approver) ?? str(duties.approver) ?? '',
|
||||
reviewers, reviews,
|
||||
}
|
||||
}
|
||||
|
||||
export interface DecisionEntries { approve: boolean; reject: boolean; final: boolean }
|
||||
const NO_DECISIONS: DecisionEntries = { approve: false, reject: false, final: false }
|
||||
|
||||
/**
|
||||
* 入口 = 命令可用 ∧ 冻结状态为在途 ∧ 会话身份在本轮冻结名单内(与服务端判定同规则:
|
||||
* 审核人未审可通过、编制人不可自审、驳回取审核人/批准人/编制人、终批须批准人且全员已审)。
|
||||
* 服务端仍逐次复核;这里只决定是否显示入口,身份取自会话,不由客户端写入载荷。
|
||||
*/
|
||||
export function decisionEntries(
|
||||
sub: DocSubmissionView | null, me: string | undefined, caps: Pick<RenderCapabilities, 'approve' | 'reject' | 'final'>,
|
||||
): DecisionEntries {
|
||||
if (!sub || !me || sub.state !== 'submitted') return NO_DECISIONS
|
||||
const isReviewer = sub.reviewers.includes(me) || Object.prototype.hasOwnProperty.call(sub.reviews, me)
|
||||
const allApproved = Object.values(sub.reviews).every(Boolean)
|
||||
return {
|
||||
approve: caps.approve && isReviewer && sub.reviews[me] !== true && me !== sub.author,
|
||||
reject: caps.reject && (isReviewer || me === sub.author || me === sub.approver),
|
||||
final: caps.final && me === sub.approver && allApproved,
|
||||
}
|
||||
}
|
||||
|
||||
/** 审签载荷:approve/final 的说明可选,reject 的原因由调用方先校验非空;round 绑定到界面所示轮次。 */
|
||||
export function decisionPayload(kind: DecisionKind, projectId: string, docId: string, text: string, round: number): { command: string; params: Record<string, unknown> } {
|
||||
const t = text.trim()
|
||||
switch (kind) {
|
||||
case 'approve': return { command: 'rdm.review.doc.approve', params: docApprovePayload(projectId, docId, t || undefined) }
|
||||
case 'reject': return { command: 'rdm.review.doc.reject', params: docRejectPayload(projectId, docId, t || undefined) }
|
||||
case 'final': return { command: 'rdm.review.doc.final', params: { ...docFinalPayload(projectId, docId, round), ...(t ? { reason: t } : {}) } }
|
||||
default: {
|
||||
const exhaustive: never = kind
|
||||
throw new Error(`unknown decision kind: ${String(exhaustive)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 规则绑定:三个作用域;候选 = 服务端选项中已发布项 ∪ 项目已发布候选(去重) ----
|
||||
|
||||
export type BindSource = 'document' | 'document-type' | 'project-default'
|
||||
export const BIND_SOURCE_LABELS: Record<BindSource, string> = {
|
||||
document: '本文档', 'document-type': '文档类型', 'project-default': '项目默认',
|
||||
}
|
||||
|
||||
export interface RuleRef { profileId: string; version: string }
|
||||
export interface BindableRule extends RuleRef { label: string }
|
||||
|
||||
/** 当前绑定:只取服务端 rdm.render.profiles 选项中的绑定来源(已绑定才会出现)。 */
|
||||
export function currentBindings(p: RenderProfiles): Partial<Record<BindSource, RuleRef>> {
|
||||
const out: Partial<Record<BindSource, RuleRef>> = {}
|
||||
for (const o of p.options) {
|
||||
if (o.source === 'document' || o.source === 'document-type' || o.source === 'project-default') {
|
||||
if (!out[o.source]) out[o.source] = { profileId: o.profileId, version: o.version }
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** rdm.render.profile.candidate.list:发布记录(publication)存在即为项目已发布的规则快照。 */
|
||||
export function normalizeCandidateRules(raw: unknown): RuleRef[] {
|
||||
const out: RuleRef[] = []
|
||||
for (const item of arr(obj(raw)?.candidates)) {
|
||||
const pub = obj(obj(item)?.publication)
|
||||
const profileId = str(pub?.profileId)
|
||||
const version = versionOf(pub?.version)
|
||||
if (profileId && version) out.push({ profileId, version })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function ruleLabel(r: RuleRef): string {
|
||||
return `${r.profileId}@${r.version}`
|
||||
}
|
||||
|
||||
export function bindableRules(p: RenderProfiles, candidates: RuleRef[]): BindableRule[] {
|
||||
const seen = new Set<string>()
|
||||
const out: BindableRule[] = []
|
||||
const add = (r: RuleRef) => {
|
||||
const key = ruleLabel(r)
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
out.push({ profileId: r.profileId, version: r.version, label: key })
|
||||
}
|
||||
for (const o of p.options) {
|
||||
if (o.state === 'published' && o.publicationAllowed) add({ profileId: o.profileId, version: o.version })
|
||||
}
|
||||
for (const c of candidates) add(c)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 作用域对应的 key:文档=docId,文档类型=字典类型键,项目默认=projectId;缺失则无法绑定。 */
|
||||
function bindingKey(source: BindSource, ctx: { projectId: string; docId: string; typeKey?: string }): string | undefined {
|
||||
if (source === 'document') return ctx.docId || undefined
|
||||
if (source === 'document-type') return ctx.typeKey || undefined
|
||||
return ctx.projectId || undefined
|
||||
}
|
||||
|
||||
export function profileBindPayload(input: { projectId: string; source: BindSource; docId: string; typeKey?: string; rule: RuleRef }): Record<string, unknown> | undefined {
|
||||
const key = bindingKey(input.source, input)
|
||||
if (!key) return undefined
|
||||
return { projectId: input.projectId, source: input.source, key, profileId: input.rule.profileId, version: input.rule.version }
|
||||
}
|
||||
|
||||
/** 解绑的前置条件(profileId+version 成对):服务端据此拒绝「界面所示绑定已变化」的旧请求。 */
|
||||
export function profileUnbindPayload(input: { projectId: string; source: BindSource; docId: string; typeKey?: string; precondition?: RuleRef }): Record<string, unknown> | undefined {
|
||||
const key = bindingKey(input.source, input)
|
||||
if (!key) return undefined
|
||||
const pre = input.precondition
|
||||
return { projectId: input.projectId, source: input.source, key, ...(pre ? { profileId: pre.profileId, version: pre.version } : {}) }
|
||||
}
|
||||
|
||||
/** rdm.inspect.object(kind=doc)的字典文档类型键,文档类型规则绑定的作用域 key。 */
|
||||
export function docTypeKeyOf(raw: unknown): string | undefined {
|
||||
return str(obj(raw)?.typeKey)
|
||||
}
|
||||
|
||||
/** rdm.perm.check 矩阵:仅 project.settings 且 allowed=true 才放行规则绑定入口。 */
|
||||
export function projectSettingsAllowed(raw: unknown): boolean {
|
||||
return arr(obj(raw)?.actions).some(item => {
|
||||
const a = obj(item)
|
||||
return a?.action === 'project.settings' && a.allowed === true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -752,6 +752,11 @@ h4.drw-reader-heading, h5.drw-reader-heading { font-size: var(--fs-ui-base); }
|
||||
.drw-decision { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
|
||||
.drw-downloads { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.drw-download { font-size: var(--fs-ui-sm); padding: 4px 8px; }
|
||||
.drw-bind-rules { display: flex; flex-direction: column; gap: 4px; }
|
||||
.drw-bind-rule { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text); }
|
||||
.drw-bind-rule:hover { background: var(--bg-hover); }
|
||||
.drw-bind-rule.sel { background: var(--bg-active); }
|
||||
.drw-bind-row { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border-soft); font-size: var(--fs-ui-sm); }
|
||||
.drw-composer { display: flex; flex-direction: column; gap: 6px; padding-top: 8px; border-top: 1px solid var(--border-soft); }
|
||||
.drw-composer-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-ui-sm); }
|
||||
.drw-composer-node { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// 统一渲染 Task 11(续):整文审签入口、完整包下载、规则绑定与解绑、诊断代码分组。
|
||||
// 入口只在服务端冻结职责允许时出现;载荷只含 projectId/docId 与 note|reason|round;
|
||||
// 拒绝以可读文案显示,原始回执只进折叠区。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import {
|
||||
RDM_RENDER_COMMANDS, NO_RENDER_CAPABILITIES, renderCapabilities,
|
||||
normalizeDocSubmission, decisionEntries, decisionPayload,
|
||||
normalizeDeliveries, downloadOptions,
|
||||
describeRenderFailure, diagnosticCodeLabel, normalizeDiagnostics,
|
||||
normalizeCandidateRules, bindableRules, currentBindings, profileBindPayload, profileUnbindPayload,
|
||||
projectSettingsAllowed, createRequestGuard, normalizeProfiles,
|
||||
type DecisionEntries,
|
||||
} from '../src/documentRenderPresentation'
|
||||
import { ReviewDecisionPanel, ProfileBindingPanel } from '../src/components/DocumentRenderActions'
|
||||
import { DiagnosticsList } from '../src/components/DocumentRenderWorkspace'
|
||||
|
||||
const cmd = (id: string, enabled = true) => ({ id, enabled })
|
||||
const idle = { op: null, status: 'idle' as const, error: '' }
|
||||
|
||||
const statusPayload = {
|
||||
docId: 'd1',
|
||||
submission: {
|
||||
submissionId: 'ds_d1_2', round: 2, state: 'submitted', createdBy: 'author-1',
|
||||
// Go 结构体 domain.Roles 无 json 标签:字段名原样输出为 Author/Reviewers/Approver。
|
||||
duties: { Author: 'author-1', Reviewers: ['rev-1', 'rev-2'], Approver: 'appr-1' },
|
||||
reviews: { 'rev-1': true, 'rev-2': false },
|
||||
},
|
||||
}
|
||||
const sub = normalizeDocSubmission(statusPayload)!
|
||||
const allowedCaps = { approve: true, reject: true, final: true }
|
||||
const none: DecisionEntries = { approve: false, reject: false, final: false }
|
||||
|
||||
describe('审签与绑定能力门控(命令清单逐项判定)', () => {
|
||||
it('每项审签/绑定命令独立门控,核心渲染能力不受影响', () => {
|
||||
const all = RDM_RENDER_COMMANDS.map(id => cmd(id))
|
||||
expect(renderCapabilities(all)).toMatchObject({ ready: true, approve: true, reject: true, final: true, bind: true, unbind: true })
|
||||
const noFinal = renderCapabilities(all.filter(c => c.id !== 'rdm.review.doc.final'))
|
||||
expect(noFinal).toMatchObject({ ready: true, approve: true, reject: true, final: false })
|
||||
expect(renderCapabilities(all.filter(c => c.id !== 'rdm.render.profile.unbind'))).toMatchObject({ bind: true, unbind: false })
|
||||
expect(NO_RENDER_CAPABILITIES).toMatchObject({ approve: false, reject: false, final: false, bind: false, unbind: false })
|
||||
expect(renderCapabilities(RDM_RENDER_COMMANDS.map(id => cmd(id, false))).final).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('整文提交归一化(rdm.review.doc.status)', () => {
|
||||
it('兼容服务端 Go 字段名 Author/Reviewers/Approver,并保留冻结的审核进度', () => {
|
||||
expect(sub).toEqual({
|
||||
submissionId: 'ds_d1_2', round: 2, state: 'submitted', author: 'author-1',
|
||||
reviewers: ['rev-1', 'rev-2'], approver: 'appr-1', reviews: { 'rev-1': true, 'rev-2': false },
|
||||
})
|
||||
})
|
||||
it('无提交或轮次非法即无记录,不补造', () => {
|
||||
expect(normalizeDocSubmission({ docId: 'd1', submission: null })).toBeNull()
|
||||
expect(normalizeDocSubmission({ submission: { round: 0, submissionId: 'x' } })).toBeNull()
|
||||
expect(normalizeDocSubmission({ submission: { round: 1 } })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('审签入口:只在服务端冻结职责允许时出现', () => {
|
||||
it('审核人未审时可通过;已通过的审核人仅可驳回;编制人不可自审', () => {
|
||||
expect(decisionEntries(sub, 'rev-2', allowedCaps)).toEqual({ approve: true, reject: true, final: false })
|
||||
expect(decisionEntries(sub, 'rev-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
|
||||
expect(decisionEntries(sub, 'author-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
|
||||
})
|
||||
it('批准人仅在全员审核通过后可终批;批准人本身可驳回', () => {
|
||||
expect(decisionEntries(sub, 'appr-1', allowedCaps)).toEqual({ approve: false, reject: true, final: false })
|
||||
const allDone = { ...sub, reviews: { 'rev-1': true, 'rev-2': true } }
|
||||
expect(decisionEntries(allDone, 'appr-1', allowedCaps)).toEqual({ approve: false, reject: true, final: true })
|
||||
})
|
||||
it('非冻结名单成员、无身份、非在途状态、能力未开一律无入口', () => {
|
||||
expect(decisionEntries(sub, 'outsider', allowedCaps)).toEqual(none)
|
||||
expect(decisionEntries(sub, undefined, allowedCaps)).toEqual(none)
|
||||
expect(decisionEntries({ ...sub, state: 'final' }, 'rev-2', allowedCaps)).toEqual(none)
|
||||
expect(decisionEntries(sub, 'rev-2', { approve: false, reject: true, final: true })).toEqual({ approve: false, reject: true, final: false })
|
||||
expect(decisionEntries(null, 'rev-2', allowedCaps)).toEqual(none)
|
||||
})
|
||||
})
|
||||
|
||||
describe('审签载荷:只含 projectId/docId 与 note|reason|round', () => {
|
||||
it('approve 的 note、reject 的 reason、final 的 round/reason;空说明不携带', () => {
|
||||
expect(decisionPayload('approve', 'p1', 'd1', ' 通过 ', 2)).toEqual({ command: 'rdm.review.doc.approve', params: { projectId: 'p1', docId: 'd1', note: '通过' } })
|
||||
expect(decisionPayload('approve', 'p1', 'd1', ' ', 2)).toEqual({ command: 'rdm.review.doc.approve', params: { projectId: 'p1', docId: 'd1' } })
|
||||
expect(decisionPayload('reject', 'p1', 'd1', '依据不足', 2)).toEqual({ command: 'rdm.review.doc.reject', params: { projectId: 'p1', docId: 'd1', reason: '依据不足' } })
|
||||
expect(decisionPayload('final', 'p1', 'd1', '', 2)).toEqual({ command: 'rdm.review.doc.final', params: { projectId: 'p1', docId: 'd1', round: 2 } })
|
||||
expect(decisionPayload('final', 'p1', 'd1', '终批', 2)).toEqual({ command: 'rdm.review.doc.final', params: { projectId: 'p1', docId: 'd1', round: 2, reason: '终批' } })
|
||||
})
|
||||
it('任何审签载荷都不携带身份字段(身份只认会话)', () => {
|
||||
const identityKey = /actor|user|owner|identity|approvedBy|reviewer|approver|author|signature/i
|
||||
for (const kind of ['approve', 'reject', 'final'] as const) {
|
||||
const keys = Object.keys(decisionPayload(kind, 'p1', 'd1', 'x', 2).params)
|
||||
expect(keys.filter(k => identityKey.test(k))).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('完整交付包(rdm.render.download kind=package)', () => {
|
||||
const ready = normalizeDeliveries({
|
||||
items: [{ deliveryId: 'd9', mode: 'approved', status: 'ready', profileId: 'x', profileVersion: 1, createdAt: '', availableKinds: ['manifest', 'docx', 'review-docx', 'markdown'] }],
|
||||
}).items[0]
|
||||
it('交付已生成(ready)时完整包位于单件下载之后', () => {
|
||||
expect(downloadOptions(ready).map(o => o.kind)).toEqual(['markdown', 'docx', 'review-docx', 'manifest', 'package'])
|
||||
expect(downloadOptions(ready).at(-1)?.label).toBe('完整交付包(ZIP)')
|
||||
})
|
||||
it('非 ready 的交付不提供完整包', () => {
|
||||
expect(downloadOptions({ ...ready, status: 'generation-failed' }).map(o => o.kind)).not.toContain('package')
|
||||
expect(downloadOptions({ ...ready, status: 'frozen' }).map(o => o.kind)).not.toContain('package')
|
||||
})
|
||||
})
|
||||
|
||||
describe('服务端拒绝码:翻译为可读文案,原始回执保留', () => {
|
||||
it('受控、未发布、绑定变化三类拒绝', () => {
|
||||
const controlled = describeRenderFailure(new Error('400: controlled_requires_exact_profile: controlled document d1 requires its exact template profile'))
|
||||
expect(controlled.message).toContain('受控文档')
|
||||
expect(controlled.raw).toContain('controlled_requires_exact_profile')
|
||||
expect(describeRenderFailure(new Error('400: profile_not_published: x@1 is not published (missing)')).message).toContain('尚未发布')
|
||||
expect(describeRenderFailure(new Error('400: binding_mismatch: document d1 is bound to a@1')).message).toContain('刷新')
|
||||
})
|
||||
it('审签拒绝保持服务端原文(去掉状态码前缀)', () => {
|
||||
expect(describeRenderFailure(new Error('400: 仅本轮(第 2 轮)冻结名单内的审核人可审批')))
|
||||
.toMatchObject({ message: '仅本轮(第 2 轮)冻结名单内的审核人可审批' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('诊断:八类代码的中文标签与严重度分组', () => {
|
||||
const codes = ['required_content_missing', 'intentionally_empty', 'template_placeholder_left', 'mapping_fallback',
|
||||
'signature_data_missing', 'mapping_unresolved', 'mapping_ambiguous', 'approval_invalid']
|
||||
it('已知代码显示中文标签,未知代码原样保留', () => {
|
||||
for (const code of codes) expect(diagnosticCodeLabel(code)).not.toBe(code)
|
||||
expect(diagnosticCodeLabel('custom_x')).toBe('custom_x')
|
||||
})
|
||||
it('错误在前、警告在后;节点按标题定位,代码标签可见', () => {
|
||||
const diags = normalizeDiagnostics([
|
||||
{ code: 'mapping_unresolved', severity: 'error', message: '映射未解析', nodeId: 'n1', suggestion: '补充映射' },
|
||||
{ code: 'template_placeholder_left', severity: 'warning', message: '占位符未替换', nodeId: 'n2' },
|
||||
])
|
||||
const html = renderToStaticMarkup(createElement(DiagnosticsList, {
|
||||
diagnostics: diags, nodeTitle: id => `章节 ${id}`, onSelectNode: () => {}, idPrefix: 'p',
|
||||
}))
|
||||
expect(html.indexOf('错误(1)')).toBeGreaterThanOrEqual(0)
|
||||
expect(html.indexOf('错误(1)')).toBeLessThan(html.indexOf('警告(1)'))
|
||||
expect(html).toContain(diagnosticCodeLabel('mapping_unresolved'))
|
||||
expect(html).toContain('章节 n1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则绑定:候选与服务端选项合并、当前绑定、载荷', () => {
|
||||
const profiles = normalizeProfiles({
|
||||
projectId: 'p1', docId: 'd1', controlled: false, fallbackAllowed: true,
|
||||
options: [
|
||||
{ source: 'organization-default', profileId: 'org', version: '3', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true },
|
||||
{ source: 'document', profileId: 'doc', version: '2', state: 'published', publicationAllowed: true, controlled: false, fallbackAllowed: true },
|
||||
{ source: 'project-default', profileId: 'prj', version: '5', state: 'blocked', publicationAllowed: true, controlled: false, fallbackAllowed: true, blockedReason: '资产校验未通过' },
|
||||
],
|
||||
})
|
||||
const candidates = normalizeCandidateRules({
|
||||
projectId: 'p1',
|
||||
candidates: [
|
||||
{ candidateId: 'c1', state: 'published', publication: { profileId: 'prj.std', version: '2', decision: 'ok' } },
|
||||
{ candidateId: 'c2', state: 'draft' },
|
||||
{ candidateId: 'c3', state: 'published', publication: { profileId: 'prj.std', version: '2' } },
|
||||
],
|
||||
})
|
||||
it('可绑定规则 = 服务端选项中已发布项 ∪ 项目已发布候选(去重,保序)', () => {
|
||||
expect(bindableRules(profiles, candidates).map(r => `${r.profileId}@${r.version}`)).toEqual(['org@3', 'doc@2', 'prj.std@2'])
|
||||
})
|
||||
it('当前绑定只取服务端选项中的绑定来源', () => {
|
||||
expect(currentBindings(profiles)).toEqual({
|
||||
document: { profileId: 'doc', version: '2' },
|
||||
'project-default': { profileId: 'prj', version: '5' },
|
||||
})
|
||||
})
|
||||
it('绑定载荷:key 按作用域取值;文档类型缺失则不生成载荷', () => {
|
||||
const rule = { profileId: 'prj.std', version: '2' }
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'document', docId: 'd1', typeKey: 'SDD', rule }))
|
||||
.toEqual({ projectId: 'p1', source: 'document', key: 'd1', profileId: 'prj.std', version: '2' })
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'document-type', docId: 'd1', typeKey: 'SDD', rule }))
|
||||
.toEqual({ projectId: 'p1', source: 'document-type', key: 'SDD', profileId: 'prj.std', version: '2' })
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'project-default', docId: 'd1', typeKey: undefined, rule }))
|
||||
.toEqual({ projectId: 'p1', source: 'project-default', key: 'p1', profileId: 'prj.std', version: '2' })
|
||||
expect(profileBindPayload({ projectId: 'p1', source: 'document-type', docId: 'd1', typeKey: undefined, rule })).toBeUndefined()
|
||||
})
|
||||
it('解绑带前置条件(profileId+version 成对);无绑定时不带前置条件', () => {
|
||||
expect(profileUnbindPayload({ projectId: 'p1', source: 'document', docId: 'd1', typeKey: 'SDD', precondition: { profileId: 'doc', version: '2' } }))
|
||||
.toEqual({ projectId: 'p1', source: 'document', key: 'd1', profileId: 'doc', version: '2' })
|
||||
expect(profileUnbindPayload({ projectId: 'p1', source: 'project-default', docId: 'd1', typeKey: 'SDD' }))
|
||||
.toEqual({ projectId: 'p1', source: 'project-default', key: 'p1' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('项目设置权限:以服务端 rdm.perm.check 的 project.settings 为准', () => {
|
||||
it('仅 allowed=true 的 project.settings 才放行', () => {
|
||||
expect(projectSettingsAllowed({ projectId: 'p1', member: 'u', role: 'member', profRole: null, actions: [{ action: 'project.settings', allowed: true, source: 'override' }] })).toBe(true)
|
||||
expect(projectSettingsAllowed({ actions: [{ action: 'project.settings', allowed: false, source: 'x' }] })).toBe(false)
|
||||
expect(projectSettingsAllowed({ actions: [{ action: 'doc.read', allowed: true, source: 'x' }] })).toBe(false)
|
||||
expect(projectSettingsAllowed(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('请求守卫:过期的审签/绑定结果被丢弃', () => {
|
||||
it('同一作用域的新请求使旧结果失效;取消后全部失效', () => {
|
||||
const g = createRequestGuard()
|
||||
const first = g.begin('scope')
|
||||
const second = g.begin('scope')
|
||||
expect(g.current(first, 'scope')).toBe(false)
|
||||
expect(g.current(second, 'scope')).toBe(true)
|
||||
g.cancel()
|
||||
expect(g.current(second, 'scope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('审签面板:入口门控与拒绝显示', () => {
|
||||
const noop = () => {}
|
||||
it('无可用入口时不出现任何动作按钮,只显示轮次与进度', () => {
|
||||
const html = renderToStaticMarkup(createElement(ReviewDecisionPanel, {
|
||||
sub, entries: none, busy: false, status: idle, onRetry: noop, onDecide: noop,
|
||||
}))
|
||||
expect(html).toContain('第 2 轮')
|
||||
expect(html).not.toContain('审核通过')
|
||||
expect(html).not.toContain('驳回')
|
||||
expect(html).not.toContain('终批')
|
||||
})
|
||||
it('只渲染服务端允许的入口;失败以可读文案显示并保留原始回执', () => {
|
||||
const entries: DecisionEntries = { approve: true, reject: true, final: false }
|
||||
const html = renderToStaticMarkup(createElement(ReviewDecisionPanel, {
|
||||
sub, entries, busy: false,
|
||||
status: { op: { kind: 'reject', operationId: 'op_1' }, status: 'failed', error: '400: 当前身份无权驳回' },
|
||||
onRetry: noop, onDecide: noop,
|
||||
}))
|
||||
expect(html).toContain('审核通过')
|
||||
expect(html).toContain('驳回')
|
||||
expect(html).not.toContain('终批')
|
||||
expect(html).toContain('当前身份无权驳回')
|
||||
expect(html).toContain('原始回执')
|
||||
})
|
||||
})
|
||||
|
||||
describe('规则绑定面板:当前绑定、解绑入口与文档类型禁用', () => {
|
||||
const noop = () => {}
|
||||
const rules = [{ profileId: 'prj.std', version: '2', label: 'prj.std v2' }]
|
||||
it('显示三个作用域的当前绑定;可解绑已绑定项;未选规则时绑定按钮禁用', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProfileBindingPanel, {
|
||||
rules, bindings: { document: { profileId: 'doc', version: '2' } }, typeKey: undefined,
|
||||
busy: false, status: idle, onRetry: noop, onBind: noop, onUnbind: noop,
|
||||
}))
|
||||
expect(html).toContain('本文档')
|
||||
expect(html).toContain('文档类型')
|
||||
expect(html).toContain('项目默认')
|
||||
expect(html).toContain('doc@2')
|
||||
expect(html).toContain('解绑')
|
||||
expect(html).toContain('disabled')
|
||||
})
|
||||
it('文档类型未知时,文档类型行提示无法绑定', () => {
|
||||
const html = renderToStaticMarkup(createElement(ProfileBindingPanel, {
|
||||
rules, bindings: {}, typeKey: undefined, busy: false, status: idle, onRetry: noop, onBind: noop, onUnbind: noop,
|
||||
}))
|
||||
expect(html).toContain('文档类型未就绪')
|
||||
})
|
||||
})
|
||||
|
||||
describe('能力表与类型守卫', () => {
|
||||
it('未知 source 的绑定不进入当前绑定', () => {
|
||||
const p = normalizeProfiles({ options: [{ source: 'template', profileId: 'company.TPL', version: '1', state: 'published', publicationAllowed: true, controlled: true, fallbackAllowed: false }] })
|
||||
expect(currentBindings(p)).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
type RenderProfiles, type DocTree,
|
||||
} from '../src/documentRenderPresentation'
|
||||
import {
|
||||
ProfileOptionList, DiagnosticsList, AnnotationGroups, DownloadButtons, RenderFailure, RenderReceipt,
|
||||
ProfileOptionList, DiagnosticsList, AnnotationGroups, DownloadButtons, RenderReceipt,
|
||||
} from '../src/components/DocumentRenderWorkspace'
|
||||
import { RenderFailure } from '../src/components/renderFeedback'
|
||||
|
||||
const cmd = (id: string, enabled = true) => ({ id, enabled })
|
||||
|
||||
@@ -27,7 +28,10 @@ describe('渲染能力门控(fail-closed)', () => {
|
||||
it('核心命令齐备才 ready;审阅/批注各自独立门控', () => {
|
||||
const all = RDM_RENDER_COMMANDS.map(id => cmd(id))
|
||||
const caps = renderCapabilities(all)
|
||||
expect(caps).toEqual({ ready: true, submit: true, review: true, annotate: true, revise: true, reviewExport: true, approvedExport: true })
|
||||
expect(caps).toEqual({
|
||||
ready: true, submit: true, review: true, annotate: true, revise: true, reviewExport: true, approvedExport: true,
|
||||
approve: true, reject: true, final: true, bind: true, unbind: true,
|
||||
})
|
||||
const noReview = renderCapabilities(all.filter(c => c.id !== 'rdm.render.review.view'))
|
||||
expect(noReview.ready).toBe(true)
|
||||
expect(noReview.review).toBe(false)
|
||||
|
||||
Reference in New Issue
Block a user