refactor(web): create projects from one hook

Remember the new project id as soon as create succeeds so a later member failure retries members instead of creating a second project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-19 12:09:28 +00:00
co-authored by Cursor
parent 9f39fbaaf3
commit a29e98096a
4 changed files with 270 additions and 179 deletions
@@ -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。
+21 -178
View File
@@ -16,13 +16,12 @@ import {
unshareFreeformDoc,
deleteFreeformDoc,
} from './api/freeformDoc';
import { upsertChatSession } from './api/chatSessions';
import type { DiffResult, OverlayMode } from './features/documentRevision/types';
import {
documentRevisionInactiveHint,
revisionToolbarMountMode,
} from './features/documentRevision/revisionToolbarMount';
import { createProject, fetchProjectLineage, getProject } from './api/projectWorkflow';
import { fetchProjectLineage, getProject } from './api/projectWorkflow';
import {
addProjectMember,
listProjectMembers,
@@ -31,14 +30,9 @@ import {
} from './api/projectMembers';
import {
buildManagedMemberUpsertPayload,
ensureCreatedProjectWithMembers,
} from './lib/createProjectMembersPersist';
import { afterProjectMemberPersisted } from './lib/afterProjectMemberPersisted';
import {
beginCreateProjectSubmit,
endCreateProjectSubmit,
resolveCreateProjectSubmitPhase,
shouldCloneMedicalTemplates,
type CreateProjectSubmitPhase,
} from './lib/createProjectSubmitGuard';
import { mapApiMembersToProjectMembers } from './lib/projectMemberAdapter';
@@ -74,7 +68,7 @@ import { UserProjectRightsModal } from './components/user/UserProjectRightsModal
import { UserSettingsModal } from './components/user/UserSettingsModal';
import { OnDemand } from './components/lazy/OnDemand';
import { DocVisibilityModal } from './components/doc/DocVisibilityModal';
import { CreateProjectModal, type CreateProjectConfirmData } from './components/project/CreateProjectModal';
import { CreateProjectModal } from './components/project/CreateProjectModal';
import { ProjectMembersModal } from './components/project/ProjectMembersModal';
import { ProjectSettingsModal } from './components/project/ProjectSettingsModal';
import { fetchProjectSettings } from './api/projectSettings';
@@ -116,7 +110,6 @@ import {
MOCK_USERS,
} from '@demo-seeds';
import {
createProjectTemplateDeliverables,
getTemplateDefinitionByTaskKey,
buildTemplateASTForProject,
} from './engine/templateFactory';
@@ -137,6 +130,7 @@ import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspace
import { useBlockEditing } from './hooks/useBlockEditing';
import { useControlledDocumentHydration } from './hooks/useControlledDocumentHydration';
import { useDocumentLifecycleActions } from './hooks/useDocumentLifecycleActions';
import { useCreateProjectConfirm } from './hooks/useCreateProjectConfirm';
import { useCardActions } from './hooks/useCardActions';
import { useTaskRunAttempts } from './hooks/useTaskRunAttempts';
import { useSendMessage } from './hooks/useSendMessage';
@@ -1630,6 +1624,24 @@ export default function App() {
};
// 未登录拦截门禁:展示现代化医疗合规登录与注册页面
const handleCreateProjectConfirm = useCreateProjectConfirm({
currentUser,
createProjectInFlightRef,
createProjectBootstrapIdRef,
setCreateProjectError,
setCreateProjectSubmitting,
setCreateProjectSubmitPhase,
setCreateProjectModalOpen,
setProjects,
setSessions,
setMessages,
setDeliverables,
setDocCache,
setActiveSessionId,
setActiveDocId,
setActiveCategory,
});
if (!session) {
return (
<LoginView
@@ -1647,175 +1659,6 @@ export default function App() {
);
}
// 发起医疗器械新项目立项 (通过 Modal 交互收集名称、品类与初始成员)
const handleCreateProjectConfirm = async (data: CreateProjectConfirmData) => {
if (!beginCreateProjectSubmit(createProjectInFlightRef)) {
return;
}
setCreateProjectError(null);
setCreateProjectSubmitting(true);
setCreateProjectSubmitPhase(
resolveCreateProjectSubmitPhase(createProjectBootstrapIdRef.current),
);
let newProjId: number;
try {
try {
const ensured = await ensureCreatedProjectWithMembers({
bootstrapProjectId: createProjectBootstrapIdRef.current,
createProject: async () => {
const created = await createProject({
name: data.title,
device_type: data.deviceType || undefined,
intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description,
product_domain: data.productDomain,
project_type_key: data.projectTypeKey,
team_mode: data.teamMode,
execution_mode: data.executionMode,
problem_statement: data.problemStatement,
target_users: data.targetUsers,
target_outcome: data.targetOutcome,
initial_workflow_key:
data.productDomain === 'non_medical_software' ? 'software_bootstrap' : undefined,
});
// create 刚成功即写入 bootstrap:成员失败窗口内串行重试不会再 create
createProjectBootstrapIdRef.current = created.id;
return created;
},
creatorUserId: currentUser.id,
initialMembers: data.initialMembers,
addMember: addProjectMember,
onPhase: setCreateProjectSubmitPhase,
});
newProjId = ensured.projectId;
createProjectBootstrapIdRef.current = ensured.projectId;
} catch (err) {
const withId = err as Error & { projectId?: number };
const bootstrapId =
typeof withId.projectId === 'number' && withId.projectId > 0
? withId.projectId
: createProjectBootstrapIdRef.current;
if (bootstrapId) {
createProjectBootstrapIdRef.current = bootstrapId;
}
const message =
err instanceof Error
? err.message
: '创建项目或同步初始成员失败,请稍后重试(不会因本地状态假成功)';
setCreateProjectError(
bootstrapId
? `项目已创建(#${bootstrapId}),但初始成员未全部落库:${message}。请再次确认以幂等补齐,勿关闭后当作成功。`
: message,
);
return;
}
const nowStr = new Date().toLocaleString();
// 1. 构建项目与成员元数据(服务端成员已持久化)
const members: ProjectMember[] = data.initialMembers.map((m) => ({
userId: m.userId,
userName: m.userName,
department: m.department,
position: m.position,
role: m.role,
status: 'active',
joinedAt: nowStr,
}));
const newProject: ProjectInfo = {
id: newProjId,
title: data.title,
deviceType: data.deviceType,
description: data.description,
creatorId: currentUser.id,
creatorName: currentUser.name,
createdAt: nowStr,
members,
};
// 2. 为每个选中的项目成员生成独立私聊会话
const newSessionsToAppend: ProjectSession[] = [];
const newMessagesToAppend: ChatMessage[] = [];
let creatorSessionId = '';
members.forEach((m) => {
const sessId = buildPrivateSessionId(`p${newProjId}`, m.userId);
if (m.userId === currentUser.id) {
creatorSessionId = sessId;
}
newSessionsToAppend.push({
sessionId: sessId,
userId: m.userId,
projectId: newProjId,
title: data.title,
deviceType: data.deviceType,
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 1,
updatedAt: '刚刚',
});
newMessagesToAppend.push({
id: `m-init-${m.userId}-${Date.now()}`,
sessionId: sessId,
role: 'system',
content:
m.userId === currentUser.id
? `【立项成功】项目《${data.title}》已成功建立!全套研发工作流与阶段文档模板已克隆就绪,协同参谋小队已就位,进入【V0.1 需求摸底】阶段。您可以点击附件图标上传临床需求 URS,或直接输入您的产品构想。`
: `【项目协同通知】您已被项目创建人 ${currentUser.name} 邀请加入《${data.title}》,并分配法定职责为【${m.role}】。`,
timestamp: new Date().toTimeString().substring(0, 5),
});
});
// 3. 仅医疗项目克隆 7 份受控模板;软件项目走服务端 catalog / docs_only
const clonedDocs = shouldCloneMedicalTemplates(data.productDomain)
? createProjectTemplateDeliverables({
projectId: newProjId,
projectName: data.title,
deviceType: data.deviceType,
creatorId: currentUser.id,
creatorName: currentUser.name,
})
: { deliverables: [], docASTMap: {} };
const clonedAsts = clonedDocs.docASTMap;
setProjects((prev) => [newProject, ...prev]);
setSessions((prev) => [...newSessionsToAppend, ...prev]);
setMessages((prev) => [...prev, ...newMessagesToAppend]);
void Promise.all(
newSessionsToAppend.map((sessionItem) =>
upsertChatSession({
session_id: sessionItem.sessionId,
title: sessionItem.title,
user_id: sessionItem.userId ?? currentUser.id,
project_id: sessionItem.projectId,
})
)
).catch((err) => {
console.warn('[sessions] 服务端会话注册失败,其他设备可能暂不可见:', err);
});
setDeliverables((prev) => [...prev, ...clonedDocs.deliverables]);
setDocCache((prev) => ({ ...prev, ...clonedAsts }));
if (creatorSessionId) {
setActiveSessionId(creatorSessionId);
}
if (clonedDocs.deliverables[0]?.docId) {
setActiveDocId(clonedDocs.deliverables[0].docId);
setActiveCategory('workflow');
}
createProjectBootstrapIdRef.current = null;
setCreateProjectModalOpen(false);
setCreateProjectError(null);
} finally {
endCreateProjectSubmit(createProjectInFlightRef);
setCreateProjectSubmitting(false);
setCreateProjectSubmitPhase(null);
}
};
// 添加成员(持久化至服务端 project_members)
const handleAddProjectMember = async (newMember: Omit<ProjectMember, 'joinedAt' | 'status'>) => {
if (!currentProject) return;
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
test('project creation lives in one hook called before the login return', () => {
const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8');
const hook = readFileSync(new URL('./useCreateProjectConfirm.ts', import.meta.url), 'utf8');
assert.equal((app.match(/useCreateProjectConfirm\(/g) || []).length, 1);
const callAt = app.indexOf('useCreateProjectConfirm(');
const loginReturnAt = app.indexOf('if (!session)');
assert.equal(callAt > 0 && callAt < loginReturnAt, true);
assert.equal(app.includes('ensureCreatedProjectWithMembers'), false);
assert.equal(hook.includes('create 刚成功即写入 bootstrap'), true);
assert.equal(hook.includes('shouldCloneMedicalTemplates'), true);
assert.equal(hook.includes('不会因本地状态假成功'), true);
});
+232
View File
@@ -0,0 +1,232 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
import { upsertChatSession } from '../api/chatSessions';
import { addProjectMember } from '../api/projectMembers';
import { createProject } from '../api/projectWorkflow';
import { buildPrivateSessionId } from '../data/storage';
import { createProjectTemplateDeliverables } from '../engine/templateFactory';
import { ensureCreatedProjectWithMembers } from '../lib/createProjectMembersPersist';
import {
beginCreateProjectSubmit,
endCreateProjectSubmit,
resolveCreateProjectSubmitPhase,
shouldCloneMedicalTemplates,
type CreateProjectSubmitPhase,
} from '../lib/createProjectSubmitGuard';
import type { CreateProjectConfirmData } from '../components/project/CreateProjectModal';
import type { DocumentAST } from '../types/block';
import type { UserProfile } from '../types/docgov';
import type {
ChatMessage,
DeliverableCategory,
DeliverableItem,
ProjectInfo,
ProjectMember,
ProjectSession,
} from '../types/workbench';
export interface CreateProjectConfirmInput {
currentUser: UserProfile;
createProjectInFlightRef: MutableRefObject<boolean>;
createProjectBootstrapIdRef: MutableRefObject<number | null>;
setCreateProjectError: Dispatch<SetStateAction<string | null>>;
setCreateProjectSubmitting: Dispatch<SetStateAction<boolean>>;
setCreateProjectSubmitPhase: Dispatch<SetStateAction<CreateProjectSubmitPhase | null>>;
setCreateProjectModalOpen: Dispatch<SetStateAction<boolean>>;
setProjects: Dispatch<SetStateAction<ProjectInfo[]>>;
setSessions: Dispatch<SetStateAction<ProjectSession[]>>;
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
setDeliverables: Dispatch<SetStateAction<DeliverableItem[]>>;
setDocCache: Dispatch<SetStateAction<Record<string, DocumentAST>>>;
setActiveSessionId: Dispatch<SetStateAction<string>>;
setActiveDocId: Dispatch<SetStateAction<string>>;
setActiveCategory: Dispatch<SetStateAction<DeliverableCategory>>;
}
export function useCreateProjectConfirm(input: CreateProjectConfirmInput) {
const {
currentUser,
createProjectInFlightRef,
createProjectBootstrapIdRef,
setCreateProjectError,
setCreateProjectSubmitting,
setCreateProjectSubmitPhase,
setCreateProjectModalOpen,
setProjects,
setSessions,
setMessages,
setDeliverables,
setDocCache,
setActiveSessionId,
setActiveDocId,
setActiveCategory,
} = input;
// 发起医疗器械新项目立项 (通过 Modal 交互收集名称、品类与初始成员)
return async (data: CreateProjectConfirmData) => {
if (!beginCreateProjectSubmit(createProjectInFlightRef)) {
return;
}
setCreateProjectError(null);
setCreateProjectSubmitting(true);
setCreateProjectSubmitPhase(
resolveCreateProjectSubmitPhase(createProjectBootstrapIdRef.current),
);
let newProjId: number;
try {
try {
const ensured = await ensureCreatedProjectWithMembers({
bootstrapProjectId: createProjectBootstrapIdRef.current,
createProject: async () => {
const created = await createProject({
name: data.title,
device_type: data.deviceType || undefined,
intended_use: data.productDomain === 'non_medical_software' ? undefined : data.description,
product_domain: data.productDomain,
project_type_key: data.projectTypeKey,
team_mode: data.teamMode,
execution_mode: data.executionMode,
problem_statement: data.problemStatement,
target_users: data.targetUsers,
target_outcome: data.targetOutcome,
initial_workflow_key:
data.productDomain === 'non_medical_software' ? 'software_bootstrap' : undefined,
});
// create 刚成功即写入 bootstrap:成员失败窗口内串行重试不会再 create
createProjectBootstrapIdRef.current = created.id;
return created;
},
creatorUserId: currentUser.id,
initialMembers: data.initialMembers,
addMember: addProjectMember,
onPhase: setCreateProjectSubmitPhase,
});
newProjId = ensured.projectId;
createProjectBootstrapIdRef.current = ensured.projectId;
} catch (err) {
const withId = err as Error & { projectId?: number };
const bootstrapId =
typeof withId.projectId === 'number' && withId.projectId > 0
? withId.projectId
: createProjectBootstrapIdRef.current;
if (bootstrapId) {
createProjectBootstrapIdRef.current = bootstrapId;
}
const message =
err instanceof Error
? err.message
: '创建项目或同步初始成员失败,请稍后重试(不会因本地状态假成功)';
setCreateProjectError(
bootstrapId
? `项目已创建(#${bootstrapId}),但初始成员未全部落库:${message}。请再次确认以幂等补齐,勿关闭后当作成功。`
: message,
);
return;
}
const nowStr = new Date().toLocaleString();
// 1. 构建项目与成员元数据(服务端成员已持久化)
const members: ProjectMember[] = data.initialMembers.map((m) => ({
userId: m.userId,
userName: m.userName,
department: m.department,
position: m.position,
role: m.role,
status: 'active',
joinedAt: nowStr,
}));
const newProject: ProjectInfo = {
id: newProjId,
title: data.title,
deviceType: data.deviceType,
description: data.description,
creatorId: currentUser.id,
creatorName: currentUser.name,
createdAt: nowStr,
members,
};
// 2. 为每个选中的项目成员生成独立私聊会话
const newSessionsToAppend: ProjectSession[] = [];
const newMessagesToAppend: ChatMessage[] = [];
let creatorSessionId = '';
members.forEach((m) => {
const sessId = buildPrivateSessionId(`p${newProjId}`, m.userId);
if (m.userId === currentUser.id) {
creatorSessionId = sessId;
}
newSessionsToAppend.push({
sessionId: sessId,
userId: m.userId,
projectId: newProjId,
title: data.title,
deviceType: data.deviceType,
stage: 'V0.1',
stageLabel: 'V0.1 需求摸底',
messageCount: 1,
updatedAt: '刚刚',
});
newMessagesToAppend.push({
id: `m-init-${m.userId}-${Date.now()}`,
sessionId: sessId,
role: 'system',
content:
m.userId === currentUser.id
? `【立项成功】项目《${data.title}》已成功建立!全套研发工作流与阶段文档模板已克隆就绪,协同参谋小队已就位,进入【V0.1 需求摸底】阶段。您可以点击附件图标上传临床需求 URS,或直接输入您的产品构想。`
: `【项目协同通知】您已被项目创建人 ${currentUser.name} 邀请加入《${data.title}》,并分配法定职责为【${m.role}】。`,
timestamp: new Date().toTimeString().substring(0, 5),
});
});
// 3. 仅医疗项目克隆 7 份受控模板;软件项目走服务端 catalog / docs_only
const clonedDocs = shouldCloneMedicalTemplates(data.productDomain)
? createProjectTemplateDeliverables({
projectId: newProjId,
projectName: data.title,
deviceType: data.deviceType,
creatorId: currentUser.id,
creatorName: currentUser.name,
})
: { deliverables: [], docASTMap: {} };
const clonedAsts = clonedDocs.docASTMap;
setProjects((prev) => [newProject, ...prev]);
setSessions((prev) => [...newSessionsToAppend, ...prev]);
setMessages((prev) => [...prev, ...newMessagesToAppend]);
void Promise.all(
newSessionsToAppend.map((sessionItem) =>
upsertChatSession({
session_id: sessionItem.sessionId,
title: sessionItem.title,
user_id: sessionItem.userId ?? currentUser.id,
project_id: sessionItem.projectId,
})
)
).catch((err) => {
console.warn('[sessions] 服务端会话注册失败,其他设备可能暂不可见:', err);
});
setDeliverables((prev) => [...prev, ...clonedDocs.deliverables]);
setDocCache((prev) => ({ ...prev, ...clonedAsts }));
if (creatorSessionId) {
setActiveSessionId(creatorSessionId);
}
if (clonedDocs.deliverables[0]?.docId) {
setActiveDocId(clonedDocs.deliverables[0].docId);
setActiveCategory('workflow');
}
createProjectBootstrapIdRef.current = null;
setCreateProjectModalOpen(false);
setCreateProjectError(null);
} finally {
endCreateProjectSubmit(createProjectInFlightRef);
setCreateProjectSubmitting(false);
setCreateProjectSubmitPhase(null);
}
};
}