feat(web): add doc visibility override modal and badges in deck drawer

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 22:36:55 +08:00
co-authored by Cursor
parent fa2d0e9837
commit e1071f74e1
4 changed files with 587 additions and 1 deletions
+41 -1
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { DeliverableItem, DeliverableCategory } from '../../types/workbench';
import { DocLifecycleState } from '../../types/docgov';
import { DocVisibilityRule } from '../../types/rbac';
import {
X,
CheckCircle2,
@@ -28,6 +29,7 @@ interface DeckDrawerProps {
onShareToProject?: (docId: string) => void;
onRevokeShare?: (docId: string) => void;
onDeleteDoc?: (docId: string) => void;
onOpenDocVisibility?: (doc: DeliverableItem) => void;
}
export const DeckDrawer: React.FC<DeckDrawerProps> = ({
@@ -40,6 +42,7 @@ export const DeckDrawer: React.FC<DeckDrawerProps> = ({
onShareToProject,
onRevokeShare,
onDeleteDoc,
onOpenDocVisibility,
}) => {
const [filterCategory, setFilterCategory] = useState<'all' | DeliverableCategory>('all');
@@ -180,6 +183,40 @@ export const DeckDrawer: React.FC<DeckDrawerProps> = ({
}
};
const renderVisibilityBadge = (rule?: DocVisibilityRule) => {
if (!rule || rule.mode === 'inherit') return null;
const isDesignated = rule.mode === 'designated_only';
const isWhitelist = rule.mode === 'role_whitelist';
return (
<span
style={{
fontSize: '10.5px',
fontWeight: 600,
padding: '1px 6px',
borderRadius: 'var(--r-pill)',
background: isDesignated
? 'rgba(239, 68, 68, 0.12)'
: isWhitelist
? 'rgba(245, 158, 11, 0.12)'
: 'rgba(34, 197, 94, 0.12)',
color: isDesignated
? 'var(--danger)'
: isWhitelist
? 'var(--amber-600)'
: 'var(--success)',
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
}}
title={`覆盖原因: ${rule.overrideReason || '权限覆盖模式'}`}
>
<Lock size={10} />
<span>{isDesignated ? '仅责任人' : isWhitelist ? '指定角色' : '公开'}</span>
</span>
);
};
const stages: { stage: string; title: string }[] = [
{ stage: 'V0.1', title: 'V0.1 需求摸底与产品定义' },
{ stage: 'V0.5', title: 'V0.5 方案成型与法规对齐' },
@@ -411,7 +448,10 @@ export const DeckDrawer: React.FC<DeckDrawerProps> = ({
<span style={{ fontSize: '12.5px', fontWeight: 600, color: 'var(--text-strong)' }}>
{doc.title}
</span>
{renderBadge(doc.lifecycleState, doc.isClaimed, doc.isTemplateOnly)}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{renderVisibilityBadge(doc.visibilityRule)}
{renderBadge(doc.lifecycleState, doc.isClaimed, doc.isTemplateOnly)}
</div>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', display: 'flex', gap: '12px' }}>
@@ -18,6 +18,8 @@ interface DocGovActionBarProps {
ast: DocumentAST;
currentRole: UserRole;
currentUserId?: number;
canOverrideVisibility?: boolean;
onOpenVisibilityModal?: () => void;
onClaim: () => void;
onSubmitReview: () => void;
onPassReview: () => void;
@@ -32,6 +34,8 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
ast,
currentRole,
currentUserId,
canOverrideVisibility = true,
onOpenVisibilityModal,
onClaim,
onSubmitReview,
onPassReview,
@@ -315,10 +319,86 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
• 已被编制人认领
</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' && (
<>
@@ -0,0 +1,464 @@
import { FC, useState, useEffect } from 'react';
import {
X,
ShieldAlert,
Lock,
Globe,
Users,
CheckCircle2,
AlertCircle,
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>
);
};
+2
View File
@@ -1,5 +1,6 @@
import { DeliverableCategory } from './workbench';
import { DocLifecycleState } from './docgov';
import { DocVisibilityRule } from './rbac';
export type BlockType =
| 'document' // 根文档块 (Root Container)
@@ -62,6 +63,7 @@ export interface DocumentAST {
reviewer: string;
approver: string;
summary?: string;
visibilityRule?: DocVisibilityRule;
updatedAt: string;
rootBlock: DocumentBlock;
}