From 70493a3ed591e1359fbd5c0d3c93ff82313ea8a9 Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Fri, 11 Sep 2026 14:28:01 +0800 Subject: [PATCH] feat(web): wire project list and create to server API Login syncs projects from GET /api/v1/projects; create project uses POST with server-assigned id while preserving local sessions and templates. Co-authored-by: Cursor --- web/src/App.tsx | 81 ++++++++++++++++++- .../components/project/CreateProjectModal.tsx | 30 ++++--- web/src/lib/projectAdapter.test.ts | 74 +++++++++++++++++ web/src/lib/projectAdapter.ts | 59 ++++++++++++++ 4 files changed, 227 insertions(+), 17 deletions(-) create mode 100644 web/src/lib/projectAdapter.test.ts create mode 100644 web/src/lib/projectAdapter.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 40882d9e..f6224279 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -17,7 +17,7 @@ import { unshareFreeformDoc, deleteFreeformDoc, } from './api/freeformDoc'; -import { fetchProjectLineage, getProject } from './api/projectWorkflow'; +import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow'; import { DocumentLineage } from './types/projectWorkflow'; import { WorkflowPrerequisiteBanner } from './components/workflow/WorkflowPrerequisiteBanner'; import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice'; @@ -120,6 +120,7 @@ import { useTurnRuntime } from './hooks/useTurnRuntime'; import { fetchChatTurnStatus } from './api/chatTurn'; import { createClientTurnId } from './lib/createClientTurnId'; import { mergeSessionUser } from './lib/mergeSessionUser'; +import { mapServerProjectToProjectInfo, mergeProjectLists } from './lib/projectAdapter'; import { SendMessageResult } from './types/sendMessage'; import { CategoryFilterBar } from './components/history/CategoryFilterBar'; import { HistoryCalendar } from './components/history/HistoryCalendar'; @@ -560,6 +561,8 @@ export default function App() { }, [projects]); const [createProjectModalOpen, setCreateProjectModalOpen] = useState(false); + const [createProjectError, setCreateProjectError] = useState(null); + const [projectSyncNotice, setProjectSyncNotice] = useState(null); const [projectMembersModalOpen, setProjectMembersModalOpen] = useState(false); const isAdmin = currentUser.roleId === 'role-admin'; @@ -639,6 +642,40 @@ export default function App() { } }, []); + useEffect(() => { + if (!session) { + setProjectSyncNotice(null); + return; + } + + let cancelled = false; + void (async () => { + try { + const serverProjects = await fetchProjects(); + if (cancelled) return; + setProjects((prev) => { + const mapped = serverProjects.map((project) => + mapServerProjectToProjectInfo( + project, + allUsers, + prev.find((item) => item.id === project.id) + ) + ); + return mergeProjectLists(prev, mapped); + }); + setProjectSyncNotice(null); + } catch (err) { + if (cancelled) return; + console.warn('[projects] 服务端项目列表同步失败,继续使用本地缓存:', err); + setProjectSyncNotice('项目列表暂用本地缓存:无法连接服务端,新建项目可能无法在其他设备同步。'); + } + })(); + + return () => { + cancelled = true; + }; + }, [session, allUsers]); + const handleLogout = () => { authClient.logout(); setSession(null); @@ -710,6 +747,21 @@ export default function App() { const workflowBanner = ( <> + {projectSyncNotice && ( +
+ {projectSyncNotice} +
+ )} ; }) => { - const newProjId = Date.now(); + setCreateProjectError(null); + + let newProjId: number; + try { + const created = await createProject({ + name: data.title, + device_type: data.deviceType, + intended_use: data.description, + }); + newProjId = created.id; + } catch (err) { + const message = err instanceof Error ? err.message : '创建项目失败,请稍后重试'; + setCreateProjectError(message); + return; + } + const nowStr = new Date().toLocaleString(); // 1. 构建项目与成员元数据 @@ -2819,6 +2886,8 @@ export default function App() { } setActiveDocId(clonedDocs[0].docId); setActiveCategory('workflow'); + setCreateProjectModalOpen(false); + setCreateProjectError(null); }; // 添加成员 @@ -3420,8 +3489,12 @@ export default function App() { {/* 发起医疗器械新项目立项弹窗 */} setCreateProjectModalOpen(false)} + onClose={() => { + setCreateProjectModalOpen(false); + setCreateProjectError(null); + }} onSubmit={handleCreateProjectConfirm} + submitError={createProjectError} currentUser={currentUser} availableUsers={allUsers} /> diff --git a/web/src/components/project/CreateProjectModal.tsx b/web/src/components/project/CreateProjectModal.tsx index b5311f9f..ccdadefa 100644 --- a/web/src/components/project/CreateProjectModal.tsx +++ b/web/src/components/project/CreateProjectModal.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { X, Sparkles, FolderPlus, Users, Check } from 'lucide-react'; import { UserProfile } from '../../types/docgov'; import { ProjectMemberRole } from '../../types/workbench'; @@ -17,7 +17,8 @@ interface CreateProjectModalProps { position?: string; role: ProjectMemberRole; }>; - }) => void; + }) => void | Promise; + submitError?: string | null; currentUser: UserProfile; availableUsers: UserProfile[]; } @@ -28,6 +29,7 @@ export const CreateProjectModal: React.FC = ({ isOpen, onClose, onSubmit, + submitError = null, currentUser, availableUsers, }) => { @@ -42,10 +44,10 @@ export const CreateProjectModal: React.FC = ({ availableUsers.forEach((u) => { if (u.id === currentUser.id) { init[u.id] = { selected: true, role: 'creator' }; - } else if (u.id === 2) { + } else if (u.id === 3) { // 默认推荐李主管为审核人 init[u.id] = { selected: true, role: 'reviewer' }; - } else if (u.id === 3) { + } else if (u.id === 4) { // 默认推荐王总监为批准人 init[u.id] = { selected: true, role: 'approver' }; } else { @@ -57,6 +59,14 @@ export const CreateProjectModal: React.FC = ({ const [errorMsg, setErrorMsg] = useState(''); + useEffect(() => { + if (!isOpen) { + setTitle(''); + setDescription(''); + setErrorMsg(''); + } + }, [isOpen]); + if (!isOpen) return null; const handleToggleUser = (userId: number) => { @@ -120,18 +130,12 @@ export const CreateProjectModal: React.FC = ({ } }); - onSubmit({ + void onSubmit({ title: cleanTitle, deviceType: finalDeviceType, description: description.trim(), initialMembers, }); - - // 重置并关闭 - setTitle(''); - setDescription(''); - setErrorMsg(''); - onClose(); }; return ( @@ -218,7 +222,7 @@ export const CreateProjectModal: React.FC = ({ {/* 表单内容 */}
- {errorMsg && ( + {(errorMsg || submitError) && (
= ({ border: '1px solid rgba(239, 68, 68, 0.2)', }} > - {errorMsg} + {errorMsg || submitError}
)} diff --git a/web/src/lib/projectAdapter.test.ts b/web/src/lib/projectAdapter.test.ts new file mode 100644 index 00000000..c7b2a46e --- /dev/null +++ b/web/src/lib/projectAdapter.test.ts @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapServerProjectToProjectInfo, mergeProjectLists } from './projectAdapter'; +import { Project } from '../types/projectWorkflow'; +import { ProjectInfo } from '../types/workbench'; + +const catalog = [ + { id: 1, name: '系统管理员' }, + { id: 2, name: '张工' }, +]; + +const serverProject: Project = { + id: 9001, + code: 'demo-9001', + name: '局域网演示项目', + status: 'active', + device_type: '超声设备', + intended_use: '跨机器一致性验证', + creator_user_id: 2, + active_workflow_key: 'main', + workflow_selection_version: 1, + created_at: '2026-09-11T08:00:00Z', + updated_at: '2026-09-11T08:00:00Z', +}; + +test('mapServerProjectToProjectInfo maps creator and preserves local members', () => { + const existing: ProjectInfo = { + id: 9001, + title: '旧标题', + deviceType: '旧类型', + creatorId: 2, + creatorName: '张工', + createdAt: '2026-09-10', + members: [ + { + userId: 2, + userName: '张工', + role: 'creator', + status: 'active', + joinedAt: '2026-09-10', + }, + { + userId: 3, + userName: '李主管', + role: 'reviewer', + status: 'active', + joinedAt: '2026-09-10', + }, + ], + }; + + const mapped = mapServerProjectToProjectInfo(serverProject, catalog, existing); + assert.equal(mapped.title, '局域网演示项目'); + assert.equal(mapped.creatorName, '张工'); + assert.equal(mapped.members.length, 2); +}); + +test('mergeProjectLists upserts server projects without dropping local-only demo projects', () => { + const localOnly: ProjectInfo = { + id: 5201, + title: 'NeuroPulse 演示', + deviceType: '超声', + creatorId: 2, + creatorName: '张工', + createdAt: '2026-09-08', + members: [], + }; + const fromServer = mapServerProjectToProjectInfo(serverProject, catalog); + const merged = mergeProjectLists([localOnly], [fromServer]); + + assert.equal(merged.length, 2); + assert.ok(merged.some((p) => p.id === 5201)); + assert.ok(merged.some((p) => p.id === 9001 && p.title === '局域网演示项目')); +}); diff --git a/web/src/lib/projectAdapter.ts b/web/src/lib/projectAdapter.ts new file mode 100644 index 00000000..1dc1485e --- /dev/null +++ b/web/src/lib/projectAdapter.ts @@ -0,0 +1,59 @@ +import { Project } from '../types/projectWorkflow'; +import { ProjectInfo, ProjectMember } from '../types/workbench'; + +type UserCatalogEntry = { id: number; name: string }; + +export function resolveUserDisplayName( + userId: number, + catalog: UserCatalogEntry[], + fallback = '未知用户' +): string { + return catalog.find((u) => u.id === userId)?.name ?? fallback; +} + +export function mapServerProjectToProjectInfo( + project: Project, + catalog: UserCatalogEntry[], + existing?: ProjectInfo +): ProjectInfo { + const creatorName = resolveUserDisplayName(project.creator_user_id, catalog); + const defaultMember: ProjectMember = { + userId: project.creator_user_id, + userName: creatorName, + role: 'creator', + status: 'active', + joinedAt: project.created_at, + }; + + return { + id: project.id, + title: project.name, + deviceType: project.device_type || existing?.deviceType || '', + description: project.intended_use || existing?.description || '', + creatorId: project.creator_user_id, + creatorName, + createdAt: project.created_at, + members: existing?.members?.length ? existing.members : [defaultMember], + }; +} + +/** 服务端项目覆盖同 id 本地项,保留本地成员等演示扩展字段。 */ +export function mergeProjectLists(local: ProjectInfo[], fromServer: ProjectInfo[]): ProjectInfo[] { + const byId = new Map(); + for (const project of local) { + byId.set(project.id, project); + } + for (const project of fromServer) { + const previous = byId.get(project.id); + if (!previous) { + byId.set(project.id, project); + continue; + } + byId.set(project.id, { + ...previous, + ...project, + members: previous.members.length > 0 ? previous.members : project.members, + }); + } + return [...byId.values()]; +}