从已保存标注恢复项目模板
- 工作区加载项目标注时,若当前会话没有该项目模板记忆,则从已保存 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 时才增强显示 |
|
| 面板滚动条 | 真实可用 | 右侧本体/语义分类面板内容过长时自身滚动;滚动条使用 `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,并滚动/聚焦到对应分类按钮 |
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
| 交互 | 状态机 | 测试 |
|
| 交互 | 状态机 | 测试 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| 切换激活模板 | 无 mask 直接切换;有任意 mask 时弹确认;确认后删除项目所有本地/后端标注再切换;取消则保持原模板 | `OntologyInspector.test.tsx` |
|
| 切换激活模板 | 无 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`、后端模板/管理员测试 |
|
| 删除模板 | 站内确认后删除;系统默认模板可由演示恢复出厂设置恢复 | `TemplateRegistry.test.tsx`、后端模板/管理员测试 |
|
||||||
| 复制模板 | 鼠标点击复制入口,生成当前用户私有副本并保留分类颜色、maskid 和层级 | `TemplateRegistry.test.tsx` |
|
| 复制模板 | 鼠标点击复制入口,生成当前用户私有副本并保留分类颜色、maskid 和层级 | `TemplateRegistry.test.tsx` |
|
||||||
| 项目复制 | 项目删除按钮旁复制入口;可选“新项目重置”或“全内容复制” | `ProjectLibrary.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 { cn } from '../lib/utils';
|
||||||
import { normalizeClassMaskIds } from '../lib/maskIds';
|
import { normalizeClassMaskIds } from '../lib/maskIds';
|
||||||
import { getUndoRedoShortcut } from '../lib/keyboardShortcuts';
|
import { getUndoRedoShortcut } from '../lib/keyboardShortcuts';
|
||||||
|
import { inferActiveTemplateIdFromMasks } from '../lib/templateSelection';
|
||||||
|
|
||||||
type PropagationDirection = 'forward' | 'backward';
|
type PropagationDirection = 'forward' | 'backward';
|
||||||
type PropagationProgress = {
|
type PropagationProgress = {
|
||||||
@@ -633,6 +634,17 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
|
|||||||
));
|
));
|
||||||
const mergedMasks = [...unsavedMasks, ...savedMasks];
|
const mergedMasks = [...unsavedMasks, ...savedMasks];
|
||||||
setMasks(mergedMasks);
|
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) {
|
if (preserveSelectedIds.length > 0) {
|
||||||
const mergedMaskIds = new Set(mergedMasks.map((mask) => mask.id));
|
const mergedMaskIds = new Set(mergedMasks.map((mask) => mask.id));
|
||||||
const nextSelectedIds = preserveSelectedIds.filter((id) => mergedMaskIds.has(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 {
|
export function getActiveTemplate(templates: Template[], activeTemplateId: string | null): Template | null {
|
||||||
return templates.find((template) => template.id === activeTemplateId) || templates[0] || null;
|
return templates.find((template) => template.id === activeTemplateId) || templates[0] || null;
|
||||||
@@ -13,3 +13,17 @@ export function getActiveClass(
|
|||||||
if (!template) return null;
|
if (!template) return null;
|
||||||
return template.classes.find((templateClass) => templateClass.id === activeClassId) || 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