隔离项目激活模板状态
- 为前端 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 时才增强显示 |
|
| 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`,默认低对比融入深色侧栏,hover/focus 时才增强显示 |
|
||||||
| 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 |
|
| 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 |
|
||||||
| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes;点击分类会设为后续新 mask 的 activeClass;如果 Canvas 无选中 mask,则不会改变已有 mask;如果 Canvas 已选 mask,则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class,并滚动/聚焦到对应分类按钮 |
|
| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 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` | 当前工作项目、帧序列和当前帧。 |
|
| `currentProject` / `frames` / `currentFrameIndex` | `src/store/useStore.ts` | 当前工作项目、帧序列和当前帧。 |
|
||||||
| `activeTool` | `src/store/useStore.ts` | 工作区当前工具。 |
|
| `activeTool` | `src/store/useStore.ts` | 工作区当前工具。 |
|
||||||
| `selectedMaskIds` | `src/store/useStore.ts` | 当前选中的 mask id 列表;Canvas、本体面板和 AI 页共享。 |
|
| `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` | 撤销/重做栈。 |
|
| `maskHistory` / `maskFuture` | `src/store/useStore.ts` | 撤销/重做栈。 |
|
||||||
|
|
||||||
## 工作区工具自动机
|
## 工作区工具自动机
|
||||||
@@ -87,6 +87,7 @@
|
|||||||
| 交互 | 状态机 | 测试 |
|
| 交互 | 状态机 | 测试 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| 切换激活模板 | 无 mask 直接切换;有任意 mask 时弹确认;确认后删除项目所有本地/后端标注再切换;取消则保持原模板 | `OntologyInspector.test.tsx` |
|
| 切换激活模板 | 无 mask 直接切换;有任意 mask 时弹确认;确认后删除项目所有本地/后端标注再切换;取消则保持原模板 | `OntologyInspector.test.tsx` |
|
||||||
|
| 切换项目 | 项目 ID 变化时清空临时帧、mask、选区和撤销栈,并按项目 ID 恢复该项目上次使用的激活模板;同一项目对象刷新名称/封面时不清空工作区 | `useStore.test.ts` |
|
||||||
| 删除模板 | 站内确认后删除;系统默认模板可由演示恢复出厂设置恢复 | `TemplateRegistry.test.tsx`、后端模板/管理员测试 |
|
| 删除模板 | 站内确认后删除;系统默认模板可由演示恢复出厂设置恢复 | `TemplateRegistry.test.tsx`、后端模板/管理员测试 |
|
||||||
| 复制模板 | 鼠标点击复制入口,生成当前用户私有副本并保留分类颜色、maskid 和层级 | `TemplateRegistry.test.tsx` |
|
| 复制模板 | 鼠标点击复制入口,生成当前用户私有副本并保留分类颜色、maskid 和层级 | `TemplateRegistry.test.tsx` |
|
||||||
| 项目复制 | 项目删除按钮旁复制入口;可选“新项目重置”或“全内容复制” | `ProjectLibrary.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
|
||||||
templates: Template[];
|
templates: Template[];
|
||||||
activeTemplateId: string | null;
|
activeTemplateId: string | null;
|
||||||
|
projectActiveTemplateIds: Record<string, string | null>;
|
||||||
activeClassId: string | null;
|
activeClassId: string | null;
|
||||||
activeClass: TemplateClass | null;
|
activeClass: TemplateClass | null;
|
||||||
setTemplates: (templates: Template[]) => void;
|
setTemplates: (templates: Template[]) => void;
|
||||||
@@ -215,6 +216,7 @@ export const useStore = create<AppState>((set) => ({
|
|||||||
maskHistory: [],
|
maskHistory: [],
|
||||||
maskFuture: [],
|
maskFuture: [],
|
||||||
activeTemplateId: null,
|
activeTemplateId: null,
|
||||||
|
projectActiveTemplateIds: {},
|
||||||
activeClassId: null,
|
activeClassId: null,
|
||||||
activeClass: null,
|
activeClass: null,
|
||||||
});
|
});
|
||||||
@@ -224,7 +226,29 @@ export const useStore = create<AppState>((set) => ({
|
|||||||
projects: [],
|
projects: [],
|
||||||
currentProject: null,
|
currentProject: null,
|
||||||
setProjects: (projects: Project[]) => set({ projects }),
|
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) =>
|
addProject: (project: Project) =>
|
||||||
set((state) => ({ projects: [project, ...state.projects] })),
|
set((state) => ({ projects: [project, ...state.projects] })),
|
||||||
updateProject: (project: Project) =>
|
updateProject: (project: Project) =>
|
||||||
@@ -321,10 +345,22 @@ export const useStore = create<AppState>((set) => ({
|
|||||||
// Templates
|
// Templates
|
||||||
templates: [],
|
templates: [],
|
||||||
activeTemplateId: null,
|
activeTemplateId: null,
|
||||||
|
projectActiveTemplateIds: {},
|
||||||
activeClassId: null,
|
activeClassId: null,
|
||||||
activeClass: null,
|
activeClass: null,
|
||||||
setTemplates: (templates: Template[]) => set({ templates }),
|
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 }),
|
setActiveClassId: (activeClassId: string | null) => set({ activeClassId }),
|
||||||
setActiveClass: (activeClass: TemplateClass | null) => set({
|
setActiveClass: (activeClass: TemplateClass | null) => set({
|
||||||
activeClass,
|
activeClass,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function resetStore() {
|
|||||||
maskFuture: [],
|
maskFuture: [],
|
||||||
templates: [],
|
templates: [],
|
||||||
activeTemplateId: null,
|
activeTemplateId: null,
|
||||||
|
projectActiveTemplateIds: {},
|
||||||
activeClassId: null,
|
activeClassId: null,
|
||||||
activeClass: null,
|
activeClass: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user