diff --git a/web/src/components/doc/DeckDrawer.tsx b/web/src/components/doc/DeckDrawer.tsx index 157a1257..4780463f 100644 --- a/web/src/components/doc/DeckDrawer.tsx +++ b/web/src/components/doc/DeckDrawer.tsx @@ -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 = ({ @@ -40,6 +42,7 @@ export const DeckDrawer: React.FC = ({ onShareToProject, onRevokeShare, onDeleteDoc, + onOpenDocVisibility, }) => { const [filterCategory, setFilterCategory] = useState<'all' | DeliverableCategory>('all'); @@ -180,6 +183,40 @@ export const DeckDrawer: React.FC = ({ } }; + const renderVisibilityBadge = (rule?: DocVisibilityRule) => { + if (!rule || rule.mode === 'inherit') return null; + const isDesignated = rule.mode === 'designated_only'; + const isWhitelist = rule.mode === 'role_whitelist'; + + return ( + + + {isDesignated ? '仅责任人' : isWhitelist ? '指定角色' : '公开'} + + ); + }; + 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 = ({ {doc.title} - {renderBadge(doc.lifecycleState, doc.isClaimed, doc.isTemplateOnly)} +
+ {renderVisibilityBadge(doc.visibilityRule)} + {renderBadge(doc.lifecycleState, doc.isClaimed, doc.isTemplateOnly)} +
diff --git a/web/src/components/doc/DocGovActionBar.tsx b/web/src/components/doc/DocGovActionBar.tsx index a3cbf3aa..856800d8 100644 --- a/web/src/components/doc/DocGovActionBar.tsx +++ b/web/src/components/doc/DocGovActionBar.tsx @@ -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 = ({ ast, currentRole, currentUserId, + canOverrideVisibility = true, + onOpenVisibilityModal, onClaim, onSubmitReview, onPassReview, @@ -315,10 +319,86 @@ export const DocGovActionBar: React.FC = ({ • 已被编制人认领 )} + {ast.visibilityRule && ast.visibilityRule.mode !== 'inherit' && ( + + + + {ast.visibilityRule.mode === 'designated_only' + ? '仅责任人可见' + : ast.visibilityRule.mode === 'role_whitelist' + ? '角色白名单' + : '全员公开'} + + + )}
{/* 右侧动作流转按钮 */}
+ {onOpenVisibilityModal && ( + + )} {/* 1. 编制阶段 */} {ast.lifecycleState === 'drafting' && ( <> diff --git a/web/src/components/doc/DocVisibilityModal.tsx b/web/src/components/doc/DocVisibilityModal.tsx new file mode 100644 index 00000000..e761a8dc --- /dev/null +++ b/web/src/components/doc/DocVisibilityModal.tsx @@ -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 = ({ + isOpen, + onClose, + doc, + roles, + currentUserName, + onSaveRule, +}) => { + const [mode, setMode] = useState('inherit'); + const [selectedRoleIds, setSelectedRoleIds] = useState([]); + const [overrideReason, setOverrideReason] = useState(''); + + // 初始化已有规则 + 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 ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* 顶部标题栏 */} +
+
+
+ +
+
+
+ 单文档可见性覆盖策略 (Doc Visibility Override) +
+
+ 设置后将优先于工作流默认可见性规则生效 +
+
+
+ +
+ + {/* 主体表单 */} +
+ {/* 目标文档信息卡 */} +
+
+ + + {doc.title} + +
+
+ 所属阶段: {doc.stage} + 编制: {doc.author} + 审核: {doc.reviewer} + 批准: {doc.approver} +
+
+ + {/* 覆盖模式选择 */} +
+ + 选择可见性覆盖策略: + + + {/* 模式 1: 继承工作流默认 */} + + + {/* 模式 2: 仅三权责任人可见 */} + + + {/* 模式 3: 指定全局角色白名单 */} + + + {/* 模式 4: 全员公开 */} + +
+ + {/* 覆盖原因留痕输入 */} + {mode !== 'inherit' && ( +
+ + 覆盖原因与合规留痕说明: + + 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)', + }} + /> +
+ )} +
+ + {/* 底部操作栏 */} +
+ {doc.visibilityRule && doc.visibilityRule.mode !== 'inherit' ? ( + + ) : ( + + 当前状态:继承工作流默认可见性 + + )} + +
+ + +
+
+
+
+ ); +}; diff --git a/web/src/types/block.ts b/web/src/types/block.ts index da4e8171..6a7e6f96 100644 --- a/web/src/types/block.ts +++ b/web/src/types/block.ts @@ -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; }