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.
564 lines
21 KiB
TypeScript
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>
|
|
);
|
|
};
|