refactor(web): persist project members from one hook
Refresh the roster and workflow documents only after the member write succeeds, so a failed add does not invent a local session. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,7 +46,7 @@ updated: 2026-09-19
|
||||
| FE 缓存 | done(版本字段)。窗口缓存写入 `{schema,data}`,无 schema 的旧载荷丢弃。`window cache: 无 schema` |
|
||||
| FE lucide | 关闭理由:`package-lock.json` 锁定 `lucide-react@1.42.0`,来源 `registry.npmjs.org`,带 integrity。`^1.16.0` 是合法的 1.x 范围,不是来源不明的包 |
|
||||
| M7 | done(出站意图 + 同键重放)。`document_content_projections` 与账本同一事务写入;投影成功才销账。`TestDocRevision_PatchOrder_RetryProjectsWithoutSecondLedgerCommit`。账本库与正文库仍不是一个 SQL 事务,进程退出后靠未投影行重放,不靠客户端记忆 |
|
||||
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存、登录同步、文档补水、服务端文档同步、生命周期、发送消息、块编辑、对话流/任务流重试、卡片动作和立项已分别抽出。工作台壳与成员管理仍在 `App.tsx` |
|
||||
| FE App.tsx | 部分。登录壳不再静态引用重模块。本地缓存、登录同步、文档补水、服务端文档同步、生命周期、发送消息、块编辑、对话流/任务流重试、卡片动作、立项和成员写操作已分别抽出。工作台壳仍在 `App.tsx` |
|
||||
| H1-del | 关闭理由:`allowLegacyTestPrincipal` 仍被 `AllowInMemory && ContextDbHost==""` 的 dev/test 内存测试使用。删除会把这批测试变成未登录 401。保留告警,删除另开、先改测试夹具 |
|
||||
|
||||
仍开放:rdev P3 三样本真 PI、首次加载 U1/U6/U7、dev 容器重建与治理库 PG。公式引擎已离开入口包(`index-j0BIrjIR.js` 737.38 kB,br 170,451 B,包内无 katex),U3 仍缺浏览器瀑布,不关。P4 键名空间化已在本分支落地:新清单用带前缀的任务键,旧键与旧 doc_id 仍可读,`TestPublishedTaskKeysAreNamespacedAndDisjoint`。意图短语仍输出旧键,执行路径按别名接上。P2 规划界面已折叠长阶段、可批量纳入人工审批,并在选中 rdev 时提示 CDO 前置。U4 登录扇出收口仍有效,浏览器请求次数证据还没有。2026-09-19 在 `fix/audit-remainder-closeout` 跑过 `cd web && npm test && npm run build`:node:test 414 pass / 0 fail,vitest 32 pass;入口 `index-BFf6n8IA.js` 1,005.01 kB,br 235,285 B。CURRENT 文首 schema 52 与三套容器一致,00053 要等重建后才升。H2 批注/审计的项目成员检查在 Postgres 上走 `RequireProjectAccess`;内存授权器在无库时把任意正用户 id 视为成员,这是 `TestAuthorizeDocRevision_ReviewerAndNonMemberCannotWrite` 的隔离前提,不能改成默认拒绝。H2 的 HTTP 负例因此要在 `TEST_DATABASE_URL` 上补,本机未设置该变量。M7 的投影意图已随账本提交(goose `00053`);容器 schema 仍是 52,要等占槽重建后才升到 53。
|
||||
|
||||
+16
-115
@@ -22,16 +22,7 @@ import {
|
||||
revisionToolbarMountMode,
|
||||
} from './features/documentRevision/revisionToolbarMount';
|
||||
import { fetchProjectLineage, getProject } from './api/projectWorkflow';
|
||||
import {
|
||||
addProjectMember,
|
||||
listProjectMembers,
|
||||
removeProjectMember,
|
||||
updateProjectMember,
|
||||
} from './api/projectMembers';
|
||||
import {
|
||||
buildManagedMemberUpsertPayload,
|
||||
} from './lib/createProjectMembersPersist';
|
||||
import { afterProjectMemberPersisted } from './lib/afterProjectMemberPersisted';
|
||||
import { listProjectMembers } from './api/projectMembers';
|
||||
import {
|
||||
type CreateProjectSubmitPhase,
|
||||
} from './lib/createProjectSubmitGuard';
|
||||
@@ -87,7 +78,6 @@ import {
|
||||
DeliverableCategory,
|
||||
ProjectInfo,
|
||||
ProjectMember,
|
||||
ProjectMemberRole,
|
||||
} from './types/workbench';
|
||||
import { UserRole, UserProfile, DocPermissionRole } from './types/docgov';
|
||||
import type { DocLifecycleState } from './types/docgov';
|
||||
@@ -123,7 +113,6 @@ import {
|
||||
loadProjects,
|
||||
filterProjectSessionsForUser,
|
||||
getProjectMembership,
|
||||
buildPrivateSessionId,
|
||||
} from './data/storage';
|
||||
import { useChatWindow } from './hooks/useChatWindow';
|
||||
import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync';
|
||||
@@ -131,6 +120,7 @@ import { useBlockEditing } from './hooks/useBlockEditing';
|
||||
import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration';
|
||||
import { useDocumentLifecycleActions } from './hooks/useDocumentLifecycleActions';
|
||||
import { useCreateProjectConfirm } from './hooks/useCreateProjectConfirm';
|
||||
import { useProjectMemberActions } from './hooks/useProjectMemberActions';
|
||||
import { useCardActions } from './hooks/useCardActions';
|
||||
import { useTaskRunAttempts } from './hooks/useTaskRunAttempts';
|
||||
import { useSendMessage } from './hooks/useSendMessage';
|
||||
@@ -1642,6 +1632,20 @@ export default function App() {
|
||||
setActiveCategory,
|
||||
});
|
||||
|
||||
const {
|
||||
handleAddProjectMember,
|
||||
handleUpdateProjectMemberRole,
|
||||
handleRemoveProjectMember,
|
||||
handleRestoreProjectMember,
|
||||
} = useProjectMemberActions({
|
||||
currentProject,
|
||||
syncProjectMembersFromServer,
|
||||
setWorkflowDocsEpoch,
|
||||
setSessions,
|
||||
setMessages,
|
||||
setProjectMembersError,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<LoginView
|
||||
@@ -1659,109 +1663,6 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
// 添加成员(持久化至服务端 project_members)
|
||||
const handleAddProjectMember = async (newMember: Omit<ProjectMember, 'joinedAt' | 'status'>) => {
|
||||
if (!currentProject) return;
|
||||
try {
|
||||
await addProjectMember(
|
||||
currentProject.id,
|
||||
buildManagedMemberUpsertPayload(
|
||||
{ userId: newMember.userId, role: newMember.role },
|
||||
currentProject.creatorId,
|
||||
),
|
||||
);
|
||||
await afterProjectMemberPersisted({
|
||||
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
|
||||
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
|
||||
});
|
||||
|
||||
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),
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '添加成员失败');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProjectMemberRole = async (userId: number, newRole: ProjectMemberRole) => {
|
||||
if (!currentProject) return;
|
||||
try {
|
||||
await updateProjectMember(
|
||||
currentProject.id,
|
||||
buildManagedMemberUpsertPayload({ userId, role: newRole }, currentProject.creatorId),
|
||||
);
|
||||
await afterProjectMemberPersisted({
|
||||
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
|
||||
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
|
||||
});
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '更新成员角色失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveProjectMember = async (userId: number) => {
|
||||
if (!currentProject) return;
|
||||
try {
|
||||
await removeProjectMember(currentProject.id, userId);
|
||||
await afterProjectMemberPersisted({
|
||||
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
|
||||
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
|
||||
});
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '移出成员失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreProjectMember = async (userId: number) => {
|
||||
if (!currentProject) return;
|
||||
try {
|
||||
const member = currentProject.members.find((m) => m.userId === userId);
|
||||
await updateProjectMember(
|
||||
currentProject.id,
|
||||
buildManagedMemberUpsertPayload(
|
||||
{ userId, role: member?.role ?? 'member', status: 'active' },
|
||||
currentProject.creatorId,
|
||||
),
|
||||
);
|
||||
await afterProjectMemberPersisted({
|
||||
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
|
||||
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
|
||||
});
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '恢复成员失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 全屏系统管理台:与工作台并列的顶层视图,不经过 AppShell
|
||||
if (view === 'admin') {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
|
||||
test('project member writes live in one hook before the login return', () => {
|
||||
const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8');
|
||||
const hook = readFileSync(new URL('./useProjectMemberActions.ts', import.meta.url), 'utf8');
|
||||
assert.equal((app.match(/useProjectMemberActions\(/g) || []).length, 1);
|
||||
assert.equal(app.indexOf('useProjectMemberActions(') < app.indexOf('if (!session)'), true);
|
||||
assert.equal(app.includes('afterProjectMemberPersisted'), false);
|
||||
assert.equal(hook.includes('afterProjectMemberPersisted'), true);
|
||||
assert.equal(hook.includes('removeProjectMember'), true);
|
||||
assert.equal(hook.includes("status: 'active'"), true);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import {
|
||||
addProjectMember,
|
||||
removeProjectMember,
|
||||
updateProjectMember,
|
||||
} from '../api/projectMembers';
|
||||
import { buildPrivateSessionId } from '../data/storage';
|
||||
import { afterProjectMemberPersisted } from '../lib/afterProjectMemberPersisted';
|
||||
import { buildManagedMemberUpsertPayload } from '../lib/createProjectMembersPersist';
|
||||
import type {
|
||||
ChatMessage,
|
||||
ProjectInfo,
|
||||
ProjectMember,
|
||||
ProjectMemberRole,
|
||||
ProjectSession,
|
||||
} from '../types/workbench';
|
||||
|
||||
export interface ProjectMemberActionsInput {
|
||||
currentProject: ProjectInfo | undefined;
|
||||
syncProjectMembersFromServer: (projectId: number, creatorId: number) => Promise<unknown>;
|
||||
setWorkflowDocsEpoch: Dispatch<SetStateAction<number>>;
|
||||
setSessions: Dispatch<SetStateAction<ProjectSession[]>>;
|
||||
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
||||
setProjectMembersError: Dispatch<SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
export interface ProjectMemberActions {
|
||||
handleAddProjectMember: (newMember: Omit<ProjectMember, 'joinedAt' | 'status'>) => Promise<void>;
|
||||
handleUpdateProjectMemberRole: (userId: number, newRole: ProjectMemberRole) => Promise<void>;
|
||||
handleRemoveProjectMember: (userId: number) => Promise<void>;
|
||||
handleRestoreProjectMember: (userId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useProjectMemberActions(input: ProjectMemberActionsInput): ProjectMemberActions {
|
||||
const {
|
||||
currentProject,
|
||||
syncProjectMembersFromServer,
|
||||
setWorkflowDocsEpoch,
|
||||
setSessions,
|
||||
setMessages,
|
||||
setProjectMembersError,
|
||||
} = input;
|
||||
|
||||
const handleAddProjectMember = async (newMember: Omit<ProjectMember, 'joinedAt' | 'status'>) => {
|
||||
if (!currentProject) return;
|
||||
try {
|
||||
await addProjectMember(
|
||||
currentProject.id,
|
||||
buildManagedMemberUpsertPayload(
|
||||
{ userId: newMember.userId, role: newMember.role },
|
||||
currentProject.creatorId,
|
||||
),
|
||||
);
|
||||
await afterProjectMemberPersisted({
|
||||
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
|
||||
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
|
||||
});
|
||||
|
||||
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),
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '添加成员失败');
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProjectMemberRole = async (userId: number, newRole: ProjectMemberRole) => {
|
||||
if (!currentProject) return;
|
||||
try {
|
||||
await updateProjectMember(
|
||||
currentProject.id,
|
||||
buildManagedMemberUpsertPayload({ userId, role: newRole }, currentProject.creatorId),
|
||||
);
|
||||
await afterProjectMemberPersisted({
|
||||
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
|
||||
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
|
||||
});
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '更新成员角色失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveProjectMember = async (userId: number) => {
|
||||
if (!currentProject) return;
|
||||
try {
|
||||
await removeProjectMember(currentProject.id, userId);
|
||||
await afterProjectMemberPersisted({
|
||||
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
|
||||
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
|
||||
});
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '移出成员失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreProjectMember = async (userId: number) => {
|
||||
if (!currentProject) return;
|
||||
try {
|
||||
const member = currentProject.members.find((m) => m.userId === userId);
|
||||
await updateProjectMember(
|
||||
currentProject.id,
|
||||
buildManagedMemberUpsertPayload(
|
||||
{ userId, role: member?.role ?? 'member', status: 'active' },
|
||||
currentProject.creatorId,
|
||||
),
|
||||
);
|
||||
await afterProjectMemberPersisted({
|
||||
syncMembers: () => syncProjectMembersFromServer(currentProject.id, currentProject.creatorId),
|
||||
refreshWorkflowDocs: () => setWorkflowDocsEpoch((n) => n + 1),
|
||||
});
|
||||
} catch (err) {
|
||||
setProjectMembersError(err instanceof Error ? err.message : '恢复成员失败');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleAddProjectMember,
|
||||
handleUpdateProjectMemberRole,
|
||||
handleRemoveProjectMember,
|
||||
handleRestoreProjectMember,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user