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>
126 lines
5.6 KiB
TypeScript
126 lines
5.6 KiB
TypeScript
// 统一渲染 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} />
|
||
</>
|
||
)
|
||
}
|