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

462 lines
16 KiB
TypeScript

import { FC, useState, useEffect } from 'react';
import {
X,
Lock,
Globe,
Users,
FileText,
} from 'lucide-react';
import { DeliverableItem } from '../../types/workbench';
import { RoleDefinition, DocVisibilityRule, VisibilityMode } from '../../types/rbac';
export interface DocVisibilityModalProps {
isOpen: boolean;
onClose: () => void;
doc: DeliverableItem | null;
roles: RoleDefinition[];
currentUserName: string;
onSaveRule: (docId: string, rule: DocVisibilityRule | undefined) => void;
}
export const DocVisibilityModal: FC<DocVisibilityModalProps> = ({
isOpen,
onClose,
doc,
roles,
currentUserName,
onSaveRule,
}) => {
const [mode, setMode] = useState<VisibilityMode>('inherit');
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
const [overrideReason, setOverrideReason] = useState<string>('');
// 初始化已有规则
useEffect(() => {
if (doc?.visibilityRule) {
setMode(doc.visibilityRule.mode);
setSelectedRoleIds(doc.visibilityRule.allowedRoleIds || []);
setOverrideReason(doc.visibilityRule.overrideReason || '');
} else {
setMode('inherit');
setSelectedRoleIds([]);
setOverrideReason('');
}
}, [doc, isOpen]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !doc) return null;
const handleToggleRole = (roleId: string) => {
setSelectedRoleIds((prev) =>
prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId]
);
};
const handleSave = () => {
if (mode === 'inherit') {
onSaveRule(doc.docId, undefined);
onClose();
return;
}
const rule: DocVisibilityRule = {
mode,
allowedRoleIds: mode === 'role_whitelist' ? selectedRoleIds : undefined,
overrideReason: overrideReason.trim() || '业务主管根据合规与保密要求设置覆盖策略',
configuredBy: currentUserName,
configuredAt: new Date().toLocaleString(),
};
onSaveRule(doc.docId, rule);
onClose();
};
const handleResetToInherit = () => {
onSaveRule(doc.docId, undefined);
onClose();
};
return (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(11, 14, 20, 0.75)',
backdropFilter: 'blur(6px)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
width: '560px',
maxWidth: '95vw',
backgroundColor: 'var(--bg-panel, #131820)',
borderRadius: '12px',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
animation: 'scaleIn 0.18s ease-out',
}}
>
{/* 顶部标题栏 */}
<div
style={{
padding: '14px 20px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: 'var(--bg-subtle, #1A212C)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
width: '34px',
height: '34px',
borderRadius: '8px',
backgroundColor: 'rgba(245, 158, 11, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--amber-500)',
}}
>
<Lock size={18} />
</div>
<div>
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-strong)' }}>
单文档可见性覆盖策略 (Doc Visibility Override)
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
设置后将优先于工作流默认可见性规则生效
</div>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-muted)',
padding: '6px',
borderRadius: '6px',
}}
>
<X size={18} />
</button>
</div>
{/* 主体表单 */}
<div style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
{/* 目标文档信息卡 */}
<div
style={{
padding: '12px 14px',
borderRadius: '8px',
backgroundColor: 'var(--bg-subtle)',
border: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
gap: '4px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<FileText size={14} color="var(--brand-500)" />
<span style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
{doc.title}
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', display: 'flex', gap: '12px' }}>
<span>所属阶段: <b>{doc.stage}</b></span>
<span>编制: <b>{doc.author}</b></span>
<span>审核: <b>{doc.reviewer}</b></span>
<span>批准: <b>{doc.approver}</b></span>
</div>
</div>
{/* 覆盖模式选择 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span style={{ fontSize: '12.5px', fontWeight: 600, color: 'var(--text-strong)' }}>
选择可见性覆盖策略:
</span>
{/* 模式 1: 继承工作流默认 */}
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
padding: '10px 12px',
borderRadius: '6px',
border: mode === 'inherit' ? '1.5px solid var(--brand-500)' : '1px solid var(--border)',
backgroundColor: mode === 'inherit' ? 'rgba(79, 107, 255, 0.06)' : 'var(--bg-subtle)',
cursor: 'pointer',
}}
>
<input
type="radio"
name="visibilityMode"
checked={mode === 'inherit'}
onChange={() => setMode('inherit')}
style={{ marginTop: '2px' }}
/>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--text-strong)' }}>
1. 继承工作流默认规则 (默认)
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
不进行单文档覆盖。具备常规工作流查阅权(<code>workflow:view_all</code>)的项目团队成员均可查阅。
</div>
</div>
</label>
{/* 模式 2: 仅三权责任人可见 */}
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
padding: '10px 12px',
borderRadius: '6px',
border: mode === 'designated_only' ? '1.5px solid var(--danger)' : '1px solid var(--border)',
backgroundColor: mode === 'designated_only' ? 'rgba(239, 68, 68, 0.06)' : 'var(--bg-subtle)',
cursor: 'pointer',
}}
>
<input
type="radio"
name="visibilityMode"
checked={mode === 'designated_only'}
onChange={() => setMode('designated_only')}
style={{ marginTop: '2px' }}
/>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Lock size={13} />
<span>2. 严格收紧:仅文档三权责任人可见</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
绝密涉密文档。仅该文档当前指定的【编制人 ({doc.author})】、【审核人 ({doc.reviewer})】、【批准人 ({doc.approver})】有权查阅,项目其他成员(包括普通主管)一律隐藏不可见。
</div>
</div>
</label>
{/* 模式 3: 指定全局角色白名单 */}
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
padding: '10px 12px',
borderRadius: '6px',
border: mode === 'role_whitelist' ? '1.5px solid var(--amber-500)' : '1px solid var(--border)',
backgroundColor: mode === 'role_whitelist' ? 'rgba(245, 158, 11, 0.06)' : 'var(--bg-subtle)',
cursor: 'pointer',
}}
>
<input
type="radio"
name="visibilityMode"
checked={mode === 'role_whitelist'}
onChange={() => setMode('role_whitelist')}
style={{ marginTop: '2px' }}
/>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--amber-600)', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Users size={13} />
<span>3. 角色白名单:仅指定全局角色可见</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
仅勾选的全局角色成员可查阅(法定三权责任人依然受保底保护)。
</div>
{mode === 'role_whitelist' && (
<div
style={{
marginTop: '8px',
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
padding: '8px',
borderRadius: '6px',
backgroundColor: 'var(--bg-card)',
border: '1px solid var(--border)',
}}
>
{roles.map((r) => {
const isChecked = selectedRoleIds.includes(r.id);
return (
<label
key={r.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '11.5px',
padding: '4px 8px',
borderRadius: '4px',
backgroundColor: isChecked ? 'rgba(79, 107, 255, 0.15)' : 'transparent',
color: isChecked ? 'var(--brand-500)' : 'var(--text)',
border: isChecked ? '1px solid var(--brand-500)' : '1px solid var(--border-light)',
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => handleToggleRole(r.id)}
/>
<span>{r.name}</span>
</label>
);
})}
</div>
)}
</div>
</label>
{/* 模式 4: 全员公开 */}
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
padding: '10px 12px',
borderRadius: '6px',
border: mode === 'public' ? '1.5px solid var(--success)' : '1px solid var(--border)',
backgroundColor: mode === 'public' ? 'rgba(34, 197, 94, 0.06)' : 'var(--bg-subtle)',
cursor: 'pointer',
}}
>
<input
type="radio"
name="visibilityMode"
checked={mode === 'public'}
onChange={() => setMode('public')}
style={{ marginTop: '2px' }}
/>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, color: 'var(--success)', display: 'flex', alignItems: 'center', gap: '4px' }}>
<Globe size={13} />
<span>4. 放宽公开:全员公开查阅 (包括访客与外部审计)</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
如产品公开规格白皮书,即使无常规工作流查阅权的普通访客也可查阅。
</div>
</div>
</label>
</div>
{/* 覆盖原因留痕输入 */}
{mode !== 'inherit' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-strong)' }}>
覆盖原因与合规留痕说明:
</span>
<input
type="text"
placeholder="例如: 涉及未公开核心光学算法与敏感供应链成本,按ISO13485要求受控锁定"
value={overrideReason}
onChange={(e) => setOverrideReason(e.target.value)}
style={{
padding: '8px 10px',
fontSize: '12px',
borderRadius: '6px',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-panel)',
color: 'var(--text-strong)',
}}
/>
</div>
)}
</div>
{/* 底部操作栏 */}
<div
style={{
padding: '12px 20px',
borderTop: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
{doc.visibilityRule && doc.visibilityRule.mode !== 'inherit' ? (
<button
onClick={handleResetToInherit}
style={{
padding: '6px 12px',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'var(--bg-panel)',
color: 'var(--text-muted)',
fontSize: '12px',
cursor: 'pointer',
}}
>
清除覆盖 (恢复继承)
</button>
) : (
<span style={{ fontSize: '11px', color: 'var(--text-muted)' }}>
当前状态:继承工作流默认可见性
</span>
)}
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={onClose}
style={{
padding: '6px 14px',
borderRadius: '6px',
border: '1px solid var(--border)',
background: 'transparent',
color: 'var(--text-muted)',
fontSize: '12px',
cursor: 'pointer',
}}
>
取消
</button>
<button
onClick={handleSave}
style={{
padding: '6px 18px',
borderRadius: '6px',
border: 'none',
background: 'var(--brand-grad)',
color: '#fff',
fontSize: '12.5px',
fontWeight: 600,
cursor: 'pointer',
}}
>
保存并应用覆盖
</button>
</div>
</div>
</div>
</div>
);
};