隔离项目激活模板状态
- 为前端 store 增加按项目保存的激活模板映射,切换项目时恢复对应项目模板。 - 项目 ID 变化时清理临时帧、标注、mask、选区和撤销栈,避免工作区状态串台。 - 同一项目对象刷新时保留当前工作区状态,避免重命名或封面刷新误清空。 - 新增 useStore 回归测试覆盖项目模板隔离和项目切换清理逻辑。 - 更新前端元素审计与交互状态机文档,记录项目级激活模板规则。
This commit is contained in:
@@ -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,并滚动/聚焦到对应分类按钮 |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
67
src/store/useStore.test.ts
Normal file
67
src/store/useStore.test.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -169,6 +169,7 @@ export interface AppState {
|
||||
// Templates
|
||||
templates: Template[];
|
||||
activeTemplateId: string | null;
|
||||
projectActiveTemplateIds: Record<string, string | null>;
|
||||
activeClassId: string | null;
|
||||
activeClass: TemplateClass | null;
|
||||
setTemplates: (templates: Template[]) => void;
|
||||
@@ -215,6 +216,7 @@ export const useStore = create<AppState>((set) => ({
|
||||
maskHistory: [],
|
||||
maskFuture: [],
|
||||
activeTemplateId: null,
|
||||
projectActiveTemplateIds: {},
|
||||
activeClassId: null,
|
||||
activeClass: null,
|
||||
});
|
||||
@@ -224,7 +226,29 @@ export const useStore = create<AppState>((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<AppState>((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,
|
||||
|
||||
@@ -22,6 +22,7 @@ export function resetStore() {
|
||||
maskFuture: [],
|
||||
templates: [],
|
||||
activeTemplateId: null,
|
||||
projectActiveTemplateIds: {},
|
||||
activeClassId: null,
|
||||
activeClass: null,
|
||||
isLoading: false,
|
||||
|
||||
Reference in New Issue
Block a user