Files
Agent-System/web/src/components/doc/DocGovActionBar.tsx
T
weichenandCursor ab9fccded4 feat(web): disable unmanaged approval actions
Hide submit/review/approve on none docs, surface the blocked-run hint in
chat, and warn that unmanaged duties cannot be added after activation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 08:00:21 +00:00

607 lines
22 KiB
TypeScript

import React, { useState } from 'react';
import { DocumentAST } from '../../types/block';
import { UserRole } from '../../types/docgov';
import { canShowDocGovApprovalAction, isUnmanagedDocDuties } from '../../lib/docGovActionVisibility';
import {
Send,
CheckCircle2,
XCircle,
Award,
UserCheck,
Lock,
Globe,
Share2,
Undo2,
Shield,
} from 'lucide-react';
interface DocGovActionBarProps {
ast: DocumentAST;
currentRole: UserRole;
currentUserId?: number;
canOverrideVisibility?: boolean;
onOpenVisibilityModal?: () => void;
onClaim: () => void;
onSubmitReview: () => void;
onPassReview: () => void;
onRejectReview: (reason: string) => void;
onApprove: () => void;
onRejectApprove: (reason: string) => void;
onShareToProject?: (docId: string) => void;
onRevokeShare?: (docId: string) => void;
}
export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
ast,
currentRole,
currentUserId,
canOverrideVisibility = true,
onOpenVisibilityModal,
onClaim,
onSubmitReview,
onPassReview,
onRejectReview,
onApprove,
onRejectApprove,
onShareToProject,
onRevokeShare,
}) => {
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [rejectTarget, setRejectTarget] = useState<'review' | 'approve'>('review');
const openReject = (target: 'review' | 'approve') => {
setRejectTarget(target);
setRejectReason('');
setRejectModalOpen(true);
};
const confirmReject = () => {
if (!rejectReason.trim()) return;
if (rejectTarget === 'review') {
onRejectReview(rejectReason.trim());
} else {
onRejectApprove(rejectReason.trim());
}
setRejectModalOpen(false);
};
const isPrivate = ast.category === 'private';
const isShared = ast.category === 'shared';
const isWorkflow = !isPrivate && !isShared;
const duties = { authorId: ast.authorId, reviewerId: ast.reviewerId, approverId: ast.approverId };
const unmanagedDuties = isUnmanagedDocDuties(duties);
const showSubmit = canShowDocGovApprovalAction('submit', currentRole, duties);
const showReview = canShowDocGovApprovalAction('review', currentRole, duties);
const showApprove = canShowDocGovApprovalAction('approve', currentRole, duties);
return (
<div
style={{
position: 'sticky',
bottom: 0,
left: 0,
right: 0,
background: 'var(--bg-panel)',
borderTop: '1px solid var(--border)',
padding: '10px 18px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: '0 -4px 16px rgba(0, 0, 0, 0.06)',
zIndex: 30,
}}
>
{/* 驳回理由弹窗 */}
{rejectModalOpen && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.5)',
zIndex: 110,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: '420px',
background: 'var(--bg-panel)',
borderRadius: 'var(--r-md)',
border: '1px solid var(--border)',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
boxShadow: 'var(--shadow-lg)',
}}
>
<div style={{ fontSize: '14px', fontWeight: 700, color: 'var(--danger)' }}>
请填写法定驳回原因 (必填)
</div>
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="说明驳回修改的具体意见,例如:缺少第3章电气安全检验标准,或技术参数指标不符合临床预期..."
rows={4}
style={{
width: '100%',
padding: '8px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text)',
fontSize: '12.5px',
resize: 'none',
}}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
onClick={() => setRejectModalOpen(false)}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
cursor: 'pointer',
}}
>
取消
</button>
<button
onClick={confirmReject}
disabled={!rejectReason.trim()}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--danger)',
border: 'none',
color: '#fff',
fontWeight: 600,
opacity: rejectReason.trim() ? 1 : 0.5,
cursor: rejectReason.trim() ? 'pointer' : 'not-allowed',
}}
>
确认驳回退回编制
</button>
</div>
</div>
</div>
)}
{/* 场景 1: 个人私密文档专属条 */}
{isPrivate && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background: '#FEF3C7',
color: '#D97706',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<Lock size={12} />
<span>个人私密草稿</span>
</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
由助手针对您的对话需求生成,<strong>仅您本人可见</strong>。不参与项目受控审签体系。
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{onShareToProject && (
<button
onClick={() => onShareToProject(ast.docId)}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--brand-500)',
border: 'none',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '5px',
cursor: 'pointer',
boxShadow: '0 2px 6px rgba(79, 107, 255, 0.25)',
}}
title="将此推演草稿一键共享给项目组成员查阅与讨论"
>
<Share2 size={13} />
<span>一键共享给项目组</span>
</button>
)}
</div>
</>
)}
{/* 场景 2: 项目协作参考资料专属条 */}
{isShared && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background: 'var(--brand-50)',
color: 'var(--brand-600)',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<Globe size={12} />
<span>项目协作参考资料</span>
</span>
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
共享人: <b>{ast.creatorName || ast.author}</b>
{ast.sharedAt ? ` (${ast.sharedAt})` : ''} · 项目全员可查阅、引用与批注
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{/* 仅创建者本人有权取消共享 */}
{ast.creatorId === currentUserId && onRevokeShare && (
<button
onClick={() => onRevokeShare(ast.docId)}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
fontSize: '12px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '5px',
cursor: 'pointer',
}}
title="撤回该文档的共享状态,恢复为您个人私有"
>
<Undo2 size={13} />
<span>取消共享 (恢复私有)</span>
</button>
)}
</div>
</>
)}
{/* 场景 3: 研发体系预设受控交付物 (严格三权分立审签) */}
{isWorkflow && (
<>
{/* 左侧状态标识 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={14} color="var(--brand-500)" />
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
受控状态:
</span>
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background:
ast.lifecycleState === 'approved'
? 'var(--success-bg)'
: ast.lifecycleState === 'reviewing'
? 'var(--amber-50)'
: ast.lifecycleState === 'approving'
? '#F3E8FF'
: 'var(--brand-50)',
color:
ast.lifecycleState === 'approved'
? 'var(--success)'
: ast.lifecycleState === 'reviewing'
? 'var(--amber-600)'
: ast.lifecycleState === 'approving'
? '#9333EA'
: 'var(--brand-600)',
}}
>
{ast.lifecycleState === 'approved'
? '已终审生效'
: ast.lifecycleState === 'reviewing'
? '审核中'
: ast.lifecycleState === 'approving'
? '批准中'
: '编制中'}
</span>
{ast.isClaimed && (
<span style={{ fontSize: '11px', color: 'var(--brand-500)', fontWeight: 500 }}>
• 已被编制人认领
</span>
)}
{ast.visibilityRule && ast.visibilityRule.mode !== 'inherit' && (
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background:
ast.visibilityRule.mode === 'designated_only'
? 'rgba(239, 68, 68, 0.12)'
: ast.visibilityRule.mode === 'role_whitelist'
? 'rgba(245, 158, 11, 0.12)'
: 'rgba(34, 197, 94, 0.12)',
color:
ast.visibilityRule.mode === 'designated_only'
? 'var(--danger)'
: ast.visibilityRule.mode === 'role_whitelist'
? 'var(--amber-600)'
: 'var(--success)',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
title={`覆盖原因: ${ast.visibilityRule.overrideReason || '无'}`}
>
<Lock size={10} />
<span>
{ast.visibilityRule.mode === 'designated_only'
? '仅责任人可见'
: ast.visibilityRule.mode === 'role_whitelist'
? '角色白名单'
: '全员公开'}
</span>
</span>
)}
</div>
{/* 右侧动作流转按钮 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{onOpenVisibilityModal && (
<button
onClick={canOverrideVisibility ? onOpenVisibilityModal : undefined}
disabled={!canOverrideVisibility}
style={{
padding: '5px 10px',
borderRadius: 'var(--r-sm)',
background:
ast.visibilityRule && ast.visibilityRule.mode !== 'inherit'
? 'rgba(245, 158, 11, 0.12)'
: 'var(--bg-subtle)',
border:
ast.visibilityRule && ast.visibilityRule.mode !== 'inherit'
? '1px solid var(--amber-500)'
: '1px solid var(--border)',
color:
ast.visibilityRule && ast.visibilityRule.mode !== 'inherit'
? 'var(--amber-600)'
: 'var(--text-muted)',
fontSize: '11.5px',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: canOverrideVisibility ? 'pointer' : 'not-allowed',
opacity: canOverrideVisibility ? 1 : 0.6,
}}
title={
canOverrideVisibility
? '配置该文档的独立可见性覆盖策略'
: '当前角色无权配置单文档可见性覆盖'
}
>
<Lock size={12} />
<span>
{ast.visibilityRule && ast.visibilityRule.mode !== 'inherit'
? '已设权限覆盖'
: '权限覆盖'}
</span>
</button>
)}
{/* 1. 编制阶段 */}
{ast.lifecycleState === 'drafting' && (
<>
{unmanagedDuties ? (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
该文档未纳入人工审批,不提供送审、审核或批准
</span>
) : showSubmit ? (
<>
{!ast.isClaimed ? (
<button
onClick={onClaim}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '5px',
cursor: 'pointer',
}}
>
<UserCheck size={13} style={{ color: 'var(--brand-500)' }} />
<span>认领此文档</span>
</button>
) : null}
<button
onClick={onSubmitReview}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--brand-grad)',
border: 'none',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '5px',
cursor: 'pointer',
boxShadow: '0 2px 6px rgba(79, 107, 255, 0.25)',
}}
>
<Send size={13} />
<span>提交送审 (流转至审核人)</span>
</button>
</>
) : (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
等待编制负责人({ast.author})完成初稿并提交送审
</span>
)}
</>
)}
{/* 2. 审核阶段 */}
{ast.lifecycleState === 'reviewing' && (
<>
<span
style={{
fontSize: '11px',
color: 'var(--text-faint)',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<Lock size={12} /> 文档已锁定
</span>
{showReview ? (
<>
<button
onClick={() => openReject('review')}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--danger-bg)',
border: '1px solid var(--danger)',
color: 'var(--danger)',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
}}
>
<XCircle size={13} />
<span>驳回修改</span>
</button>
<button
onClick={onPassReview}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--amber-500)',
border: 'none',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
boxShadow: '0 2px 6px rgba(242, 169, 59, 0.3)',
}}
>
<CheckCircle2 size={13} />
<span>审核通过 (送呈批准人)</span>
</button>
</>
) : (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
等待审核负责人({ast.reviewer})完成审核签署
</span>
)}
</>
)}
{/* 3. 批准阶段 */}
{ast.lifecycleState === 'approving' && (
<>
{showApprove ? (
<>
<button
onClick={() => openReject('approve')}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--danger-bg)',
border: '1px solid var(--danger)',
color: 'var(--danger)',
fontSize: '12px',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: '4px',
cursor: 'pointer',
}}
>
<XCircle size={13} />
<span>驳回修改</span>
</button>
<button
onClick={onApprove}
style={{
padding: '6px 16px',
borderRadius: 'var(--r-sm)',
background: 'var(--success)',
border: 'none',
color: '#fff',
fontSize: '12.5px',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
gap: '5px',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(33, 166, 106, 0.35)',
}}
>
<Award size={14} />
<span>终审批准生效 (自动入标杆库)</span>
</button>
</>
) : (
<span style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
等待批准负责人({ast.approver})终审批准发布
</span>
)}
</>
)}
{/* 4. 已批准终态 */}
{ast.lifecycleState === 'approved' && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
color: 'var(--success)',
fontSize: '12.5px',
fontWeight: 600,
}}
>
<Award size={16} />
<span>受控归档交付物 (已沉淀为全局标杆经验)</span>
</div>
)}
</div>
</>
)}
</div>
);
};