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:
committed by
weichen
co-authored by
Cursor
parent
636eb0ccf1
commit
70493a3ed5
+77
-4
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 === '局域网演示项目'));
|
||||
});
|
||||
@@ -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()];
|
||||
}
|
||||
Reference in New Issue
Block a user