隔离项目激活模板状态

- 为前端 store 增加按项目保存的激活模板映射,切换项目时恢复对应项目模板。

- 项目 ID 变化时清理临时帧、标注、mask、选区和撤销栈,避免工作区状态串台。

- 同一项目对象刷新时保留当前工作区状态,避免重命名或封面刷新误清空。

- 新增 useStore 回归测试覆盖项目模板隔离和项目切换清理逻辑。

- 更新前端元素审计与交互状态机文档,记录项目级激活模板规则。
This commit is contained in:
2026-05-09 15:07:55 +08:00
parent 80e62596ee
commit a71c622df4
5 changed files with 109 additions and 4 deletions

View File

@@ -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并滚动/聚焦到对应分类按钮 |

View File

@@ -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` |

View 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([]);
});
});

View File

@@ -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,

View File

@@ -22,6 +22,7 @@ export function resetStore() {
maskFuture: [],
templates: [],
activeTemplateId: null,
projectActiveTemplateIds: {},
activeClassId: null,
activeClass: null,
isLoading: false,