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:
weichen
2026-10-17 02:32:45 +00:00
co-authored by Cursor
parent d5e6b12e75
commit 7f3538da22
7 changed files with 710 additions and 36 deletions
+125
View File
@@ -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} />
</>
)
}
+65 -32
View File
@@ -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>
+33
View File
@@ -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
}
+209 -2
View File
@@ -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
})
}
+5
View File
@@ -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); }
+267
View File
@@ -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({})
})
})
+6 -2
View File
@@ -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)