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 <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-13 02:50:22 +00:00
committed by weichen
co-authored by Cursor
parent 636eb0ccf1
commit 70493a3ed5
4 changed files with 227 additions and 17 deletions
+77 -4
View File
@@ -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<string | null>(null);
const [projectSyncNotice, setProjectSyncNotice] = useState<string | null>(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 && (
<div
style={{
padding: '8px 12px',
margin: '0 12px 8px',
borderRadius: '8px',
background: 'rgba(245, 158, 11, 0.12)',
border: '1px solid rgba(245, 158, 11, 0.35)',
color: '#b45309',
fontSize: '12px',
}}
>
{projectSyncNotice}
</div>
)}
<WorkflowPrerequisiteBanner
workflowKey={currentWfState.activeKey}
hasApprovedSystemReqs={hasApprovedSystemReqs}
@@ -2728,7 +2780,7 @@ export default function App() {
}
// 发起医疗器械新项目立项 (通过 Modal 交互收集名称、品类与初始成员)
const handleCreateProjectConfirm = (data: {
const handleCreateProjectConfirm = async (data: {
title: string;
deviceType: string;
description: string;
@@ -2740,7 +2792,22 @@ export default function App() {
role: ProjectMemberRole;
}>;
}) => {
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() {
{/* 发起医疗器械新项目立项弹窗 */}
<CreateProjectModal
isOpen={createProjectModalOpen}
onClose={() => setCreateProjectModalOpen(false)}
onClose={() => {
setCreateProjectModalOpen(false);
setCreateProjectError(null);
}}
onSubmit={handleCreateProjectConfirm}
submitError={createProjectError}
currentUser={currentUser}
availableUsers={allUsers}
/>
@@ -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<void>;
submitError?: string | null;
currentUser: UserProfile;
availableUsers: UserProfile[];
}
@@ -28,6 +29,7 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
isOpen,
onClose,
onSubmit,
submitError = null,
currentUser,
availableUsers,
}) => {
@@ -42,10 +44,10 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
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<CreateProjectModalProps> = ({
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<CreateProjectModalProps> = ({
}
});
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<CreateProjectModalProps> = ({
{/* 表单内容 */}
<form onSubmit={handleConfirm} style={{ flex: 1, overflowY: 'auto', padding: '20px 24px' }}>
{errorMsg && (
{(errorMsg || submitError) && (
<div
style={{
padding: '10px 14px',
@@ -230,7 +234,7 @@ export const CreateProjectModal: React.FC<CreateProjectModalProps> = ({
border: '1px solid rgba(239, 68, 68, 0.2)',
}}
>
{errorMsg}
{errorMsg || submitError}
</div>
)}
+74
View File
@@ -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 === '局域网演示项目'));
});
+59
View File
@@ -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<number, ProjectInfo>();
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()];
}