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.
This commit is contained in:
+130
-94
@@ -20,6 +20,16 @@ import {
|
||||
import { fetchChatSessions, upsertChatSession } from './api/chatSessions';
|
||||
import { listProjectWorkflowDocuments } from './api/projectDocuments';
|
||||
import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow';
|
||||
import {
|
||||
addProjectMember,
|
||||
listProjectMembers,
|
||||
removeProjectMember,
|
||||
updateProjectMember,
|
||||
} from './api/projectMembers';
|
||||
import {
|
||||
mapApiMembersToProjectMembers,
|
||||
toApiProjectRole,
|
||||
} from './lib/projectMemberAdapter';
|
||||
import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync';
|
||||
import {
|
||||
buildControlledLoadErrorSkeleton,
|
||||
@@ -586,6 +596,7 @@ export default function App() {
|
||||
const [projectSyncNotice, setProjectSyncNotice] = useState<string | null>(null);
|
||||
const [sessionSyncNotice, setSessionSyncNotice] = useState<string | null>(null);
|
||||
const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false);
|
||||
const [projectMembersError, setProjectMembersError] = useState<string | null>(null);
|
||||
|
||||
const isAdmin = currentUser.roleId === 'role-admin';
|
||||
|
||||
@@ -604,6 +615,29 @@ export default function App() {
|
||||
return getProjectMembership(currentProject, currentUser.id);
|
||||
}, [currentProject, currentUser.id]);
|
||||
|
||||
const applyMembersToProject = useCallback((projectId: number, members: ProjectMember[]) => {
|
||||
setProjects((prev) => prev.map((p) => (p.id === projectId ? { ...p, members } : p)));
|
||||
}, []);
|
||||
|
||||
const syncProjectMembersFromServer = useCallback(
|
||||
async (projectId: number, creatorId: number) => {
|
||||
try {
|
||||
const records = await listProjectMembers(projectId);
|
||||
const members = mapApiMembersToProjectMembers(records, creatorId);
|
||||
if (members.length > 0) {
|
||||
applyMembersToProject(projectId, members);
|
||||
}
|
||||
setProjectMembersError(null);
|
||||
return members;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '项目成员同步失败';
|
||||
setProjectMembersError(msg);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[applyMembersToProject]
|
||||
);
|
||||
|
||||
const isCurrentProjectExited = currentMembership?.status === 'exited';
|
||||
|
||||
// 当前用户专属的项目会话列表 (基于项目成员身份与退出留痕过滤)
|
||||
@@ -692,6 +726,11 @@ export default function App() {
|
||||
);
|
||||
return mergeProjectLists(prev, mapped);
|
||||
});
|
||||
await Promise.all(
|
||||
serverProjects.map((project) =>
|
||||
syncProjectMembersFromServer(project.id, project.creator_user_id)
|
||||
)
|
||||
);
|
||||
setProjectSyncNotice(null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
@@ -703,7 +742,12 @@ export default function App() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [session, allUsers]);
|
||||
}, [session, allUsers, syncProjectMembersFromServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectMembersModalOpen || !currentProject || !session) return;
|
||||
void syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
|
||||
}, [projectMembersModalOpen, currentProject, session, syncProjectMembersFromServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || !currentUser.id) {
|
||||
@@ -762,6 +806,14 @@ export default function App() {
|
||||
);
|
||||
}, [currentUser, roles, capabilities]);
|
||||
|
||||
const canManageProjectMembers = useMemo(() => {
|
||||
if (!currentProject) return false;
|
||||
if (hasPermission(currentUser, roles, 'perm:manage', capabilities)) return true;
|
||||
if (currentUser.id === currentProject.creatorId) return true;
|
||||
const membership = currentProject.members.find((m) => m.userId === currentUser.id);
|
||||
return membership?.role === 'creator' || membership?.role === 'owner';
|
||||
}, [currentProject, currentUser, roles, capabilities]);
|
||||
|
||||
// 项目多工作流激活状态与文档血缘 (支持前端切换与状态联动)
|
||||
const [projectWorkflowsState, setProjectWorkflowsState] = useState<
|
||||
Record<number, { activeKey: string; version: number; creatorId: number }>
|
||||
@@ -3177,112 +3229,94 @@ export default function App() {
|
||||
setCreateProjectError(null);
|
||||
};
|
||||
|
||||
// 添加成员
|
||||
const handleAddProjectMember = (newMember: Omit<ProjectMember, 'joinedAt' | 'status'>) => {
|
||||
// 添加成员(持久化至服务端 project_members)
|
||||
const handleAddProjectMember = async (newMember: Omit<ProjectMember, 'joinedAt' | 'status'>) => {
|
||||
if (!currentProject) return;
|
||||
const nowStr = new Date().toLocaleString();
|
||||
const fullMember: ProjectMember = {
|
||||
...newMember,
|
||||
status: 'active',
|
||||
joinedAt: nowStr,
|
||||
};
|
||||
try {
|
||||
await addProjectMember(currentProject.id, {
|
||||
user_id: newMember.userId,
|
||||
project_role: toApiProjectRole(newMember.role, newMember.userId, currentProject.creatorId),
|
||||
});
|
||||
await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
|
||||
|
||||
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,
|
||||
];
|
||||
});
|
||||
|
||||
// 为新成员生成独立会话
|
||||
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: '刚刚',
|
||||
},
|
||||
setMessages((prev) => [
|
||||
...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),
|
||||
},
|
||||
]);
|
||||
{
|
||||
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),
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '添加成员失败');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// 调整成员职责
|
||||
const handleUpdateProjectMemberRole = (userId: number, newRole: ProjectMemberRole) => {
|
||||
const handleUpdateProjectMemberRole = async (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;
|
||||
})
|
||||
);
|
||||
try {
|
||||
await updateProjectMember(currentProject.id, {
|
||||
user_id: userId,
|
||||
project_role: toApiProjectRole(newRole, userId, currentProject.creatorId),
|
||||
});
|
||||
await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '更新成员角色失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 移出/退出项目 (标记状态为 exited,保留聊天记录供审计留痕)
|
||||
const handleRemoveProjectMember = (userId: number) => {
|
||||
const handleRemoveProjectMember = async (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;
|
||||
})
|
||||
);
|
||||
try {
|
||||
await removeProjectMember(currentProject.id, userId);
|
||||
await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '移出成员失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 恢复重新加入项目
|
||||
const handleRestoreProjectMember = (userId: number) => {
|
||||
const handleRestoreProjectMember = async (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;
|
||||
})
|
||||
);
|
||||
try {
|
||||
const member = currentProject.members.find((m) => m.userId === userId);
|
||||
await updateProjectMember(currentProject.id, {
|
||||
user_id: userId,
|
||||
project_role: toApiProjectRole(
|
||||
member?.role ?? 'member',
|
||||
userId,
|
||||
currentProject.creatorId
|
||||
),
|
||||
status: 'active',
|
||||
});
|
||||
await syncProjectMembersFromServer(currentProject.id, currentProject.creatorId);
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '恢复成员失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -3776,6 +3810,8 @@ export default function App() {
|
||||
project={currentProject}
|
||||
currentUser={currentUser}
|
||||
availableUsers={allUsers}
|
||||
canManage={canManageProjectMembers}
|
||||
membersError={projectMembersError}
|
||||
onAddMember={handleAddProjectMember}
|
||||
onUpdateMemberRole={handleUpdateProjectMemberRole}
|
||||
onRemoveMember={handleRemoveProjectMember}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
|
||||
export type ApiProjectMemberRole = 'owner' | 'member' | 'viewer';
|
||||
export type ApiProjectMemberStatus = 'active' | 'exited';
|
||||
|
||||
export interface ApiProjectMemberRecord {
|
||||
id?: number;
|
||||
project_id?: number;
|
||||
user_id: number;
|
||||
username?: string;
|
||||
name?: string;
|
||||
project_role: ApiProjectMemberRole;
|
||||
status: ApiProjectMemberStatus;
|
||||
joined_at?: string;
|
||||
}
|
||||
|
||||
export interface ListProjectMembersResponse {
|
||||
members: ApiProjectMemberRecord[];
|
||||
}
|
||||
|
||||
export interface UpsertProjectMemberPayload {
|
||||
user_id: number;
|
||||
project_role: ApiProjectMemberRole;
|
||||
status?: ApiProjectMemberStatus;
|
||||
}
|
||||
|
||||
async function parseError(res: Response, fallback: string): Promise<string> {
|
||||
const text = await res.text();
|
||||
if (!text) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { message?: string; error?: string };
|
||||
return parsed.message || parsed.error || text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listProjectMembers(projectId: number): Promise<ApiProjectMemberRecord[]> {
|
||||
const res = await fetch(`/api/v1/projects/${projectId}/members`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseError(res, `获取项目成员失败: ${res.status}`));
|
||||
}
|
||||
const data: ListProjectMembersResponse = await res.json();
|
||||
return data.members ?? [];
|
||||
}
|
||||
|
||||
export async function addProjectMember(
|
||||
projectId: number,
|
||||
payload: UpsertProjectMemberPayload
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/v1/projects/${projectId}/members`, {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseError(res, `添加项目成员失败: ${res.status}`));
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProjectMember(
|
||||
projectId: number,
|
||||
payload: UpsertProjectMemberPayload
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/v1/projects/${projectId}/members`, {
|
||||
method: 'PUT',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseError(res, `更新项目成员失败: ${res.status}`));
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeProjectMember(projectId: number, userId: number): Promise<void> {
|
||||
const res = await fetch(
|
||||
`/api/v1/projects/${projectId}/members?user_id=${encodeURIComponent(String(userId))}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: getBearerAuthHeaders(),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseError(res, `移出项目成员失败: ${res.status}`));
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,12 @@ interface ProjectMembersModalProps {
|
||||
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; // 重新激活
|
||||
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> = ({
|
||||
@@ -21,6 +23,8 @@ export const ProjectMembersModal: React.FC<ProjectMembersModalProps> = ({
|
||||
project,
|
||||
currentUser,
|
||||
availableUsers,
|
||||
canManage: canManageProp,
|
||||
membersError,
|
||||
onAddMember,
|
||||
onUpdateMemberRole,
|
||||
onRemoveMember,
|
||||
@@ -28,35 +32,46 @@ export const ProjectMembersModal: React.FC<ProjectMembersModalProps> = ({
|
||||
}) => {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | ''>('');
|
||||
const [selectedRole, setSelectedRole] = useState<ProjectMemberRole>('viewer');
|
||||
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 = isCreator || isAdmin;
|
||||
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 = (e: React.FormEvent) => {
|
||||
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedUserId) return;
|
||||
if (!selectedUserId || pending) 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,
|
||||
});
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
setSelectedUserId('');
|
||||
setSelectedRole('viewer');
|
||||
setShowAddForm(false);
|
||||
const normalizeEditableRole = (role: ProjectMemberRole): ProjectMemberRole => {
|
||||
if (role === 'viewer') return 'viewer';
|
||||
if (role === 'member' || role === 'owner') return role;
|
||||
return 'member';
|
||||
};
|
||||
|
||||
const getRoleBadge = (role: ProjectMemberRole) => {
|
||||
@@ -69,6 +84,10 @@ export const ProjectMembersModal: React.FC<ProjectMembersModalProps> = ({
|
||||
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' };
|
||||
}
|
||||
@@ -257,9 +276,7 @@ export const ProjectMembersModal: React.FC<ProjectMembersModalProps> = ({
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="author">编制人 (Author)</option>
|
||||
<option value="reviewer">审核人 (Reviewer)</option>
|
||||
<option value="approver">批准人 (Approver)</option>
|
||||
<option value="member">项目成员 (Member)</option>
|
||||
<option value="viewer">查阅者 (Viewer)</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -267,7 +284,7 @@ export const ProjectMembersModal: React.FC<ProjectMembersModalProps> = ({
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!selectedUserId}
|
||||
disabled={!selectedUserId || pending}
|
||||
style={{
|
||||
padding: '0 14px',
|
||||
height: '34px',
|
||||
@@ -303,6 +320,21 @@ export const ProjectMembersModal: React.FC<ProjectMembersModalProps> = ({
|
||||
</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={{
|
||||
@@ -400,8 +432,10 @@ export const ProjectMembersModal: React.FC<ProjectMembersModalProps> = ({
|
||||
<>
|
||||
{/* 修改职责 */}
|
||||
<select
|
||||
value={member.role}
|
||||
onChange={(e) => onUpdateMemberRole(member.userId, e.target.value as ProjectMemberRole)}
|
||||
value={normalizeEditableRole(member.role)}
|
||||
onChange={(e) => {
|
||||
void onUpdateMemberRole(member.userId, e.target.value as ProjectMemberRole);
|
||||
}}
|
||||
style={{
|
||||
height: '28px',
|
||||
padding: '0 6px',
|
||||
@@ -413,9 +447,7 @@ export const ProjectMembersModal: React.FC<ProjectMembersModalProps> = ({
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<option value="author">编制人</option>
|
||||
<option value="reviewer">审核人</option>
|
||||
<option value="approver">批准人</option>
|
||||
<option value="member">项目成员</option>
|
||||
<option value="viewer">查阅者</option>
|
||||
</select>
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
fromApiProjectRole,
|
||||
mapApiMemberToProjectMember,
|
||||
toApiProjectRole,
|
||||
} from './projectMemberAdapter';
|
||||
|
||||
test('toApiProjectRole maps doc roles to member and creator to owner', () => {
|
||||
assert.equal(toApiProjectRole('creator', 1, 1), 'owner');
|
||||
assert.equal(toApiProjectRole('author', 2, 1), 'member');
|
||||
assert.equal(toApiProjectRole('reviewer', 2, 1), 'member');
|
||||
assert.equal(toApiProjectRole('viewer', 2, 1), 'viewer');
|
||||
});
|
||||
|
||||
test('fromApiProjectRole maps owner to creator for project creator', () => {
|
||||
assert.equal(fromApiProjectRole('owner', 1, 1), 'creator');
|
||||
assert.equal(fromApiProjectRole('owner', 2, 1), 'owner');
|
||||
assert.equal(fromApiProjectRole('member', 3, 1), 'member');
|
||||
assert.equal(fromApiProjectRole('viewer', 4, 1), 'viewer');
|
||||
});
|
||||
|
||||
test('mapApiMemberToProjectMember builds UI record', () => {
|
||||
const mapped = mapApiMemberToProjectMember(
|
||||
{
|
||||
user_id: 2,
|
||||
name: '张工',
|
||||
username: 'zhang',
|
||||
project_role: 'member',
|
||||
status: 'active',
|
||||
joined_at: '2026-09-12 10:00:00',
|
||||
},
|
||||
1
|
||||
);
|
||||
assert.equal(mapped.userId, 2);
|
||||
assert.equal(mapped.userName, '张工');
|
||||
assert.equal(mapped.role, 'member');
|
||||
assert.equal(mapped.status, 'active');
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ApiProjectMemberRecord, ApiProjectMemberRole } from '../api/projectMembers';
|
||||
import { ProjectMember, ProjectMemberRole } from '../types/workbench';
|
||||
|
||||
/** UI 角色 → 服务端 project_role(禁止 author/reviewer/approver)。 */
|
||||
export function toApiProjectRole(
|
||||
role: ProjectMemberRole,
|
||||
userId: number,
|
||||
creatorId: number
|
||||
): ApiProjectMemberRole {
|
||||
if (role === 'creator' || role === 'owner' || (userId === creatorId && role !== 'viewer')) {
|
||||
return 'owner';
|
||||
}
|
||||
if (role === 'viewer') {
|
||||
return 'viewer';
|
||||
}
|
||||
return 'member';
|
||||
}
|
||||
|
||||
/** 服务端 project_role → UI 角色。 */
|
||||
export function fromApiProjectRole(
|
||||
apiRole: string,
|
||||
userId: number,
|
||||
creatorId: number
|
||||
): ProjectMemberRole {
|
||||
const normalized = apiRole.trim().toLowerCase();
|
||||
if (normalized === 'owner') {
|
||||
return userId === creatorId ? 'creator' : 'owner';
|
||||
}
|
||||
if (normalized === 'viewer') {
|
||||
return 'viewer';
|
||||
}
|
||||
return 'member';
|
||||
}
|
||||
|
||||
export function mapApiMemberToProjectMember(
|
||||
rec: ApiProjectMemberRecord,
|
||||
creatorId: number
|
||||
): ProjectMember {
|
||||
return {
|
||||
userId: rec.user_id,
|
||||
userName: rec.name?.trim() || rec.username?.trim() || `用户 #${rec.user_id}`,
|
||||
role: fromApiProjectRole(rec.project_role, rec.user_id, creatorId),
|
||||
status: rec.status === 'exited' ? 'exited' : 'active',
|
||||
joinedAt: rec.joined_at || new Date().toLocaleString(),
|
||||
exitedAt: rec.status === 'exited' ? rec.joined_at : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapApiMembersToProjectMembers(
|
||||
records: ApiProjectMemberRecord[],
|
||||
creatorId: number
|
||||
): ProjectMember[] {
|
||||
return records.map((rec) => mapApiMemberToProjectMember(rec, creatorId));
|
||||
}
|
||||
|
||||
/** 项目成员管理可选角色(与后端校验一致)。 */
|
||||
export const ASSIGNABLE_PROJECT_MEMBER_ROLES: ProjectMemberRole[] = ['member', 'viewer'];
|
||||
@@ -5,7 +5,15 @@ 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';
|
||||
/** creator/owner/member/viewer 为项目成员 API 对齐角色;author/reviewer/approver 为历史演示数据保留。 */
|
||||
export type ProjectMemberRole =
|
||||
| 'creator'
|
||||
| 'owner'
|
||||
| 'member'
|
||||
| 'author'
|
||||
| 'reviewer'
|
||||
| 'approver'
|
||||
| 'viewer';
|
||||
|
||||
export interface ProjectMember {
|
||||
userId: number;
|
||||
|
||||
Reference in New Issue
Block a user