feat(project): implement modal-based project creation, creator-led member assignment, and exited-member read-only retention
This commit is contained in:
+317
-57
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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('管理员')}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, '系统管理员必须具备全量项目监管与防死锁兜底可见特权');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user