From 0ca1fed9d41520e0c66f3604e6e82315c21c139a Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sat, 9 May 2026 15:16:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=8E=E5=B7=B2=E4=BF=9D=E5=AD=98=E6=A0=87?= =?UTF-8?q?=E6=B3=A8=E6=81=A2=E5=A4=8D=E9=A1=B9=E7=9B=AE=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工作区加载项目标注时,若当前会话没有该项目模板记忆,则从已保存 mask 的 templateId 推断激活模板。 - 新增模板推断工具函数,按有效模板引用的出现次数选择项目模板,并忽略无效模板引用。 - 增加 templateSelection 回归测试覆盖模板推断和无效引用过滤。 - 更新前端审计和交互状态机文档,记录项目模板推断兜底规则。 --- doc/03-frontend-element-audit.md | 2 +- doc/11-frontend-interaction-state-machines.md | 2 +- src/components/VideoWorkspace.tsx | 12 +++++++ src/lib/templateSelection.test.ts | 36 +++++++++++++++++++ src/lib/templateSelection.ts | 16 ++++++++- 5 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 src/lib/templateSelection.test.ts diff --git a/doc/03-frontend-element-audit.md b/doc/03-frontend-element-audit.md index 0e18ed6..9400d34 100644 --- a/doc/03-frontend-element-audit.md +++ b/doc/03-frontend-element-audit.md @@ -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,并滚动/聚焦到对应分类按钮 | diff --git a/doc/11-frontend-interaction-state-machines.md b/doc/11-frontend-interaction-state-machines.md index 4e61c0f..e4815aa 100644 --- a/doc/11-frontend-interaction-state-machines.md +++ b/doc/11-frontend-interaction-state-machines.md @@ -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` | diff --git a/src/components/VideoWorkspace.tsx b/src/components/VideoWorkspace.tsx index 811ae98..c9d7687 100644 --- a/src/components/VideoWorkspace.tsx +++ b/src/components/VideoWorkspace.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)); diff --git a/src/lib/templateSelection.test.ts b/src/lib/templateSelection.test.ts new file mode 100644 index 0000000..043764a --- /dev/null +++ b/src/lib/templateSelection.test.ts @@ -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(); + }); +}); diff --git a/src/lib/templateSelection.ts b/src/lib/templateSelection.ts index 919e116..d2c8fd2 100644 --- a/src/lib/templateSelection.ts +++ b/src/lib/templateSelection.ts @@ -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(); + 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; +}