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:
weichen
2026-09-13 02:50:22 +00:00
committed by weichen
parent 844bfd2803
commit 47b8179824
6 changed files with 382 additions and 122 deletions
+130 -94
View File
@@ -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}
+88
View File
@@ -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>
+39
View File
@@ -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');
});
+57
View File
@@ -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'];
+9 -1
View File
@@ -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;