feat(project): implement modal-based project creation, creator-led member assignment, and exited-member read-only retention

This commit is contained in:
WeiCHEN
2026-09-11 10:10:33 +08:00
parent af003a3bb9
commit c72bcc8ab0
10 changed files with 2086 additions and 75 deletions
+317 -57
View File
@@ -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<DeliverableCategory>('workflow');
// 项目与成员体系状态 (支持持久化)
const [projects, setProjects] = useState<ProjectInfo[]>(() => 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<string>(() => {
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<ProjectMember, 'joinedAt' | 'status'>) => {
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 (
<>
<AppShell
sessions={visibleSessions}
activeSessionId={activeSessionId}
onSelectSession={(sessId) => {
// 切换项目时清理旧会话的残留锚点 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="您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。"
/>
</div>
}
docContent={
isCurrentProjectExited ? (
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: '40px 30px',
background: 'var(--bg-subtle, #f8fafc)',
textAlign: 'center',
}}
>
<div
style={{
width: '64px',
height: '64px',
borderRadius: '16px',
background: 'rgba(239, 68, 68, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#dc2626',
marginBottom: '16px',
}}
>
<ShieldAlert size={32} />
</div>
<h3 style={{ margin: '0 0 8px', fontSize: '18px', fontWeight: 600, color: 'var(--text-strong, #0f172a)' }}>
受控文档已执行合规脱密隔离
</h3>
<p
style={{
margin: 0,
maxWidth: '440px',
fontSize: '13px',
color: 'var(--text-muted, #64748b)',
lineHeight: 1.6,
}}
>
您已退出该研发项目。依据医疗器械质量管理体系 (GMP/ISO 13485) 及商业保密规程,本项目技术规格书、设计输入草案与验证交付物已对您执行访问切断。历史聊天记录仅供审计留痕查阅。
</p>
</div>
) : (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden', position: 'relative' }}>
{/* 36px 极简 Tab 条 (含三分类分段切换与私密/共享识别) */}
<SlimTabBar
@@ -2624,6 +2861,7 @@ export default function App() {
/>
)}
</div>
)
}
/>
@@ -2736,6 +2974,28 @@ export default function App() {
currentUserName={currentUser.name}
onSaveRule={handleSaveDocVisibilityRule}
/>
{/* 发起医疗器械新项目立项弹窗 */}
<CreateProjectModal
isOpen={createProjectModalOpen}
onClose={() => setCreateProjectModalOpen(false)}
onSubmit={handleCreateProjectConfirm}
currentUser={currentUser}
availableUsers={allUsers}
/>
{/* 项目成员与职责分配管理弹窗 */}
<ProjectMembersModal
isOpen={projectMembersModalOpen}
onClose={() => setProjectMembersModalOpen(false)}
project={currentProject}
currentUser={currentUser}
availableUsers={allUsers}
onAddMember={handleAddProjectMember}
onUpdateMemberRole={handleUpdateProjectMemberRole}
onRemoveMember={handleRemoveProjectMember}
onRestoreMember={handleRestoreProjectMember}
/>
</>
);
}
+42 -1
View File
@@ -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<InputAreaProps> = ({
@@ -23,6 +25,8 @@ export const InputArea: React.FC<InputAreaProps> = ({
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<InputAreaProps> = ({
setAttachments((prev) => prev.filter((_, i) => i !== index));
};
if (disabled) {
return (
<div
style={{
padding: '16px 20px',
background: 'var(--bg-subtle, #f8fafc)',
borderTop: '1px solid var(--border-light, #e2e8f0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
color: 'var(--text-muted, #64748b)',
fontSize: '13px',
}}
>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'rgba(239, 68, 68, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#dc2626',
}}
>
<Lock size={14} />
</div>
<span>
{disabledMessage ||
'您已退出该研发项目。依据受控体系审计要求,会话已转为只读留痕模式,无法继续发送消息。'}
</span>
</div>
);
}
return (
<div
style={{
+3
View File
@@ -20,6 +20,7 @@ interface AppShellProps {
onOpenRightsModal: () => void;
onOpenUserSettings: () => void;
onOpenAdminSettings?: () => void;
onOpenProjectMembers?: () => void;
chatContent: React.ReactNode;
docContent: React.ReactNode;
activeWorkflowKey?: string;
@@ -42,6 +43,7 @@ export const AppShell: React.FC<AppShellProps> = ({
onOpenRightsModal,
onOpenUserSettings,
onOpenAdminSettings,
onOpenProjectMembers,
chatContent,
docContent,
activeWorkflowKey,
@@ -136,6 +138,7 @@ export const AppShell: React.FC<AppShellProps> = ({
onOpenRightsModal={onOpenRightsModal}
onOpenUserSettings={onOpenUserSettings}
onOpenAdminSettings={onOpenAdminSettings}
onOpenProjectMembers={onOpenProjectMembers}
isAdmin={currentUser.roleId === 'role-admin' || currentUser.name.includes('管理员')}
/>
+74 -17
View File
@@ -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<LeftRailProps> = ({
onOpenRightsModal,
onOpenUserSettings,
onOpenAdminSettings,
onOpenProjectMembers,
isAdmin = false,
}) => {
if (collapsed) {
@@ -113,12 +115,48 @@ export const LeftRail: React.FC<LeftRailProps> = ({
gap: '6px',
}}
>
<div style={{ padding: '4px 6px', fontSize: '11px', fontWeight: 600, color: 'var(--text-faint)' }}>
项目会话 (一项目一会话)
<div
style={{
padding: '4px 6px',
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-faint)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span>项目会话 (一项目一会话)</span>
{onOpenProjectMembers && (
<button
onClick={(e) => {
e.stopPropagation();
onOpenProjectMembers();
}}
style={{
border: 'none',
background: 'transparent',
cursor: 'pointer',
color: 'var(--brand-600, #4f6bff)',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
gap: '3px',
padding: '2px 6px',
borderRadius: '4px',
fontWeight: 600,
}}
title="管理当前项目成员与职责分配"
>
<Users size={12} />
<span>项目成员</span>
</button>
)}
</div>
{sessions.map((sess) => {
const isActive = sess.sessionId === activeSessionId;
const isExited = sess.isExited;
return (
<div
key={sess.sessionId}
@@ -133,6 +171,7 @@ export const LeftRail: React.FC<LeftRailProps> = ({
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<LeftRailProps> = ({
if (!isActive) e.currentTarget.style.background = 'transparent';
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '6px' }}>
<span
style={{
fontSize: '13px',
fontWeight: isActive ? 600 : 500,
color: isActive ? 'var(--text-strong)' : 'var(--text)',
color: isExited ? 'var(--text-muted)' : isActive ? 'var(--text-strong)' : 'var(--text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
title={isExited ? `${sess.title} (您已退出该项目)` : sess.title}
>
{sess.title}
</span>
<span
style={{
fontSize: '10px',
fontWeight: 600,
padding: '1px 6px',
borderRadius: 'var(--r-pill)',
background: isActive ? 'var(--brand-50)' : 'var(--bg-subtle)',
color: isActive ? 'var(--brand-600)' : 'var(--text-muted)',
}}
>
{sess.stage}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', flexShrink: 0 }}>
{isExited && (
<span
style={{
fontSize: '9px',
fontWeight: 600,
padding: '1px 5px',
borderRadius: '4px',
background: 'rgba(239, 68, 68, 0.12)',
color: '#dc2626',
}}
>
已退出
</span>
)}
<span
style={{
fontSize: '10px',
fontWeight: 600,
padding: '1px 6px',
borderRadius: 'var(--r-pill)',
background: isActive ? 'var(--brand-50)' : 'var(--bg-subtle)',
color: isActive ? 'var(--brand-600)' : 'var(--text-muted)',
}}
>
{sess.stage}
</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '11px', color: 'var(--text-muted)' }}>
@@ -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<CreateProjectModalProps> = ({
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<number, { selected: boolean; role: ProjectMemberRole }>
>(() => {
const init: Record<number, { selected: boolean; role: ProjectMemberRole }> = {};
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 (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
background: 'rgba(15, 23, 42, 0.65)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={onClose}
>
<div
style={{
width: '100%',
maxWidth: '620px',
maxHeight: '90vh',
background: 'var(--bg-panel, #ffffff)',
borderRadius: '16px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.1)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div
style={{
padding: '18px 24px',
borderBottom: '1px solid var(--border-light, #e2e8f0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'linear-gradient(135deg, rgba(79, 107, 255, 0.05) 0%, rgba(147, 51, 234, 0.05) 100%)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '10px',
background: 'var(--brand-grad, linear-gradient(135deg, #4f6bff 0%, #7c3aed 100%))',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>
<FolderPlus size={20} />
</div>
<div>
<h2 style={{ margin: 0, fontSize: '16px', fontWeight: 600, color: 'var(--text-strong, #0f172a)' }}>
发起医疗器械新项目立项
</h2>
<p style={{ margin: '2px 0 0', fontSize: '12px', color: 'var(--text-muted, #64748b)' }}>
创建项目将自动注入全套受控文档模板,并为您配置专属项目成员协作空间
</p>
</div>
</div>
<button
onClick={onClose}
style={{
border: 'none',
background: 'transparent',
cursor: 'pointer',
color: 'var(--text-muted, #64748b)',
padding: '6px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<X size={18} />
</button>
</div>
{/* 表单内容 */}
<form onSubmit={handleConfirm} style={{ flex: 1, overflowY: 'auto', padding: '20px 24px' }}>
{errorMsg && (
<div
style={{
padding: '10px 14px',
marginBottom: '16px',
borderRadius: '8px',
background: 'rgba(239, 68, 68, 0.1)',
color: '#dc2626',
fontSize: '13px',
border: '1px solid rgba(239, 68, 68, 0.2)',
}}
>
{errorMsg}
</div>
)}
{/* 1. 项目名称 */}
<div style={{ marginBottom: '18px' }}>
<label
style={{
display: 'block',
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-strong, #1e293b)',
marginBottom: '6px',
}}
>
项目名称 <span style={{ color: '#ef4444' }}>*</span>
</label>
<input
type="text"
placeholder="例如:无线便携式双频超声骨动力微创手术系统"
value={title}
onChange={(e) => {
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
/>
</div>
{/* 2. 器械类型 */}
<div style={{ marginBottom: '18px' }}>
<label
style={{
display: 'block',
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-strong, #1e293b)',
marginBottom: '6px',
}}
>
医疗器械分类与品类
</label>
<select
value={deviceType}
onChange={(e) => setDeviceType(e.target.value)}
style={{
width: '100%',
height: '40px',
padding: '0 12px',
borderRadius: '8px',
border: '1px solid var(--border, #cbd5e1)',
background: 'var(--bg-subtle, #f8fafc)',
fontSize: '13px',
color: 'var(--text-strong, #0f172a)',
outline: 'none',
boxSizing: 'border-box',
marginBottom: deviceType === '其他创新医疗器械' ? '8px' : '0',
}}
>
{DEVICE_TYPE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
{deviceType === '其他创新医疗器械' && (
<input
type="text"
placeholder="请输入自定义医疗器械品类名称"
value={customDeviceType}
onChange={(e) => 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',
}}
/>
)}
</div>
{/* 3. 临床概述 */}
<div style={{ marginBottom: '22px' }}>
<label
style={{
display: 'block',
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-strong, #1e293b)',
marginBottom: '6px',
}}
>
临床预期用途与立项构想(选填)
</label>
<textarea
rows={2}
placeholder="简要说明产品临床科室、主要技术亮点或目标准入法规标准(GB 9706 / YY 系列)..."
value={description}
onChange={(e) => setDescription(e.target.value)}
style={{
width: '100%',
padding: '8px 12px',
borderRadius: '8px',
border: '1px solid var(--border, #cbd5e1)',
background: 'var(--bg-subtle, #f8fafc)',
fontSize: '13px',
color: 'var(--text, #334155)',
outline: 'none',
resize: 'none',
boxSizing: 'border-box',
}}
/>
</div>
{/* 4. 添加项目成员与职责分配 */}
<div style={{ marginBottom: '10px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
}}
>
<label
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-strong, #1e293b)',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<Users size={15} color="var(--brand-500, #4f6bff)" />
初始项目成员与职责分配
</label>
<span style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
创建者拥有全权管理职责,立项后可随时增减
</span>
</div>
<div
style={{
border: '1px solid var(--border-light, #e2e8f0)',
borderRadius: '10px',
overflow: 'hidden',
background: 'var(--bg-card, #ffffff)',
}}
>
{availableUsers.map((user) => {
const isCreator = user.id === currentUser.id;
const config = selectedMembers[user.id] || { selected: isCreator, role: 'viewer' };
const isSelected = isCreator || config.selected;
return (
<div
key={user.id}
style={{
padding: '10px 14px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid var(--border-light, #f1f5f9)',
background: isSelected ? 'rgba(79, 107, 255, 0.03)' : 'transparent',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
cursor: isCreator ? 'default' : 'pointer',
}}
onClick={() => handleToggleUser(user.id)}
>
<div
style={{
width: '18px',
height: '18px',
borderRadius: '4px',
border: isSelected
? '1px solid var(--brand-500, #4f6bff)'
: '1px solid var(--border, #cbd5e1)',
background: isSelected ? 'var(--brand-500, #4f6bff)' : '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>
{isSelected && <Check size={13} strokeWidth={3} />}
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-strong, #0f172a)',
}}
>
{user.name}
</span>
{isCreator && (
<span
style={{
fontSize: '10px',
background: 'rgba(79, 107, 255, 0.15)',
color: 'var(--brand-600, #4338ca)',
padding: '1px 6px',
borderRadius: '4px',
fontWeight: 600,
}}
>
创建者 (您)
</span>
)}
<span style={{ fontSize: '11px', color: 'var(--text-faint, #94a3b8)' }}>
{user.position}
</span>
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)' }}>
{user.department}
</div>
</div>
</div>
{/* 职责下拉 */}
<div>
{isCreator ? (
<span
style={{
fontSize: '12px',
fontWeight: 600,
color: 'var(--brand-600, #4338ca)',
background: 'rgba(79, 107, 255, 0.08)',
padding: '4px 10px',
borderRadius: '6px',
}}
>
创建人兼编制人
</span>
) : (
<select
disabled={!isSelected}
value={config.role}
onChange={(e) => handleChangeRole(user.id, e.target.value as ProjectMemberRole)}
style={{
height: '30px',
padding: '0 8px',
borderRadius: '6px',
border: '1px solid var(--border, #cbd5e1)',
background: isSelected ? '#fff' : 'var(--bg-subtle, #f1f5f9)',
color: isSelected ? 'var(--text-strong, #1e293b)' : 'var(--text-faint, #94a3b8)',
fontSize: '12px',
cursor: isSelected ? 'pointer' : 'not-allowed',
outline: 'none',
}}
>
<option value="author">编制人 (Author)</option>
<option value="reviewer">审核人 (Reviewer)</option>
<option value="approver">批准人 (Approver)</option>
<option value="viewer">查阅者 (Viewer)</option>
</select>
)}
</div>
</div>
);
})}
</div>
</div>
</form>
{/* 底部按钮栏 */}
<div
style={{
padding: '14px 24px',
borderTop: '1px solid var(--border-light, #e2e8f0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: '12px',
background: 'var(--bg-subtle, #f8fafc)',
}}
>
<button
type="button"
onClick={onClose}
style={{
padding: '8px 16px',
borderRadius: '8px',
border: '1px solid var(--border, #cbd5e1)',
background: '#fff',
color: 'var(--text, #334155)',
fontSize: '13px',
fontWeight: 500,
cursor: 'pointer',
}}
>
取消
</button>
<button
type="button"
onClick={handleConfirm}
style={{
padding: '8px 20px',
borderRadius: '8px',
border: 'none',
background: 'var(--brand-grad, linear-gradient(135deg, #4f6bff 0%, #7c3aed 100%))',
color: '#fff',
fontSize: '13px',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
boxShadow: '0 2px 8px rgba(79, 107, 255, 0.3)',
}}
>
<Sparkles size={15} />
确认立项并创建会话
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,531 @@
import React, { useState } from 'react';
import { X, Users, UserPlus, Shield, UserMinus, RotateCcw, LogOut } from 'lucide-react';
import { ProjectInfo, ProjectMember, ProjectMemberRole } from '../../types/workbench';
import { UserProfile } from '../../types/docgov';
interface ProjectMembersModalProps {
isOpen: boolean;
onClose: () => void;
project: ProjectInfo | undefined;
currentUser: UserProfile;
availableUsers: UserProfile[];
onAddMember: (member: Omit<ProjectMember, 'joinedAt' | 'status'>) => void;
onUpdateMemberRole: (userId: number, role: ProjectMemberRole) => void;
onRemoveMember: (userId: number) => void; // 移出/置为已退出
onRestoreMember: (userId: number) => void; // 重新激活
}
export const ProjectMembersModal: React.FC<ProjectMembersModalProps> = ({
isOpen,
onClose,
project,
currentUser,
availableUsers,
onAddMember,
onUpdateMemberRole,
onRemoveMember,
onRestoreMember,
}) => {
const [showAddForm, setShowAddForm] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | ''>('');
const [selectedRole, setSelectedRole] = useState<ProjectMemberRole>('viewer');
if (!isOpen || !project) return null;
const isCreator = currentUser.id === project.creatorId;
const isAdmin = currentUser.id === 4 || currentUser.roleId === 'role-admin';
const canManage = isCreator || isAdmin;
// 筛选尚未加入该项目的人员
const existingMemberIds = new Set(project.members.map((m) => m.userId));
const unjoinedUsers = availableUsers.filter((u) => !existingMemberIds.has(u.id));
const handleAddSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUserId) return;
const targetUser = availableUsers.find((u) => u.id === selectedUserId);
if (!targetUser) return;
onAddMember({
userId: targetUser.id,
userName: targetUser.name,
department: targetUser.department,
position: targetUser.position,
role: selectedRole,
});
setSelectedUserId('');
setSelectedRole('viewer');
setShowAddForm(false);
};
const getRoleBadge = (role: ProjectMemberRole) => {
switch (role) {
case 'creator':
return { label: '项目创建人', bg: 'rgba(79, 107, 255, 0.12)', color: '#4f6bff' };
case 'author':
return { label: '编制人 (Author)', bg: 'rgba(59, 130, 246, 0.12)', color: '#2563eb' };
case 'reviewer':
return { label: '审核人 (Reviewer)', bg: 'rgba(217, 119, 6, 0.12)', color: '#d97706' };
case 'approver':
return { label: '批准人 (Approver)', bg: 'rgba(16, 185, 129, 0.12)', color: '#059669' };
default:
return { label: '查阅者 (Viewer)', bg: 'rgba(100, 116, 139, 0.12)', color: '#64748b' };
}
};
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
background: 'rgba(15, 23, 42, 0.65)',
backdropFilter: 'blur(4px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={onClose}
>
<div
style={{
width: '100%',
maxWidth: '680px',
maxHeight: '85vh',
background: 'var(--bg-panel, #ffffff)',
borderRadius: '16px',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
onClick={(e) => e.stopPropagation()}
>
{/* 头部 */}
<div
style={{
padding: '18px 24px',
borderBottom: '1px solid var(--border-light, #e2e8f0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--bg-subtle, #f8fafc)',
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Users size={18} color="var(--brand-500, #4f6bff)" />
<h2 style={{ margin: 0, fontSize: '16px', fontWeight: 600, color: 'var(--text-strong, #0f172a)' }}>
项目成员与职责权限管理
</h2>
<span
style={{
fontSize: '11px',
background: 'rgba(79, 107, 255, 0.1)',
color: 'var(--brand-600, #4338ca)',
padding: '2px 8px',
borderRadius: '10px',
fontWeight: 600,
}}
>
{project.members.filter((m) => m.status === 'active').length} 位在职成员
</span>
</div>
<p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--text-muted, #64748b)' }}>
当前项目:<strong>{project.title}</strong> (创建人: {project.creatorName})
</p>
</div>
<button
onClick={onClose}
style={{
border: 'none',
background: 'transparent',
cursor: 'pointer',
color: 'var(--text-muted, #64748b)',
padding: '6px',
borderRadius: '8px',
}}
>
<X size={18} />
</button>
</div>
{/* 主体列表 */}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px' }}>
{/* 管理员/创建者操作提示 */}
<div
style={{
padding: '10px 14px',
marginBottom: '16px',
borderRadius: '8px',
background: canManage ? 'rgba(79, 107, 255, 0.05)' : 'rgba(100, 116, 139, 0.06)',
border: '1px solid var(--border-light, #e2e8f0)',
fontSize: '12px',
color: 'var(--text, #334155)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Shield size={15} color={canManage ? '#4f6bff' : '#64748b'} />
<span>
{canManage
? '您是该项目的创建人/管理员,有权添加新成员、调整法定职责或移出成员。'
: '您是该项目的协同成员,仅可查阅成员名单与职责分配。'}
</span>
</div>
{canManage && !showAddForm && unjoinedUsers.length > 0 && (
<button
onClick={() => setShowAddForm(true)}
style={{
padding: '4px 12px',
borderRadius: '6px',
border: 'none',
background: 'var(--brand-500, #4f6bff)',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<UserPlus size={13} />
添加成员
</button>
)}
</div>
{/* 添加新成员表单 */}
{showAddForm && (
<form
onSubmit={handleAddSubmit}
style={{
padding: '14px 16px',
marginBottom: '18px',
borderRadius: '10px',
border: '1px solid var(--brand-200, #bfdbfe)',
background: 'rgba(79, 107, 255, 0.04)',
display: 'flex',
alignItems: 'center',
gap: '12px',
flexWrap: 'wrap',
}}
>
<div style={{ flex: 1, minWidth: '180px' }}>
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(Number(e.target.value))}
style={{
width: '100%',
height: '34px',
padding: '0 8px',
borderRadius: '6px',
border: '1px solid var(--border, #cbd5e1)',
background: '#fff',
fontSize: '12px',
outline: 'none',
}}
required
>
<option value="">-- 请选择要添加的成员 --</option>
{unjoinedUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name} ({u.position} - {u.department})
</option>
))}
</select>
</div>
<div style={{ width: '150px' }}>
<select
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value as ProjectMemberRole)}
style={{
width: '100%',
height: '34px',
padding: '0 8px',
borderRadius: '6px',
border: '1px solid var(--border, #cbd5e1)',
background: '#fff',
fontSize: '12px',
outline: 'none',
}}
>
<option value="author">编制人 (Author)</option>
<option value="reviewer">审核人 (Reviewer)</option>
<option value="approver">批准人 (Approver)</option>
<option value="viewer">查阅者 (Viewer)</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="submit"
disabled={!selectedUserId}
style={{
padding: '0 14px',
height: '34px',
borderRadius: '6px',
border: 'none',
background: 'var(--brand-500, #4f6bff)',
color: '#fff',
fontSize: '12px',
fontWeight: 600,
cursor: selectedUserId ? 'pointer' : 'not-allowed',
opacity: selectedUserId ? 1 : 0.6,
}}
>
确认加入
</button>
<button
type="button"
onClick={() => setShowAddForm(false)}
style={{
padding: '0 12px',
height: '34px',
borderRadius: '6px',
border: '1px solid var(--border, #cbd5e1)',
background: '#fff',
color: 'var(--text, #334155)',
fontSize: '12px',
cursor: 'pointer',
}}
>
取消
</button>
</div>
</form>
)}
{/* 成员列表 */}
<div
style={{
border: '1px solid var(--border-light, #e2e8f0)',
borderRadius: '10px',
overflow: 'hidden',
background: '#fff',
}}
>
{project.members.map((member) => {
const badge = getRoleBadge(member.role);
const isExited = member.status === 'exited';
const isMemberCreator = member.role === 'creator' || member.userId === project.creatorId;
return (
<div
key={member.userId}
style={{
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid var(--border-light, #f1f5f9)',
background: isExited ? 'var(--bg-subtle, #f8fafc)' : '#fff',
opacity: isExited ? 0.75 : 1,
}}
>
{/* 左侧成员信息 */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
fontSize: '13px',
fontWeight: 600,
color: isExited ? 'var(--text-muted, #64748b)' : 'var(--text-strong, #0f172a)',
}}
>
{member.userName}
</span>
{/* 角色徽章 */}
<span
style={{
fontSize: '11px',
padding: '2px 8px',
borderRadius: '4px',
fontWeight: 600,
background: badge.bg,
color: badge.color,
}}
>
{badge.label}
</span>
{/* 状态标签 */}
{isExited ? (
<span
style={{
fontSize: '10px',
padding: '1px 6px',
borderRadius: '4px',
background: '#fee2e2',
color: '#dc2626',
fontWeight: 600,
}}
>
已退出 ({member.exitedAt || '历史记录留痕'})
</span>
) : (
<span
style={{
fontSize: '10px',
padding: '1px 6px',
borderRadius: '4px',
background: '#dcfce7',
color: '#15803d',
fontWeight: 600,
}}
>
在职成员
</span>
)}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted, #64748b)', marginTop: '3px' }}>
{member.department || '研发中心'} · {member.position || '研发岗位'} · 加入时间: {member.joinedAt}
</div>
</div>
{/* 右侧管理操作 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
{canManage && !isMemberCreator && (
<>
{!isExited ? (
<>
{/* 修改职责 */}
<select
value={member.role}
onChange={(e) => onUpdateMemberRole(member.userId, e.target.value as ProjectMemberRole)}
style={{
height: '28px',
padding: '0 6px',
borderRadius: '6px',
border: '1px solid var(--border, #cbd5e1)',
background: '#fff',
fontSize: '11px',
color: 'var(--text-strong, #1e293b)',
outline: 'none',
}}
>
<option value="author">编制人</option>
<option value="reviewer">审核人</option>
<option value="approver">批准人</option>
<option value="viewer">查阅者</option>
</select>
{/* 移出项目 */}
<button
onClick={() => onRemoveMember(member.userId)}
title="将该成员移出项目(保留历史会话但限制后续操作与文档查阅)"
style={{
padding: '4px 8px',
borderRadius: '6px',
border: '1px solid rgba(239, 68, 68, 0.3)',
background: 'rgba(239, 68, 68, 0.06)',
color: '#dc2626',
fontSize: '11px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<UserMinus size={12} />
移出项目
</button>
</>
) : (
/* 重新激活恢复 */
<button
onClick={() => onRestoreMember(member.userId)}
title="重新激活该成员在项目中的工作权限"
style={{
padding: '4px 10px',
borderRadius: '6px',
border: '1px solid rgba(16, 185, 129, 0.3)',
background: 'rgba(16, 185, 129, 0.08)',
color: '#059669',
fontSize: '11px',
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<RotateCcw size={12} />
恢复加入
</button>
)}
</>
)}
{/* 普通在职成员自主退出项目 */}
{!canManage && member.userId === currentUser.id && !isExited && (
<button
onClick={() => onRemoveMember(currentUser.id)}
style={{
padding: '4px 10px',
borderRadius: '6px',
border: '1px solid rgba(239, 68, 68, 0.3)',
background: 'rgba(239, 68, 68, 0.08)',
color: '#dc2626',
fontSize: '11px',
fontWeight: 500,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<LogOut size={12} />
退出该项目
</button>
)}
</div>
</div>
);
})}
</div>
</div>
{/* 底部 */}
<div
style={{
padding: '12px 24px',
borderTop: '1px solid var(--border-light, #e2e8f0)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: 'var(--bg-subtle, #f8fafc)',
fontSize: '12px',
color: 'var(--text-muted, #64748b)',
}}
>
<div>
💡 退出项目说明:退出成员将保留已有聊天记录供审计留痕,但无法再发送对话或查阅受控交付物。
</div>
<button
onClick={onClose}
style={{
padding: '6px 16px',
borderRadius: '6px',
border: '1px solid var(--border, #cbd5e1)',
background: '#fff',
fontSize: '12px',
cursor: 'pointer',
}}
>
完成并关闭
</button>
</div>
</div>
</div>
);
};
+209
View File
@@ -0,0 +1,209 @@
import { ProjectInfo, ProjectMember, ProjectMemberRole, ProjectSession } from '../types/workbench';
import { TEN_PROJECTS_LIST } from './tenProjectsDemo';
const STORAGE_KEY_PROJECTS = 'smdf_agent_workbench_v6_projects';
/**
* 默认预设项目列表与成员职责基线
* 包含:5201 (NeuroPulse), 3 (CUSA-Ultra), 10 个医疗器械评审项目 (6001~6010)
* 默认成员:张工 (创建者/编制人), 李主管 (审核人), 王总监 (批准人)
*/
export const DEFAULT_PROJECTS: ProjectInfo[] = [
{
id: 5201,
title: '超声吸引系统 (NeuroPulse-CUSA)',
deviceType: '数字化高频超声手术吸引系统',
description: '神经外科颅内微创高频超声吸引与切割系统研发项目',
creatorId: 1,
creatorName: '张工',
createdAt: '2026-09-08 09:00',
members: [
{
userId: 1,
userName: '张工',
department: '机器人与微创器械研发中心',
position: '方案工程师',
role: 'creator',
status: 'active',
joinedAt: '2026-09-08 09:00',
},
{
userId: 2,
userName: '李主管',
department: '机器人与微创内窥镜研发中心',
position: '研发项目技术主管',
role: 'reviewer',
status: 'active',
joinedAt: '2026-09-08 09:10',
},
{
userId: 3,
userName: '王总监',
department: '医疗器械研发事业部',
position: '微创器械研发总监',
role: 'approver',
status: 'active',
joinedAt: '2026-09-08 09:15',
},
],
},
{
id: 3,
title: '数字化超声手术吸引系统 (CUSA-Ultra)',
deviceType: '高频超声手术吸引切割系统',
description: '多频段钛合金压电换能手术吸引切割设备研发',
creatorId: 1,
creatorName: '张工',
createdAt: '2026-09-07 14:00',
members: [
{
userId: 1,
userName: '张工',
department: '机器人与微创器械研发中心',
position: '方案工程师',
role: 'creator',
status: 'active',
joinedAt: '2026-09-07 14:00',
},
{
userId: 2,
userName: '李主管',
department: '机器人与微创内窥镜研发中心',
position: '研发项目技术主管',
role: 'reviewer',
status: 'active',
joinedAt: '2026-09-07 14:10',
},
{
userId: 3,
userName: '王总监',
department: '医疗器械研发事业部',
position: '微创器械研发总监',
role: 'approver',
status: 'active',
joinedAt: '2026-09-07 14:20',
},
],
},
...TEN_PROJECTS_LIST.map((p) => ({
id: p.id,
title: `${p.name} (${p.shortName})`,
deviceType: p.deviceType,
description: `对标临床 ${p.customer},面向 ${p.targetMarket} 准入。${p.intendedUse}`,
creatorId: p.creatorUserId || 1,
creatorName: '张工',
createdAt: '2026-09-11 08:30',
members: [
{
userId: 1,
userName: '张工',
department: '机器人与微创器械研发中心',
position: '方案工程师',
role: 'creator' as ProjectMemberRole,
status: 'active' as const,
joinedAt: '2026-09-11 08:30',
},
{
userId: 2,
userName: '李主管',
department: '机器人与微创内窥镜研发中心',
position: '研发项目技术主管',
role: 'reviewer' as ProjectMemberRole,
status: 'active' as const,
joinedAt: '2026-09-11 08:40',
},
{
userId: 3,
userName: '王总监',
department: '医疗器械研发事业部',
position: '微创器械研发总监',
role: 'approver' as ProjectMemberRole,
status: 'active' as const,
joinedAt: '2026-09-11 08:50',
},
],
})),
];
/**
* 加载项目列表
*/
export function loadProjects(fallback: ProjectInfo[] = DEFAULT_PROJECTS): ProjectInfo[] {
try {
const raw = localStorage.getItem(STORAGE_KEY_PROJECTS);
if (!raw) return fallback;
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || parsed.length === 0) return fallback;
return parsed;
} catch (err) {
console.warn('[ProjectsStorage] 加载项目失败,回退默认基线:', err);
return fallback;
}
}
/**
* 保存项目列表
*/
export function saveProjects(projects: ProjectInfo[]): void {
try {
localStorage.setItem(STORAGE_KEY_PROJECTS, JSON.stringify(projects));
} catch (err) {
console.warn('[ProjectsStorage] 保存项目失败:', err);
}
}
/**
* 依据项目成员身份严格过滤当前用户可见的项目会话:
* 1. 未加入(非项目成员):完全不可见;
* 2. 在职成员 (active):正常可见,可操作;
* 3. 中途加入后已退出 (exited):保留会话显示,附加 isExited=true 标记供界面只读留痕;
* 4. 超级管理员 (userId === 4):通配可见所有会话。
*/
export function filterProjectSessionsForUser(
sessions: ProjectSession[],
projects: ProjectInfo[],
userId: number,
isAdmin: boolean = false
): ProjectSession[] {
if (isAdmin) {
// 管理员兜底全量可见
return sessions.filter((s) => s.userId === userId || s.userId == null);
}
const projectMap = new Map<number, ProjectInfo>();
for (const p of projects) {
projectMap.set(p.id, p);
}
const userSessions = sessions.filter((s) => s.userId === userId);
return userSessions.filter((s) => {
const proj = projectMap.get(s.projectId);
if (!proj) {
// 容错:若未配置项目元数据,默认可见
return true;
}
const member = proj.members.find((m) => m.userId === userId);
if (!member) {
// 未成为项目成员,完全不显示
return false;
}
if (member.status === 'exited') {
s.isExited = true;
return true;
}
s.isExited = false;
return true;
});
}
/**
* 获取用户在指定项目中的成员记录
*/
export function getProjectMembership(
project: ProjectInfo | undefined,
userId: number
): ProjectMember | undefined {
if (!project) return undefined;
return project.members.find((m) => m.userId === userId);
}
+8
View File
@@ -27,6 +27,14 @@ export {
dedupePrivateSessions,
} from './sessionPrivacy';
export {
DEFAULT_PROJECTS,
loadProjects,
saveProjects,
filterProjectSessionsForUser,
getProjectMembership,
} from './projectsStorage';
/**
* 默认预设项目会话(每人独立私聊:同一项目不同 userId 各自一条 session)
* 10 个医疗器械示例项目 + NeuroPulse (5201)
@@ -0,0 +1,282 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ProjectInfo,
ProjectSession,
} from '../types/workbench';
import {
DEFAULT_PROJECTS,
filterProjectSessionsForUser,
getProjectMembership,
} from '../data/projectsStorage';
test('项目立项、成员职责分配与退出留痕治理测试套件', async (t) => {
await t.test('1. 默认项目库包含完整元数据与法定三权成员 (active 状态)', () => {
assert.ok(DEFAULT_PROJECTS.length >= 12, '必须包含 10 个评审项目 + 2 个存量标杆项目');
DEFAULT_PROJECTS.forEach((p) => {
assert.ok(p.id > 0, '项目 ID 必须为正整数');
assert.ok(p.title, '项目必须包含标题');
assert.ok(p.creatorId > 0, '项目必须指定明确创建人 ID');
assert.ok(p.members.length >= 3, '默认项目至少包含编制/审核/批准 3 位在职成员');
const creatorMember = p.members.find((m) => m.userId === p.creatorId);
assert.ok(creatorMember, '项目成员列表中必须包含创建人');
assert.equal(creatorMember?.role, 'creator', '创建人初始角色必须为 creator');
assert.equal(creatorMember?.status, 'active', '创建人初始状态必须为 active');
// 验证在职成员状态
p.members.forEach((m) => {
assert.equal(m.status, 'active', '初始成员状态必须均为 active');
assert.ok(m.joinedAt, '成员必须记录加入时间');
});
});
});
await t.test('2. 跨用户会话可见性严格隔离:未加入成员完全不可见', () => {
// 模拟项目 9001
const testProject: ProjectInfo = {
id: 9001,
title: '全磁悬浮人工心脏离心泵研发项目',
deviceType: '植入式有源医疗器械',
creatorId: 1, // 张工
creatorName: '张工',
createdAt: '2026-09-11 10:00',
members: [
{
userId: 1,
userName: '张工',
role: 'creator',
status: 'active',
joinedAt: '2026-09-11 10:00',
},
{
userId: 2,
userName: '李主管',
role: 'reviewer',
status: 'active',
joinedAt: '2026-09-11 10:05',
},
],
};
const allSessions: ProjectSession[] = [
{
sessionId: 'sess-p9001-u1',
userId: 1,
projectId: 9001,
title: testProject.title,
deviceType: testProject.deviceType,
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 3,
updatedAt: '刚刚',
},
{
sessionId: 'sess-p9001-u2',
userId: 2,
projectId: 9001,
title: testProject.title,
deviceType: testProject.deviceType,
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 2,
updatedAt: '刚刚',
},
{
sessionId: 'sess-p9001-u5',
userId: 5, // 钱审计 (未加入该项目)
projectId: 9001,
title: testProject.title,
deviceType: testProject.deviceType,
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 0,
updatedAt: '刚刚',
},
];
const currentProjects = [testProject];
// 张工 (userId: 1) 可见
const zhangVisible = filterProjectSessionsForUser(allSessions, currentProjects, 1, false);
assert.equal(zhangVisible.length, 1);
assert.equal(zhangVisible[0].sessionId, 'sess-p9001-u1');
assert.equal(zhangVisible[0].isExited, false);
// 李主管 (userId: 2) 可见
const liVisible = filterProjectSessionsForUser(allSessions, currentProjects, 2, false);
assert.equal(liVisible.length, 1);
assert.equal(liVisible[0].sessionId, 'sess-p9001-u2');
assert.equal(liVisible[0].isExited, false);
// 钱审计 (userId: 5, 未加入) 完全不可见!
const qianVisible = filterProjectSessionsForUser(allSessions, currentProjects, 5, false);
assert.equal(qianVisible.length, 0, '未加入该项目的用户在界面中绝对不可见该项目会话');
});
await t.test('3. 创建者添加新成员并分配职责后,新成员即刻可见该项目会话', () => {
const project: ProjectInfo = {
id: 9002,
title: '高精内窥镜荧光摄像系统',
deviceType: '医用光学成像设备',
creatorId: 1,
creatorName: '张工',
createdAt: '2026-09-11 10:10',
members: [
{
userId: 1,
userName: '张工',
role: 'creator',
status: 'active',
joinedAt: '2026-09-11 10:10',
},
],
};
const allSessions: ProjectSession[] = [
{
sessionId: 'sess-p9002-u1',
userId: 1,
projectId: 9002,
title: project.title,
deviceType: project.deviceType,
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 1,
updatedAt: '刚刚',
},
{
sessionId: 'sess-p9002-u3',
userId: 3,
projectId: 9002,
title: project.title,
deviceType: project.deviceType,
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 1,
updatedAt: '刚刚',
},
];
// 添加王总监 (userId: 3) 为批准人
project.members.push({
userId: 3,
userName: '王总监',
role: 'approver',
status: 'active',
joinedAt: '2026-09-11 10:15',
});
const wangVisible = filterProjectSessionsForUser(allSessions, [project], 3, false);
assert.equal(wangVisible.length, 1, '成为项目成员后会话即刻显现');
assert.equal(wangVisible[0].sessionId, 'sess-p9002-u3');
assert.equal(wangVisible[0].isExited, false);
const membership = getProjectMembership(project, 3);
assert.equal(membership?.role, 'approver', '职责分配必须正确记录');
assert.equal(membership?.status, 'active', '状态必须为 active');
});
await t.test('4. 中途加入后期退出 (exited):保留项目会话但标记已退出只读留痕', () => {
const project: ProjectInfo = {
id: 9003,
title: '穿刺手术导航机器人系统',
deviceType: '手术机器人与空间配准系统',
creatorId: 1,
creatorName: '张工',
createdAt: '2026-09-11 10:20',
members: [
{
userId: 1,
userName: '张工',
role: 'creator',
status: 'active',
joinedAt: '2026-09-11 10:20',
},
{
userId: 5,
userName: '钱审计',
role: 'viewer',
status: 'active',
joinedAt: '2026-09-11 10:25',
},
],
};
const allSessions: ProjectSession[] = [
{
sessionId: 'sess-p9003-u5',
userId: 5,
projectId: 9003,
title: project.title,
deviceType: project.deviceType,
stage: 'V0.5',
stageLabel: 'V0.5 方案成型与法规对齐',
messageCount: 6,
updatedAt: '刚刚',
},
];
// 1. 退出前:在职成员正常可见
let qianVisible = filterProjectSessionsForUser(allSessions, [project], 5, false);
assert.equal(qianVisible.length, 1);
assert.equal(qianVisible[0].isExited, false);
// 2. 模拟钱审计退出该项目
const memberIndex = project.members.findIndex((m) => m.userId === 5);
project.members[memberIndex] = {
...project.members[memberIndex],
status: 'exited',
exitedAt: '2026-09-11 10:30',
};
// 3. 退出后:会话仍然保留,但标记 isExited = true
qianVisible = filterProjectSessionsForUser(allSessions, [project], 5, false);
assert.equal(qianVisible.length, 1, '退出成员必须保留该项目会话供历史留痕审计');
assert.equal(qianVisible[0].sessionId, 'sess-p9003-u5');
assert.equal(qianVisible[0].isExited, true, '会话必须被标记为 isExited=true 触发界面只读与文档脱密拦截');
const exitedMembership = getProjectMembership(project, 5);
assert.equal(exitedMembership?.status, 'exited');
assert.ok(exitedMembership?.exitedAt, '必须记录具体的退出时间戳');
});
await t.test('5. 超级管理员 (ADMIN) 享有全量通配可见与管理兜底特权', () => {
const project: ProjectInfo = {
id: 9004,
title: '高度机密自研微创系统',
deviceType: '核心技术研发',
creatorId: 1,
creatorName: '张工',
createdAt: '2026-09-11 10:35',
members: [
{
userId: 1,
userName: '张工',
role: 'creator',
status: 'active',
joinedAt: '2026-09-11 10:35',
},
],
};
const adminSessions: ProjectSession[] = [
{
sessionId: 'sess-p9004-u4',
userId: 4, // 赵管理员
projectId: 9004,
title: project.title,
deviceType: project.deviceType,
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 0,
updatedAt: '刚刚',
},
];
// 管理员即使未在特定 members 中,设置 isAdmin=true 仍可全局可见
const adminVisible = filterProjectSessionsForUser(adminSessions, [project], 4, true);
assert.equal(adminVisible.length, 1, '系统管理员必须具备全量项目监管与防死锁兜底可见特权');
});
});
+26
View File
@@ -4,6 +4,31 @@ import { MessageContentKind, MessageExportCapability } from './replyExport';
export type StageKey = 'V0.1' | 'V0.5' | 'V1.0';
export type ProjectMemberStatus = 'active' | 'exited';
export type ProjectMemberRole = 'creator' | 'author' | 'reviewer' | 'approver' | 'viewer';
export interface ProjectMember {
userId: number;
userName: string;
department?: string;
position?: string;
role: ProjectMemberRole;
status: ProjectMemberStatus;
joinedAt: string;
exitedAt?: string;
}
export interface ProjectInfo {
id: number;
title: string;
deviceType: string;
description?: string;
creatorId: number;
creatorName: string;
createdAt: string;
members: ProjectMember[];
}
export interface ProjectSession {
sessionId: string;
userId?: number;
@@ -14,6 +39,7 @@ export interface ProjectSession {
stageLabel: string;
messageCount: number;
updatedAt: string;
isExited?: boolean; // 标识当前用户在所属项目中是否已退出
}
export interface PhaseEvent {