462 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|