diff --git a/doc/03-frontend-element-audit.md b/doc/03-frontend-element-audit.md index 2876945..0e18ed6 100644 --- a/doc/03-frontend-element-audit.md +++ b/doc/03-frontend-element-audit.md @@ -139,7 +139,7 @@ | 元素 | 状态 | 说明 | |------|------|------| -| 模板选择 | 真实可用 | 读取全局 templates,可切换 activeTemplateId,并会驱动分类树、mask 分类和导出类别信息 | +| 模板选择 | 真实可用 | 读取全局 templates,可切换当前项目的 activeTemplateId,并会驱动分类树、mask 分类和导出类别信息;不同项目的激活模板在前端 store 中按项目 ID 独立保存,切换项目时不会沿用上一个项目的模板 | | 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`,默认低对比融入深色侧栏,hover/focus 时才增强显示 | | 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 | | 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes;点击分类会设为后续新 mask 的 activeClass;如果 Canvas 无选中 mask,则不会改变已有 mask;如果 Canvas 已选 mask,则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class,并滚动/聚焦到对应分类按钮 | diff --git a/doc/11-frontend-interaction-state-machines.md b/doc/11-frontend-interaction-state-machines.md index 3fbf242..4e61c0f 100644 --- a/doc/11-frontend-interaction-state-machines.md +++ b/doc/11-frontend-interaction-state-machines.md @@ -10,7 +10,7 @@ | `currentProject` / `frames` / `currentFrameIndex` | `src/store/useStore.ts` | 当前工作项目、帧序列和当前帧。 | | `activeTool` | `src/store/useStore.ts` | 工作区当前工具。 | | `selectedMaskIds` | `src/store/useStore.ts` | 当前选中的 mask id 列表;Canvas、本体面板和 AI 页共享。 | -| `activeTemplateId` / `activeClass` | `src/store/useStore.ts` | 当前模板和后续新建 mask 使用的语义类别。 | +| `activeTemplateId` / `projectActiveTemplateIds` / `activeClass` | `src/store/useStore.ts` | 当前项目的激活模板、各项目独立保存的激活模板映射,以及后续新建 mask 使用的语义类别。 | | `maskHistory` / `maskFuture` | `src/store/useStore.ts` | 撤销/重做栈。 | ## 工作区工具自动机 @@ -87,6 +87,7 @@ | 交互 | 状态机 | 测试 | |------|--------|------| | 切换激活模板 | 无 mask 直接切换;有任意 mask 时弹确认;确认后删除项目所有本地/后端标注再切换;取消则保持原模板 | `OntologyInspector.test.tsx` | +| 切换项目 | 项目 ID 变化时清空临时帧、mask、选区和撤销栈,并按项目 ID 恢复该项目上次使用的激活模板;同一项目对象刷新名称/封面时不清空工作区 | `useStore.test.ts` | | 删除模板 | 站内确认后删除;系统默认模板可由演示恢复出厂设置恢复 | `TemplateRegistry.test.tsx`、后端模板/管理员测试 | | 复制模板 | 鼠标点击复制入口,生成当前用户私有副本并保留分类颜色、maskid 和层级 | `TemplateRegistry.test.tsx` | | 项目复制 | 项目删除按钮旁复制入口;可选“新项目重置”或“全内容复制” | `ProjectLibrary.test.tsx` | diff --git a/src/store/useStore.test.ts b/src/store/useStore.test.ts new file mode 100644 index 0000000..6a1b40d --- /dev/null +++ b/src/store/useStore.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { resetStore } from '../test/storeTestUtils'; +import { Project, useStore } from './useStore'; + +function makeProject(id: string, name = id): Project { + return { + id, + name, + status: 'ready', + }; +} + +describe('useStore project workspace state', () => { + beforeEach(() => { + resetStore(); + }); + + it('keeps the active template isolated per project', () => { + const projectA = makeProject('project-a'); + const projectB = makeProject('project-b'); + + useStore.getState().setCurrentProject(projectA); + useStore.getState().setActiveTemplateId('template-a'); + + useStore.getState().setCurrentProject(projectB); + expect(useStore.getState().activeTemplateId).toBeNull(); + + useStore.getState().setActiveTemplateId('template-b'); + + useStore.getState().setCurrentProject(projectA); + expect(useStore.getState().activeTemplateId).toBe('template-a'); + + useStore.getState().setCurrentProject(projectB); + expect(useStore.getState().activeTemplateId).toBe('template-b'); + }); + + it('clears transient workspace data only when the project id changes', () => { + const project = makeProject('project-a', '旧名称'); + + useStore.setState({ + currentProject: project, + activeTemplateId: 'template-a', + projectActiveTemplateIds: { [project.id]: 'template-a' }, + activeClassId: 'class-a', + activeClass: { id: 'class-a', name: '胆囊', color: '#ff0000', zIndex: 1 }, + frames: [{ id: 'frame-a', projectId: project.id, index: 0, url: '/a.jpg', width: 100, height: 100 }], + currentFrameIndex: 1, + annotations: [{ id: 'ann-a', frameId: 'frame-a', type: 'polygon', points: [], label: '胆囊', color: '#ff0000' }], + masks: [{ id: 'mask-a', frameId: 'frame-a', pathData: 'M 0 0 L 10 0 L 10 10 Z', label: '胆囊', color: '#ff0000' }], + selectedMaskIds: ['mask-a'], + maskHistory: [[]], + }); + + useStore.getState().setCurrentProject({ ...project, name: '新名称' }); + expect(useStore.getState().activeTemplateId).toBe('template-a'); + expect(useStore.getState().masks).toHaveLength(1); + expect(useStore.getState().currentProject?.name).toBe('新名称'); + + useStore.getState().setCurrentProject(makeProject('project-b')); + expect(useStore.getState().activeTemplateId).toBeNull(); + expect(useStore.getState().activeClass).toBeNull(); + expect(useStore.getState().frames).toEqual([]); + expect(useStore.getState().masks).toEqual([]); + expect(useStore.getState().selectedMaskIds).toEqual([]); + expect(useStore.getState().maskHistory).toEqual([]); + }); +}); diff --git a/src/store/useStore.ts b/src/store/useStore.ts index 68c3728..739df71 100644 --- a/src/store/useStore.ts +++ b/src/store/useStore.ts @@ -169,6 +169,7 @@ export interface AppState { // Templates templates: Template[]; activeTemplateId: string | null; + projectActiveTemplateIds: Record; activeClassId: string | null; activeClass: TemplateClass | null; setTemplates: (templates: Template[]) => void; @@ -215,6 +216,7 @@ export const useStore = create((set) => ({ maskHistory: [], maskFuture: [], activeTemplateId: null, + projectActiveTemplateIds: {}, activeClassId: null, activeClass: null, }); @@ -224,7 +226,29 @@ export const useStore = create((set) => ({ projects: [], currentProject: null, setProjects: (projects: Project[]) => set({ projects }), - setCurrentProject: (currentProject: Project | null) => set({ currentProject }), + setCurrentProject: (currentProject: Project | null) => + set((state) => { + const previousProjectId = state.currentProject?.id || null; + const nextProjectId = currentProject?.id || null; + if (previousProjectId === nextProjectId) { + return { currentProject }; + } + return { + currentProject, + frames: [], + currentFrameIndex: 0, + annotations: [], + masks: [], + selectedMaskIds: [], + maskHistory: [], + maskFuture: [], + activeTemplateId: nextProjectId + ? state.projectActiveTemplateIds[nextProjectId] || null + : null, + activeClassId: null, + activeClass: null, + }; + }), addProject: (project: Project) => set((state) => ({ projects: [project, ...state.projects] })), updateProject: (project: Project) => @@ -321,10 +345,22 @@ export const useStore = create((set) => ({ // Templates templates: [], activeTemplateId: null, + projectActiveTemplateIds: {}, activeClassId: null, activeClass: null, setTemplates: (templates: Template[]) => set({ templates }), - setActiveTemplateId: (activeTemplateId: string | null) => set({ activeTemplateId }), + setActiveTemplateId: (activeTemplateId: string | null) => + set((state) => { + const projectId = state.currentProject?.id; + if (!projectId) return { activeTemplateId }; + return { + activeTemplateId, + projectActiveTemplateIds: { + ...state.projectActiveTemplateIds, + [projectId]: activeTemplateId, + }, + }; + }), setActiveClassId: (activeClassId: string | null) => set({ activeClassId }), setActiveClass: (activeClass: TemplateClass | null) => set({ activeClass, diff --git a/src/test/storeTestUtils.ts b/src/test/storeTestUtils.ts index 64b25c9..a660833 100644 --- a/src/test/storeTestUtils.ts +++ b/src/test/storeTestUtils.ts @@ -22,6 +22,7 @@ export function resetStore() { maskFuture: [], templates: [], activeTemplateId: null, + projectActiveTemplateIds: {}, activeClassId: null, activeClass: null, isLoading: false,