Files
Agent-System-Frontend/src/components/DocumentRenderActions.tsx
T
weichenandCursor 7f3538da22 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>
2026-10-17 02:32:45 +00:00

126 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 统一渲染 Task 11:整文审签与规则绑定面板。纯展示:入口、载荷与校验来自 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} />
</>
)
}