diff --git a/web/src/App.tsx b/web/src/App.tsx index d5de7736..7846857e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -29,6 +29,9 @@ import { UserProjectRightsModal } from './components/user/UserProjectRightsModal import { UserSettingsModal } from './components/user/UserSettingsModal'; import { AdminSettingsModal } from './components/admin/AdminSettingsModal'; import { DocVisibilityModal } from './components/doc/DocVisibilityModal'; +import { CreateProjectModal } from './components/project/CreateProjectModal'; +import { ProjectMembersModal } from './components/project/ProjectMembersModal'; +import { ShieldAlert } from 'lucide-react'; import { LoginView } from './components/auth/LoginView'; import { FirstLoginPasswordModal } from './components/auth/FirstLoginPasswordModal'; import { DevRoleSwitcher } from './components/dev/DevRoleSwitcher'; @@ -51,6 +54,9 @@ import { InteractiveCardData, PhaseEvent, TaskRunContext, + ProjectInfo, + ProjectMember, + ProjectMemberRole, } from './types/workbench'; import { UserRole, UserProfile, DocPermissionRole } from './types/docgov'; import { RoleDefinition, DocVisibilityRule } from './types/rbac'; @@ -97,9 +103,13 @@ import { loadUsers, saveUsers, migrateAndClearLegacyMessages, - filterSessionsForUser, resolveActiveSessionForUser, ensurePrivateSessionsForUser, + loadProjects, + saveProjects, + filterProjectSessionsForUser, + getProjectMembership, + buildPrivateSessionId, } from './data/storage'; import { useChatWindow } from './hooks/useChatWindow'; import { CategoryFilterBar } from './components/history/CategoryFilterBar'; @@ -482,33 +492,52 @@ export default function App() { const [activeCategory, setActiveCategory] = useState('workflow'); + // 项目与成员体系状态 (支持持久化) + const [projects, setProjects] = useState(() => loadProjects()); + useEffect(() => { + saveProjects(projects); + }, [projects]); + + const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false); + const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false); + + const isAdmin = currentUser.roleId === 'role-admin' || currentUser.id === 4; + // 获取当前激活会话的项目ID (默认为 1) const currentProjectId = useMemo(() => { const activeSess = sessions.find((s) => s.sessionId === activeSessionId); return activeSess ? activeSess.projectId : 1; }, [sessions, activeSessionId]); - // 当前用户专属的项目会话列表 (严格多用户隔离) - const visibleSessions = useMemo(() => { - const mine = filterSessionsForUser(sessions, currentUser.id); - return mine.length > 0 ? mine : sessions; - }, [sessions, currentUser.id]); + // 获取当前项目与当前用户在其中的成员职责 + const currentProject = useMemo(() => { + return projects.find((p) => p.id === currentProjectId); + }, [projects, currentProjectId]); - // 账号变动时,确保当前激活的会话属于该用户 + const currentMembership = useMemo(() => { + return getProjectMembership(currentProject, currentUser.id); + }, [currentProject, currentUser.id]); + + const isCurrentProjectExited = currentMembership?.status === 'exited'; + + // 当前用户专属的项目会话列表 (基于项目成员身份与退出留痕过滤) + const visibleSessions = useMemo(() => { + return filterProjectSessionsForUser(sessions, projects, currentUser.id, isAdmin); + }, [sessions, projects, currentUser.id, isAdmin]); + + // 账号变动或项目退出时,确保当前激活的会话属于当前用户可见范围 useEffect(() => { - const cur = sessions.find((s) => s.sessionId === activeSessionId); - if (!cur || (cur.userId && cur.userId !== currentUser.id)) { + const cur = visibleSessions.find((s) => s.sessionId === activeSessionId); + if (!cur && visibleSessions.length > 0) { const nextActiveId = resolveActiveSessionForUser( - sessions, + visibleSessions, currentUser.id, currentProjectId, activeSessionId ); - if (nextActiveId && nextActiveId !== activeSessionId) { - setActiveSessionId(nextActiveId); - } + setActiveSessionId(nextActiveId || visibleSessions[0].sessionId); } - }, [currentUser.id, sessions, currentProjectId, activeSessionId]); + }, [currentUser.id, visibleSessions, currentProjectId, activeSessionId]); const [activeDocId, setActiveDocId] = useState(() => { const activeSess = sessions.find((s) => s.sessionId === activeSessionId); @@ -2308,60 +2337,220 @@ export default function App() { ); } + // 发起医疗器械新项目立项 (通过 Modal 交互收集名称、品类与初始成员) + const handleCreateProjectConfirm = (data: { + title: string; + deviceType: string; + description: string; + initialMembers: Array<{ + userId: number; + userName: string; + department?: string; + position?: string; + role: ProjectMemberRole; + }>; + }) => { + const newProjId = Date.now(); + const nowStr = new Date().toLocaleString(); + + // 1. 构建项目与成员元数据 + const members: ProjectMember[] = data.initialMembers.map((m) => ({ + userId: m.userId, + userName: m.userName, + department: m.department, + position: m.position, + role: m.role, + status: 'active', + joinedAt: nowStr, + })); + + const newProject: ProjectInfo = { + id: newProjId, + title: data.title, + deviceType: data.deviceType, + description: data.description, + creatorId: currentUser.id, + creatorName: currentUser.name, + createdAt: nowStr, + members, + }; + + // 2. 为每个选中的项目成员生成独立私聊会话 + const newSessionsToAppend: ProjectSession[] = []; + const newMessagesToAppend: ChatMessage[] = []; + let creatorSessionId = ''; + + members.forEach((m) => { + const sessId = buildPrivateSessionId(`p${newProjId}`, m.userId); + if (m.userId === currentUser.id) { + creatorSessionId = sessId; + } + newSessionsToAppend.push({ + sessionId: sessId, + userId: m.userId, + projectId: newProjId, + title: data.title, + deviceType: data.deviceType, + stage: 'V0.1', + stageLabel: 'V0.1 需求摸底', + messageCount: 1, + updatedAt: '刚刚', + }); + + newMessagesToAppend.push({ + id: `m-init-${m.userId}-${Date.now()}`, + sessionId: sessId, + role: 'system', + content: + m.userId === currentUser.id + ? `【立项成功】项目《${data.title}》已成功建立!全套研发工作流与阶段文档模板已克隆就绪,协同参谋小队已就位,进入【V0.1 需求摸底】阶段。您可以点击附件图标上传临床需求 URS,或直接输入您的产品构想。` + : `【项目协同通知】您已被项目创建人 ${currentUser.name} 邀请加入《${data.title}》,并分配法定职责为【${m.role}】。`, + timestamp: new Date().toTimeString().substring(0, 5), + }); + }); + + // 3. 克隆全生命周期 7 份受控模板副本 + const { deliverables: clonedDocs, docASTMap: clonedAsts } = createProjectTemplateDeliverables({ + projectId: newProjId, + projectName: data.title, + deviceType: data.deviceType, + creatorId: currentUser.id, + creatorName: currentUser.name, + }); + + setProjects((prev) => [newProject, ...prev]); + setSessions((prev) => [...newSessionsToAppend, ...prev]); + setMessages((prev) => [...prev, ...newMessagesToAppend]); + setDeliverables((prev) => [...prev, ...clonedDocs]); + setDocCache((prev) => ({ ...prev, ...clonedAsts })); + + if (creatorSessionId) { + setActiveSessionId(creatorSessionId); + } + setActiveDocId(clonedDocs[0].docId); + setActiveCategory('workflow'); + }; + + // 添加成员 + const handleAddProjectMember = (newMember: Omit) => { + if (!currentProject) return; + const nowStr = new Date().toLocaleString(); + const fullMember: ProjectMember = { + ...newMember, + status: 'active', + joinedAt: nowStr, + }; + + setProjects((prev) => + prev.map((p) => { + if (p.id === currentProject.id) { + return { + ...p, + members: [...p.members.filter((m) => m.userId !== newMember.userId), fullMember], + }; + } + return p; + }) + ); + + // 为新成员生成独立会话 + const newSessId = buildPrivateSessionId(`p${currentProject.id}`, newMember.userId); + setSessions((prev) => { + const exists = prev.some((s) => s.projectId === currentProject.id && s.userId === newMember.userId); + if (exists) return prev; + return [ + { + sessionId: newSessId, + userId: newMember.userId, + projectId: currentProject.id, + title: currentProject.title, + deviceType: currentProject.deviceType, + stage: 'V0.1', + stageLabel: 'V0.1 需求摸底', + messageCount: 1, + updatedAt: '刚刚', + }, + ...prev, + ]; + }); + + setMessages((prev) => [ + ...prev, + { + id: `m-join-${newMember.userId}-${Date.now()}`, + sessionId: newSessId, + role: 'system', + content: `【项目协同通知】您已被项目创建人 ${currentProject.creatorName} 加入项目《${currentProject.title}》,并分配法定职责为【${newMember.role}】。`, + timestamp: new Date().toTimeString().substring(0, 5), + }, + ]); + }; + + // 调整成员职责 + const handleUpdateProjectMemberRole = (userId: number, newRole: ProjectMemberRole) => { + if (!currentProject) return; + setProjects((prev) => + prev.map((p) => { + if (p.id === currentProject.id) { + return { + ...p, + members: p.members.map((m) => (m.userId === userId ? { ...m, role: newRole } : m)), + }; + } + return p; + }) + ); + }; + + // 移出/退出项目 (标记状态为 exited,保留聊天记录供审计留痕) + const handleRemoveProjectMember = (userId: number) => { + if (!currentProject) return; + const nowStr = new Date().toLocaleString(); + setProjects((prev) => + prev.map((p) => { + if (p.id === currentProject.id) { + return { + ...p, + members: p.members.map((m) => + m.userId === userId ? { ...m, status: 'exited', exitedAt: nowStr } : m + ), + }; + } + return p; + }) + ); + }; + + // 恢复重新加入项目 + const handleRestoreProjectMember = (userId: number) => { + if (!currentProject) return; + setProjects((prev) => + prev.map((p) => { + if (p.id === currentProject.id) { + return { + ...p, + members: p.members.map((m) => + m.userId === userId ? { ...m, status: 'active', exitedAt: undefined } : m + ), + }; + } + return p; + }) + ); + }; + return ( <> { - // 切换项目时清理旧会话的残留锚点 hash,防止回弹 if (window.location.hash) { history.replaceState(null, '', window.location.pathname + window.location.search); } setActiveSessionId(sessId); }} - onNewSession={() => { - const newSessId = 'sess-' + Date.now(); - const newProjId = Date.now(); - const newProjectTitle = '新建医疗器械研发项目'; - const newDeviceType = '通用医疗器械'; - - const newSess: ProjectSession = { - sessionId: newSessId, - projectId: newProjId, - title: newProjectTitle, - deviceType: newDeviceType, - stage: 'V0.1', - stageLabel: 'V0.1 需求摸底', - messageCount: 1, - updatedAt: '刚刚', - }; - - const initMsg: ChatMessage = { - id: 'm-init-' + Date.now(), - sessionId: newSessId, - role: 'system', - content: '水木东方协同参谋小队已就位,当前新项目进入【V0.1 需求摸底与产品定义】阶段。全套研发工作流与阶段文档模板已克隆就绪,请在右侧查阅或在此输入您的研发构想。', - timestamp: new Date().toTimeString().substring(0, 5), - }; - - // 即时克隆全生命周期 7 份受控模板副本 - const { deliverables: clonedDocs, docASTMap: clonedAsts } = createProjectTemplateDeliverables({ - projectId: newProjId, - projectName: newProjectTitle, - deviceType: newDeviceType, - creatorId: currentUser.id, - creatorName: currentUser.name, - }); - - setSessions((prev) => [newSess, ...prev]); - setMessages((prev) => [...prev, initMsg]); - setDeliverables((prev) => [...prev, ...clonedDocs]); - setDocCache((prev) => ({ ...prev, ...clonedAsts })); - setActiveSessionId(newSessId); - setActiveDocId(clonedDocs[0].docId); - setActiveCategory('workflow'); - }} + onNewSession={() => setCreateProjectModalOpen(true)} canCreateProject={canCreateProject} currentUser={currentUser} currentRole={currentDocRole} @@ -2370,6 +2559,7 @@ export default function App() { onOpenRightsModal={() => setRightsModalOpen(true)} onOpenUserSettings={() => setUserSettingsOpen(true)} onOpenAdminSettings={() => setAdminSettingsOpen(true)} + onOpenProjectMembers={() => setProjectMembersModalOpen(true)} activeWorkflowKey={currentWfState.activeKey} workflowVersion={currentWfState.version} isCreator={currentWfState.creatorId === currentUser.id} @@ -2517,10 +2707,57 @@ export default function App() { onStopStreaming={handleStopStreaming} quotes={activeQuotes} onRemoveQuote={handleRemoveQuote} + disabled={isCurrentProjectExited} + disabledMessage="您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。" /> } docContent={ + isCurrentProjectExited ? ( +
+
+ +
+

+ 受控文档已执行合规脱密隔离 +

+

+ 您已退出该研发项目。依据医疗器械质量管理体系 (GMP/ISO 13485) 及商业保密规程,本项目技术规格书、设计输入草案与验证交付物已对您执行访问切断。历史聊天记录仅供审计留痕查阅。 +

+
+ ) : (
{/* 36px 极简 Tab 条 (含三分类分段切换与私密/共享识别) */} )}
+ ) } /> @@ -2736,6 +2974,28 @@ export default function App() { currentUserName={currentUser.name} onSaveRule={handleSaveDocVisibilityRule} /> + + {/* 发起医疗器械新项目立项弹窗 */} + setCreateProjectModalOpen(false)} + onSubmit={handleCreateProjectConfirm} + currentUser={currentUser} + availableUsers={allUsers} + /> + + {/* 项目成员与职责分配管理弹窗 */} + setProjectMembersModalOpen(false)} + project={currentProject} + currentUser={currentUser} + availableUsers={allUsers} + onAddMember={handleAddProjectMember} + onUpdateMemberRole={handleUpdateProjectMemberRole} + onRemoveMember={handleRemoveProjectMember} + onRestoreMember={handleRestoreProjectMember} + /> ); } diff --git a/web/src/components/chat/InputArea.tsx b/web/src/components/chat/InputArea.tsx index 33fa5c1c..498d6822 100644 --- a/web/src/components/chat/InputArea.tsx +++ b/web/src/components/chat/InputArea.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; -import { Send, Paperclip, Square, X, Loader2, Sparkles } from 'lucide-react'; +import { Send, Paperclip, Square, X, Loader2, Sparkles, Lock } from 'lucide-react'; import { QuotedMessage } from '../../types/workbench'; import { QuoteChip } from './QuoteChip'; @@ -14,6 +14,8 @@ interface InputAreaProps { onStopStreaming?: () => void; quotes?: QuotedMessage[]; onRemoveQuote?: (index: number) => void; + disabled?: boolean; + disabledMessage?: string; } export const InputArea: React.FC = ({ @@ -23,6 +25,8 @@ export const InputArea: React.FC = ({ onStopStreaming, quotes = [], onRemoveQuote, + disabled = false, + disabledMessage, }) => { const [text, setText] = useState(''); const [attachments, setAttachments] = useState<{ name: string; size: string; type: string }[]>([]); @@ -84,6 +88,43 @@ export const InputArea: React.FC = ({ setAttachments((prev) => prev.filter((_, i) => i !== index)); }; + if (disabled) { + return ( +
+
+ +
+ + {disabledMessage || + '您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。'} + +
+ ); + } + return (
void; onOpenUserSettings: () => void; onOpenAdminSettings?: () => void; + onOpenProjectMembers?: () => void; chatContent: React.ReactNode; docContent: React.ReactNode; activeWorkflowKey?: string; @@ -42,6 +43,7 @@ export const AppShell: React.FC = ({ onOpenRightsModal, onOpenUserSettings, onOpenAdminSettings, + onOpenProjectMembers, chatContent, docContent, activeWorkflowKey, @@ -136,6 +138,7 @@ export const AppShell: React.FC = ({ onOpenRightsModal={onOpenRightsModal} onOpenUserSettings={onOpenUserSettings} onOpenAdminSettings={onOpenAdminSettings} + onOpenProjectMembers={onOpenProjectMembers} isAdmin={currentUser.roleId === 'role-admin' || currentUser.name.includes('管理员')} /> diff --git a/web/src/components/layout/LeftRail.tsx b/web/src/components/layout/LeftRail.tsx index f30e8a85..39ce994b 100644 --- a/web/src/components/layout/LeftRail.tsx +++ b/web/src/components/layout/LeftRail.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Plus, Settings, User, ShieldCheck, MessageSquare, Shield } from 'lucide-react'; +import { Plus, Settings, User, ShieldCheck, MessageSquare, Shield, Users } from 'lucide-react'; import { ProjectSession } from '../../types/workbench'; import { UserRole, UserProfile } from '../../types/docgov'; @@ -16,6 +16,7 @@ interface LeftRailProps { onOpenRightsModal: () => void; onOpenUserSettings: () => void; onOpenAdminSettings?: () => void; + onOpenProjectMembers?: () => void; isAdmin?: boolean; } @@ -32,6 +33,7 @@ export const LeftRail: React.FC = ({ onOpenRightsModal, onOpenUserSettings, onOpenAdminSettings, + onOpenProjectMembers, isAdmin = false, }) => { if (collapsed) { @@ -113,12 +115,48 @@ export const LeftRail: React.FC = ({ gap: '6px', }} > -
- 项目会话 (一项目一会话) +
+ 项目会话 (一项目一会话) + {onOpenProjectMembers && ( + + )}
{sessions.map((sess) => { const isActive = sess.sessionId === activeSessionId; + const isExited = sess.isExited; return (
= ({ flexDirection: 'column', gap: '4px', transition: 'all 0.15s var(--ease)', + opacity: isExited ? 0.75 : 1, }} onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'; @@ -141,31 +180,49 @@ export const LeftRail: React.FC = ({ if (!isActive) e.currentTarget.style.background = 'transparent'; }} > -
+
{sess.title} - - {sess.stage} - +
+ {isExited && ( + + 已退出 + + )} + + {sess.stage} + +
diff --git a/web/src/components/project/CreateProjectModal.tsx b/web/src/components/project/CreateProjectModal.tsx new file mode 100644 index 00000000..0e38f422 --- /dev/null +++ b/web/src/components/project/CreateProjectModal.tsx @@ -0,0 +1,594 @@ +import React, { useState } from 'react'; +import { X, Sparkles, FolderPlus, Users, Check } from 'lucide-react'; +import { UserProfile } from '../../types/docgov'; +import { ProjectMemberRole } from '../../types/workbench'; + +interface CreateProjectModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: { + title: string; + deviceType: string; + description: string; + initialMembers: Array<{ + userId: number; + userName: string; + department?: string; + position?: string; + role: ProjectMemberRole; + }>; + }) => void; + currentUser: UserProfile; + availableUsers: UserProfile[]; +} + +const DEVICE_TYPE_OPTIONS = [ + '数字化高频超声手术吸引系统', + '微创骨科手术机器人与导航定位系统', + '膝骨关节炎综合治疗仪', + '远程超声移动巡诊系统', + '一次性使用高频切凝手术电极', + '牙髓根管预备与热牙胶充填系统', + '自研增强现实 (AR) 手术导航系统', + '一次性使用双极低温等离子手术电极', + '医用高频低温等离子手术系统', + '气压弹道式体外冲击波治疗仪', + '肌骨关节超声影像智能辅助处理软件', + '其他创新医疗器械', +]; + +export const CreateProjectModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + currentUser, + availableUsers, +}) => { + const [title, setTitle] = useState(''); + const [deviceType, setDeviceType] = useState(DEVICE_TYPE_OPTIONS[0]); + const [customDeviceType, setCustomDeviceType] = useState(''); + const [description, setDescription] = useState(''); + + // 初始成员勾选列表(默认包含当前创建者) + const [selectedMembers, setSelectedMembers] = useState< + Record + >(() => { + const init: Record = {}; + availableUsers.forEach((u) => { + if (u.id === currentUser.id) { + init[u.id] = { selected: true, role: 'creator' }; + } else if (u.id === 2) { + // 默认推荐李主管为审核人 + init[u.id] = { selected: true, role: 'reviewer' }; + } else if (u.id === 3) { + // 默认推荐王总监为批准人 + init[u.id] = { selected: true, role: 'approver' }; + } else { + init[u.id] = { selected: false, role: 'viewer' }; + } + }); + return init; + }); + + const [errorMsg, setErrorMsg] = useState(''); + + if (!isOpen) return null; + + const handleToggleUser = (userId: number) => { + if (userId === currentUser.id) return; // 创建者不可取消 + setSelectedMembers((prev) => ({ + ...prev, + [userId]: { + selected: !prev[userId]?.selected, + role: prev[userId]?.role || 'viewer', + }, + })); + }; + + const handleChangeRole = (userId: number, role: ProjectMemberRole) => { + setSelectedMembers((prev) => ({ + ...prev, + [userId]: { + ...prev[userId], + role, + }, + })); + }; + + const handleConfirm = (e: React.FormEvent) => { + e.preventDefault(); + const cleanTitle = title.trim(); + if (!cleanTitle) { + setErrorMsg('请输入项目名称'); + return; + } + + const finalDeviceType = + deviceType === '其他创新医疗器械' ? customDeviceType.trim() || '通用医疗器械' : deviceType; + + const initialMembers: Array<{ + userId: number; + userName: string; + department?: string; + position?: string; + role: ProjectMemberRole; + }> = []; + + // 确保创建者在列表中 + initialMembers.push({ + userId: currentUser.id, + userName: currentUser.name, + department: currentUser.department, + position: currentUser.position, + role: 'creator', + }); + + // 添加其他选中的成员 + availableUsers.forEach((u) => { + if (u.id !== currentUser.id && selectedMembers[u.id]?.selected) { + initialMembers.push({ + userId: u.id, + userName: u.name, + department: u.department, + position: u.position, + role: selectedMembers[u.id]?.role || 'viewer', + }); + } + }); + + onSubmit({ + title: cleanTitle, + deviceType: finalDeviceType, + description: description.trim(), + initialMembers, + }); + + // 重置并关闭 + setTitle(''); + setDescription(''); + setErrorMsg(''); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + > + {/* 头部 */} +
+
+
+ +
+
+

+ 发起医疗器械新项目立项 +

+

+ 创建项目将自动注入全套受控文档模板,并为您配置专属项目成员协作空间 +

+
+
+ +
+ + {/* 表单内容 */} +
+ {errorMsg && ( +
+ {errorMsg} +
+ )} + + {/* 1. 项目名称 */} +
+ + { + setTitle(e.target.value); + if (errorMsg) setErrorMsg(''); + }} + style={{ + width: '100%', + height: '40px', + padding: '0 12px', + borderRadius: '8px', + border: '1px solid var(--border, #cbd5e1)', + background: 'var(--bg-subtle, #f8fafc)', + fontSize: '14px', + color: 'var(--text-strong, #0f172a)', + outline: 'none', + boxSizing: 'border-box', + }} + autoFocus + /> +
+ + {/* 2. 器械类型 */} +
+ + + {deviceType === '其他创新医疗器械' && ( + setCustomDeviceType(e.target.value)} + style={{ + width: '100%', + height: '38px', + padding: '0 12px', + borderRadius: '8px', + border: '1px solid var(--border, #cbd5e1)', + background: 'var(--bg-subtle, #f8fafc)', + fontSize: '13px', + outline: 'none', + boxSizing: 'border-box', + }} + /> + )} +
+ + {/* 3. 临床概述 */} +
+ +