Files
Agent-System/web/src/components/project/ProjectMembersModal.tsx
T
weichen 47b8179824 feat(web): wire project members CRUD to REST API
Add projectMembers client and role adapter; sync members from server on login and modal open; persist add/update/remove via /api/v1/projects/{id}/members.
2026-09-13 02:50:22 +00:00

564 lines
21 KiB
TypeScript

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[];
canManage?: boolean;
membersError?: string | null;
onAddMember: (member: Omit<ProjectMember, 'joinedAt' | 'status'>) => void | Promise<void>;
onUpdateMemberRole: (userId: number, role: ProjectMemberRole) => void | Promise<void>;
onRemoveMember: (userId: number) => void | Promise<void>;
onRestoreMember: (userId: number) => void | Promise<void>;
}
export const ProjectMembersModal: React.FC<ProjectMembersModalProps> = ({
isOpen,
onClose,
project,
currentUser,
availableUsers,
canManage: canManageProp,
membersError,
onAddMember,
onUpdateMemberRole,
onRemoveMember,
onRestoreMember,
}) => {
const [showAddForm, setShowAddForm] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | ''>('');
const [selectedRole, setSelectedRole] = useState<ProjectMemberRole>('member');
const [pending, setPending] = useState(false);
if (!isOpen || !project) return null;
const isCreator = currentUser.id === project.creatorId;
const isAdmin = currentUser.roleId === 'role-admin';
const canManage = canManageProp ?? (isCreator || isAdmin);
// 筛选尚未加入该项目的人员
const existingMemberIds = new Set(project.members.map((m) => m.userId));
const unjoinedUsers = availableUsers.filter((u) => !existingMemberIds.has(u.id));
const handleAddSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedUserId || pending) return;
const targetUser = availableUsers.find((u) => u.id === selectedUserId);
if (!targetUser) return;
setPending(true);
try {
await onAddMember({
userId: targetUser.id,
userName: targetUser.name,
department: targetUser.department,
position: targetUser.position,
role: selectedRole,
});
setSelectedUserId('');
setSelectedRole('member');
setShowAddForm(false);
} finally {
setPending(false);
}
};
const normalizeEditableRole = (role: ProjectMemberRole): ProjectMemberRole => {
if (role === 'viewer') return 'viewer';
if (role === 'member' || role === 'owner') return role;
return 'member';
};
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' };
case 'owner':
return { label: '项目负责人 (Owner)', bg: 'rgba(79, 107, 255, 0.12)', color: '#4f6bff' };
case 'member':
return { label: '项目成员 (Member)', bg: 'rgba(14, 165, 233, 0.12)', color: '#0284c7' };
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="member">项目成员 (Member)</option>
<option value="viewer">查阅者 (Viewer)</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="submit"
disabled={!selectedUserId || pending}
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>
)}
{membersError ? (
<div
style={{
marginBottom: '12px',
padding: '10px 12px',
borderRadius: '8px',
background: 'rgba(239, 68, 68, 0.08)',
color: '#dc2626',
fontSize: '12px',
}}
>
{membersError}
</div>
) : null}
{/* 成员列表 */}
<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={normalizeEditableRole(member.role)}
onChange={(e) => {
void 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="member">项目成员</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>
);
};