从已保存标注恢复项目模板
- 工作区加载项目标注时,若当前会话没有该项目模板记忆,则从已保存 mask 的 templateId 推断激活模板。 - 新增模板推断工具函数,按有效模板引用的出现次数选择项目模板,并忽略无效模板引用。 - 增加 templateSelection 回归测试覆盖模板推断和无效引用过滤。 - 更新前端审计和交互状态机文档,记录项目模板推断兜底规则。
This commit is contained in:
@@ -139,7 +139,7 @@
|
||||
|
||||
| 元素 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 模板选择 | 真实可用 | 读取全局 templates,可切换当前项目的 activeTemplateId,并会驱动分类树、mask 分类和导出类别信息;不同项目的激活模板在前端 store 中按项目 ID 独立保存,切换项目时不会沿用上一个项目的模板 |
|
||||
| 模板选择 | 真实可用 | 读取全局 templates,可切换当前项目的 activeTemplateId,并会驱动分类树、mask 分类和导出类别信息;不同项目的激活模板在前端 store 中按项目 ID 独立保存,切换项目时不会沿用上一个项目的模板;若本会话没有项目模板记忆,会从已保存 mask 的 `templateId` 推断当前项目模板 |
|
||||
| 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `seg-scrollbar`,默认低对比融入深色侧栏,hover/focus 时才增强显示 |
|
||||
| 面板标题 | 已简化 | 原“本体论与属性分类管理树”固定说明栏已移除,右侧面板直接展示模板、透明度和语义分类树 |
|
||||
| 分类树展示 / 换标签 | 真实可用 | 显示当前模板 classes;点击分类会设为后续新 mask 的 activeClass;如果 Canvas 无选中 mask,则不会改变已有 mask;如果 Canvas 已选 mask,则同步更新已选 mask 及同一传播链前后帧对应 mask 的标签、颜色和 class 元数据,并把已选 mask 移到前端渲染最上层;当用户在 Canvas 点击已有 mask 时,本面板会按 mask 的 class id / 名称自动切换模板、设置 active class,并滚动/聚焦到对应分类按钮 |
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
| 交互 | 状态机 | 测试 |
|
||||
|------|--------|------|
|
||||
| 切换激活模板 | 无 mask 直接切换;有任意 mask 时弹确认;确认后删除项目所有本地/后端标注再切换;取消则保持原模板 | `OntologyInspector.test.tsx` |
|
||||
| 切换项目 | 项目 ID 变化时清空临时帧、mask、选区和撤销栈,并按项目 ID 恢复该项目上次使用的激活模板;同一项目对象刷新名称/封面时不清空工作区 | `useStore.test.ts` |
|
||||
| 切换项目 | 项目 ID 变化时清空临时帧、mask、选区和撤销栈,并按项目 ID 恢复该项目上次使用的激活模板;若本会话没有该项目模板记忆,则从已保存 mask 的 `templateId` 推断项目模板;同一项目对象刷新名称/封面时不清空工作区 | `useStore.test.ts`、`templateSelection.test.ts` |
|
||||
| 删除模板 | 站内确认后删除;系统默认模板可由演示恢复出厂设置恢复 | `TemplateRegistry.test.tsx`、后端模板/管理员测试 |
|
||||
| 复制模板 | 鼠标点击复制入口,生成当前用户私有副本并保留分类颜色、maskid 和层级 | `TemplateRegistry.test.tsx` |
|
||||
| 项目复制 | 项目删除按钮旁复制入口;可选“新项目重置”或“全内容复制” | `ProjectLibrary.test.tsx` |
|
||||
|
||||
@@ -27,6 +27,7 @@ import { DEFAULT_AI_MODEL_ID, SAM2_MODEL_OPTIONS, type AiModelId, type Frame, ty
|
||||
import { cn } from '../lib/utils';
|
||||
import { normalizeClassMaskIds } from '../lib/maskIds';
|
||||
import { getUndoRedoShortcut } from '../lib/keyboardShortcuts';
|
||||
import { inferActiveTemplateIdFromMasks } from '../lib/templateSelection';
|
||||
|
||||
type PropagationDirection = 'forward' | 'backward';
|
||||
type PropagationProgress = {
|
||||
@@ -633,6 +634,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
||||
));
|
||||
const mergedMasks = [...unsavedMasks, ...savedMasks];
|
||||
setMasks(mergedMasks);
|
||||
const latestStoreState = useStore.getState();
|
||||
const hasProjectTemplateMemory = Object.prototype.hasOwnProperty.call(
|
||||
latestStoreState.projectActiveTemplateIds,
|
||||
String(projectId),
|
||||
);
|
||||
if (!hasProjectTemplateMemory && !latestStoreState.activeTemplateId) {
|
||||
const inferredTemplateId = inferActiveTemplateIdFromMasks(latestTemplates, savedMasks);
|
||||
if (inferredTemplateId) {
|
||||
latestStoreState.setActiveTemplateId(inferredTemplateId);
|
||||
}
|
||||
}
|
||||
if (preserveSelectedIds.length > 0) {
|
||||
const mergedMaskIds = new Set(mergedMasks.map((mask) => mask.id));
|
||||
const nextSelectedIds = preserveSelectedIds.filter((id) => mergedMaskIds.has(id));
|
||||
|
||||
36
src/lib/templateSelection.test.ts
Normal file
36
src/lib/templateSelection.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Mask, Template } from '../store/useStore';
|
||||
import { inferActiveTemplateIdFromMasks } from './templateSelection';
|
||||
|
||||
const templates: Template[] = [
|
||||
{ id: 'template-a', name: '模板 A', classes: [] },
|
||||
{ id: 'template-b', name: '模板 B', classes: [] },
|
||||
];
|
||||
|
||||
function makeMask(id: string, templateId?: string): Mask {
|
||||
return {
|
||||
id,
|
||||
frameId: 'frame-1',
|
||||
templateId,
|
||||
pathData: 'M 0 0 L 10 0 L 10 10 Z',
|
||||
label: 'mask',
|
||||
color: '#ff0000',
|
||||
};
|
||||
}
|
||||
|
||||
describe('templateSelection', () => {
|
||||
it('infers the project active template from saved masks', () => {
|
||||
expect(inferActiveTemplateIdFromMasks(templates, [
|
||||
makeMask('m1', 'template-a'),
|
||||
makeMask('m2', 'template-b'),
|
||||
makeMask('m3', 'template-b'),
|
||||
])).toBe('template-b');
|
||||
});
|
||||
|
||||
it('ignores masks without a valid template reference', () => {
|
||||
expect(inferActiveTemplateIdFromMasks(templates, [
|
||||
makeMask('m1'),
|
||||
makeMask('m2', 'missing-template'),
|
||||
])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Template, TemplateClass } from '../store/useStore';
|
||||
import type { Mask, Template, TemplateClass } from '../store/useStore';
|
||||
|
||||
export function getActiveTemplate(templates: Template[], activeTemplateId: string | null): Template | null {
|
||||
return templates.find((template) => template.id === activeTemplateId) || templates[0] || null;
|
||||
@@ -13,3 +13,17 @@ export function getActiveClass(
|
||||
if (!template) return null;
|
||||
return template.classes.find((templateClass) => templateClass.id === activeClassId) || null;
|
||||
}
|
||||
|
||||
export function inferActiveTemplateIdFromMasks(templates: Template[], masks: Mask[]): string | null {
|
||||
const validTemplateIds = new Set(templates.map((template) => String(template.id)));
|
||||
const counts = new Map<string, number>();
|
||||
masks.forEach((mask) => {
|
||||
if (!mask.templateId) return;
|
||||
const templateId = String(mask.templateId);
|
||||
if (!validTemplateIds.has(templateId)) return;
|
||||
counts.set(templateId, (counts.get(templateId) || 0) + 1);
|
||||
});
|
||||
return Array.from(counts.entries())
|
||||
.sort((left, right) => right[1] - left[1])
|
||||
.at(0)?.[0] || null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user