Files
Agent-System/web/src/components/doc/DocGovActionBar.tsx
T

725 lines
26 KiB
TypeScript

import React, { useRef, 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 | Promise<void>;
onSubmitReview: () => void | Promise<void>;
onPassReview: () => void | Promise<void>;
onRejectReview: (reason: string) => void | Promise<void>;
onApprove: () => void | Promise<void>;
onRejectApprove: (reason: string) => void | Promise<void>;
onShareToProject?: (docId: string) => void;
onRevokeShare?: (docId: string) => void;
onRequestReopen?: () => void;
}
export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
ast,
currentRole,
currentUserId,
canOverrideVisibility = true,
onOpenVisibilityModal,
onClaim,
onSubmitReview,
onPassReview,
onRejectReview,
onApprove,
onRejectApprove,
onShareToProject,
onRevokeShare,
onRequestReopen,
}) => {
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [rejectTarget, setRejectTarget] = useState<'review' | 'approve'>('review');
const [rejectSubmitting, setRejectSubmitting] = useState(false);
const [rejectError, setRejectError] = useState<string | null>(null);
const rejectReasonRef = useRef('');
const rejectTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const openReject = (target: 'review' | 'approve') => {
setRejectTarget(target);
setRejectReason('');
rejectReasonRef.current = '';
setRejectError(null);
setRejectSubmitting(false);
setRejectModalOpen(true);
};
/** 优先读 textarea DOM,避免受控 state 与 Playwright fill 竞态导致空理由。 */
const readRejectReason = (): string => {
const fromDom = rejectTextareaRef.current?.value ?? '';
const raw = fromDom || rejectReasonRef.current || rejectReason;
return raw.trim();
};
const confirmReject = async () => {
const reason = readRejectReason();
if (!reason || rejectSubmitting) return;
setRejectSubmitting(true);
setRejectError(null);
try {
if (rejectTarget === 'review') {
await Promise.resolve(onRejectReview(reason));
} else {
await Promise.resolve(onRejectApprove(reason));
}
setRejectModalOpen(false);
setRejectReason('');
rejectReasonRef.current = '';
} catch (err) {
setRejectError(err instanceof Error ? err.message : String(err));
} finally {
setRejectSubmitting(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', currentUserId, duties);
const showReview = canShowDocGovApprovalAction('review', currentUserId, duties);
const showApprove = canShowDocGovApprovalAction('approve', currentUserId, 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
ref={rejectTextareaRef}
value={rejectReason}
onChange={(e) => {
const next = e.target.value;
rejectReasonRef.current = next;
setRejectReason(next);
}}
placeholder="说明驳回修改的具体意见,例如:缺少第3章电气安全检验标准,或技术参数指标不符合临床预期..."
rows={4}
disabled={rejectSubmitting}
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',
}}
/>
{rejectError ? (
<div style={{ fontSize: '12px', color: 'var(--danger)' }} role="alert">
驳回失败:{rejectError}
</div>
) : null}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<button
type="button"
onClick={() => {
if (rejectSubmitting) return;
setRejectModalOpen(false);
}}
disabled={rejectSubmitting}
style={{
padding: '6px 12px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
cursor: rejectSubmitting ? 'not-allowed' : 'pointer',
opacity: rejectSubmitting ? 0.6 : 1,
}}
>
取消
</button>
<button
type="button"
onClick={() => {
void confirmReject();
}}
disabled={rejectSubmitting}
style={{
padding: '6px 14px',
borderRadius: 'var(--r-sm)',
background: 'var(--danger)',
border: 'none',
color: '#fff',
fontWeight: 600,
opacity: rejectSubmitting ? 0.5 : 1,
cursor: rejectSubmitting ? 'not-allowed' : 'pointer',
}}
>
{rejectSubmitting ? '驳回中…' : '确认驳回退回编制'}
</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'
: ast.lifecycleState === 'template'
? '#E0F2FE'
: 'var(--brand-50)',
color:
ast.lifecycleState === 'approved'
? 'var(--success)'
: ast.lifecycleState === 'reviewing'
? 'var(--amber-600)'
: ast.lifecycleState === 'approving'
? '#9333EA'
: ast.lifecycleState === 'template'
? '#0369A1'
: 'var(--brand-600)',
}}
>
{ast.lifecycleState === 'approved'
? '已终审生效'
: ast.lifecycleState === 'reviewing'
? '审核中'
: ast.lifecycleState === 'approving'
? '批准中'
: ast.lifecycleState === 'template'
? '模板预览'
: '编制中'}
</span>
{ast.dutyPolicy === 'author_only' && (
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background: 'rgba(79, 107, 255, 0.12)',
color: 'var(--brand-600)',
}}
title="编制、审核、批准由同一人承担;仍保留三步签署"
>
编制人兼任
</span>
)}
{ast.isClaimed && (
<span style={{ fontSize: '11px', color: 'var(--brand-500)', fontWeight: 500 }}>
• 已被编制人认领
</span>
)}
{ast.isClaimed &&
currentRole === 'author' &&
(ast.authorId === undefined || currentUserId === undefined || ast.authorId === currentUserId) && (
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background: 'rgba(33, 166, 106, 0.12)',
color: 'var(--success, #21a66a)',
maxWidth: '280px',
lineHeight: 1.35,
}}
title="认领后他人发起的修改需您确认"
>
您已于{' '}
{ast.authorClaimedAt
? new Date(ast.authorClaimedAt).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '此前'}{' '}
认领本文档;此后他人发起的修改需您确认。
</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: '8px',
color: 'var(--success)',
fontSize: '12.5px',
fontWeight: 600,
}}
>
<Award size={16} />
<span>受控归档交付物 (已沉淀为全局标杆经验)</span>
{onRequestReopen &&
(currentUserId === ast.authorId ||
currentUserId === ast.approverId ||
currentUserId === ast.creatorId) && (
<button
onClick={onRequestReopen}
style={{
marginLeft: '8px',
padding: '5px 10px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text)',
fontSize: '11.5px',
fontWeight: 600,
cursor: 'pointer',
}}
title="向原批准人申请将文档重开至编制期"
>
申请再修订
</button>
)}
</div>
)}
</div>
</>
)}
</div>
);
};