fix(template): 修复模板库保存/颜色/拖拽排序,联动OntologyInspector,种子腹腔镜35分类模板

- backend/schemas.py: TemplateUpdate 添加 classes/rules 字段
- backend/models.py: Template 添加 description 列
- backend/routers/templates.py: create/update 打包/解包 mapping_rules.classes (已有)
- backend/main.py: seed 腹腔镜胆囊切除术35分类模板
- src/lib/api.ts: updateTemplate 改 PATCH,补齐 color/z_index,_mapTemplate 对齐 TS 接口
- src/store/useStore.ts: 新增 activeTemplateId/setActiveTemplateId
- src/components/TemplateRegistry.tsx: 随机颜色(HSL轮盘)、HTML5拖拽排序、批量JSON导入、一键载入腹腔镜模板、handleSave 补齐必填字段
- src/components/OntologyInspector.tsx: 完全重写,从store读取模板,支持模板切换和自定义分类
- src/components/VideoWorkspace.tsx: 进入时自动加载模板列表
- src/components/ProjectLibrary.tsx: 修复状态字符串 TS 严格类型报错
- 工程分析/: 更新实现方案与经验记录

Timestamp: 20260430_222830
This commit is contained in:
2026-04-30 22:42:55 +08:00
parent 6d008ec4a2
commit 4d65c37c73
14 changed files with 598 additions and 68 deletions

View File

@@ -1,13 +1,37 @@
import React from 'react';
import { Layers, ChevronDown, Tag, Eye } from 'lucide-react';
import React, { useState } from 'react';
import { Layers, ChevronDown, Tag, Eye, Plus, X } from 'lucide-react';
import { useStore } from '../store/useStore';
import type { TemplateClass } from '../store/useStore';
export function OntologyInspector() {
const ontology = [
{ id: '1', label: 'vehicle_four_wheels', color: 'bg-cyan-500', count: 4, zIndex: 60 },
{ id: '2', label: 'pedestrian', color: 'bg-purple-500', count: 2, zIndex: 70 },
{ id: '3', label: 'road_surface', color: 'bg-gray-500', count: 1, zIndex: 10 },
{ id: '4', label: 'traffic_sign', color: 'bg-green-500', count: 3, zIndex: 50 },
];
const templates = useStore((state) => state.templates);
const activeTemplateId = useStore((state) => state.activeTemplateId);
const setActiveTemplateId = useStore((state) => state.setActiveTemplateId);
// Project-level custom classes (in addition to template classes)
const [customClasses, setCustomClasses] = useState<TemplateClass[]>([]);
const [showAddForm, setShowAddForm] = useState(false);
const [newClassName, setNewClassName] = useState('');
const [newClassColor, setNewClassColor] = useState('#06b6d4');
const activeTemplate = templates.find((t) => t.id === activeTemplateId) || templates[0] || null;
const templateClasses = activeTemplate?.classes || [];
const allClasses = [...templateClasses, ...customClasses].sort((a, b) => b.zIndex - a.zIndex);
const handleAddCustom = () => {
if (!newClassName.trim()) return;
const maxZ = allClasses.length > 0 ? Math.max(...allClasses.map((c) => c.zIndex)) : 0;
const newClass: TemplateClass = {
id: `custom-${Date.now()}`,
name: newClassName.trim(),
color: newClassColor,
zIndex: maxZ + 10,
category: '自定义',
};
setCustomClasses([...customClasses, newClass]);
setNewClassName('');
setShowAddForm(false);
};
return (
<div className="w-60 bg-[#0d0d0d] flex flex-col border-l border-white/5 shrink-0 z-10 overflow-hidden">
@@ -17,35 +41,42 @@ export function OntologyInspector() {
</div>
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-6">
{/* Frame Metadata */}
{/* Template Selector */}
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3"></h3>
<div className="bg-white/5 rounded p-2 text-[11px] space-y-2 font-mono text-gray-300">
<div className="flex justify-between">
<span className="text-gray-500">:</span> <span>1920x1080</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">:</span> <span>00:01:24.16</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">:</span> <span>10 </span>
</div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-2"></h3>
<div className="relative">
<select
value={activeTemplate?.id || ''}
onChange={(e) => setActiveTemplateId(e.target.value || null)}
className="w-full bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 appearance-none cursor-pointer focus:outline-none focus:border-cyan-500/50"
>
<option value="">-- --</option>
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<ChevronDown size={12} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none" />
</div>
{activeTemplate && (
<div className="mt-2 text-[10px] text-gray-600">
{activeTemplate.classes?.length ?? 0}
{customClasses.length > 0 && ` + ${customClasses.length} 个自定义`}
</div>
)}
</div>
{/* Global Priority Classes */}
{/* Semantic Classification Tree */}
<div>
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3 flex justify-between items-center">
<span> (/Z-Index)</span>
<button className="text-cyan-400 hover:text-cyan-300"><ChevronDown size={14} /></button>
</h3>
<div className="space-y-2">
{ontology.sort((a,b) => b.zIndex - a.zIndex).map(cls => (
{allClasses.map(cls => (
<div key={cls.id} className="flex flex-col gap-1">
<div className="flex items-center justify-between p-2 rounded bg-white/5 hover:bg-white/10 cursor-pointer group transition-colors">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-sm ${cls.color}`} />
<span className="text-xs font-medium text-gray-200">{cls.label}</span>
<span className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: cls.color }} />
<span className="text-xs font-medium text-gray-200">{cls.name}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] text-gray-500 font-mono">z:{cls.zIndex}</span>
@@ -54,16 +85,58 @@ export function OntologyInspector() {
</div>
</div>
))}
{allClasses.length === 0 && (
<div className="text-xs text-gray-600 text-center py-4"></div>
)}
</div>
</div>
{/* Add Custom Class */}
<div>
<div className="flex justify-between items-center mb-2">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest"></h3>
<button
onClick={() => setShowAddForm(!showAddForm)}
className="text-cyan-400 hover:text-cyan-300 transition-colors"
>
<Plus size={12} />
</button>
</div>
{showAddForm && (
<div className="bg-[#1a1a1a] border border-white/10 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<input
type="color"
value={newClassColor}
onChange={(e) => setNewClassColor(e.target.value)}
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer"
/>
<input
type="text"
value={newClassName}
onChange={(e) => setNewClassName(e.target.value)}
placeholder="分类名称"
className="flex-1 bg-[#111] border border-white/10 rounded px-2 py-1 text-xs text-white"
onKeyDown={(e) => e.key === 'Enter' && handleAddCustom()}
/>
<button onClick={handleAddCustom} className="text-cyan-400 hover:text-cyan-300">
<Plus size={14} />
</button>
<button onClick={() => setShowAddForm(false)} className="text-gray-500 hover:text-gray-300">
<X size={14} />
</button>
</div>
</div>
)}
</div>
{/* Current Active Object Properties */}
<div className="mt-4 pt-4 border-t border-[#222]">
<h3 className="text-[10px] font-bold text-gray-500 uppercase tracking-widest mb-3"></h3>
<div className="bg-white/5 rounded-lg p-3">
<div className="flex items-center gap-2 mb-3">
<Tag size={12} className="text-cyan-400" />
<span className="text-xs font-semibold text-gray-200">vehicle_four_wheels</span>
<span className="text-xs font-semibold text-gray-200">{activeTemplate?.name || '未选择'}</span>
</div>
<div className="space-y-3">
<div className="space-y-1">

View File

@@ -212,14 +212,14 @@ export function ProjectLibrary({ onProjectSelect }: ProjectLibraryProps) {
{proj.source_type === 'dicom' ? 'DICOM' : (proj.fps || '30FPS')}
</span>
<span className="backdrop-blur-md bg-black/40 text-gray-200 text-[10px] px-2 py-1 rounded border border-white/10 flex items-center gap-1 uppercase tracking-widest">
{proj.status === 'Ready' || proj.status === 'ready' ? (
{proj.status === 'Ready' ? (
<><div className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> </>
) : proj.status === 'Parsing' || proj.status === 'parsing' ? (
) : proj.status === 'Parsing' ? (
<><div className="w-1.5 h-1.5 bg-amber-500 rounded-full animate-pulse" /> </>
) : proj.status === 'pending' || proj.status === 'Pending' ? (
<><div className="w-1.5 h-1.5 bg-blue-500 rounded-full" /> </>
) : (
) : proj.status === 'Error' ? (
<><div className="w-1.5 h-1.5 bg-red-500 rounded-full" /> </>
) : (
<><div className="w-1.5 h-1.5 bg-blue-500 rounded-full" /> </>
)}
</span>
</div>

View File

@@ -1,10 +1,45 @@
import React, { useState, useEffect } from 'react';
import { Settings, Database, Trash2, Edit3, Plus, Loader2, X } from 'lucide-react';
import { Settings, Database, Trash2, Edit3, Plus, Loader2, X, GripVertical, Import } from 'lucide-react';
import { cn } from '../lib/utils';
import { useStore } from '../store/useStore';
import { getTemplates, createTemplate, updateTemplate, deleteTemplate } from '../lib/api';
import type { Template, TemplateClass } from '../store/useStore';
// HSL to Hex color generator
function hslToHex(h: number, s: number, l: number): string {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
function generateColor(index: number, total: number): string {
const hue = (index * 360 / Math.max(total, 1)) % 360;
return hslToHex(hue, 75, 55);
}
const LAPAROSCOPIC_COLORS = [
[134, 124, 118], [0, 157, 142], [245, 161, 0], [255, 172, 159], [146, 175, 236], [155, 62, 0],
[255, 91, 0], [255, 234, 0], [85, 111, 181], [155, 132, 0], [181, 227, 14], [72, 0, 255],
[255, 0, 255], [29, 32, 136], [240, 16, 116], [160, 15, 95], [0, 155, 33], [0, 160, 233],
[52, 184, 178], [66, 115, 82], [90, 120, 41], [255, 0, 0], [117, 0, 0], [167, 24, 233],
[42, 8, 66], [112, 113, 150], [0, 255, 0], [255, 255, 255], [0, 255, 255], [181, 85, 105],
[113, 102, 140], [202, 202, 200], [197, 83, 181], [136, 162, 196], [138, 251, 213],
];
const LAPAROSCOPIC_NAMES = [
'针', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝',
'肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管',
'吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹',
'无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝',
'静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带',
'肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声',
];
export function TemplateRegistry() {
const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates);
@@ -16,11 +51,14 @@ export function TemplateRegistry() {
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [showModal, setShowModal] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showImport, setShowImport] = useState(false);
const [importText, setImportText] = useState('');
const [editName, setEditName] = useState('');
const [editDesc, setEditDesc] = useState('');
const [editClasses, setEditClasses] = useState<TemplateClass[]>([]);
const [editingClassId, setEditingClassId] = useState<string | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
useEffect(() => {
setIsLoading(true);
@@ -50,24 +88,25 @@ export function TemplateRegistry() {
if (!editName.trim()) return;
setIsSaving(true);
try {
const basePayload = {
name: editName.trim(),
description: editDesc.trim() || undefined,
classes: editClasses,
rules: [],
color: selectedTemplate ? (selectedTemplate as any).color || '#06b6d4' : '#06b6d4',
z_index: selectedTemplate ? (selectedTemplate as any).z_index ?? 0 : 0,
};
if (selectedTemplate) {
const updated = await updateTemplate(selectedTemplate.id, {
name: editName.trim(),
description: editDesc.trim() || undefined,
classes: editClasses,
});
const updated = await updateTemplate(selectedTemplate.id, basePayload);
updateTemplateStore(updated);
} else {
const created = await createTemplate({
name: editName.trim(),
description: editDesc.trim() || undefined,
classes: editClasses,
});
const created = await createTemplate(basePayload);
addTemplate(created);
}
setShowModal(false);
} catch (err) {
console.error('Failed to save template:', err);
alert('保存失败,请查看控制台');
} finally {
setIsSaving(false);
}
@@ -90,7 +129,7 @@ export function TemplateRegistry() {
const newClass: TemplateClass = {
id: `cls-${Date.now()}`,
name: '新类别',
color: '#06b6d4',
color: generateColor(editClasses.length, Math.max(editClasses.length + 1, 8)),
zIndex: editClasses.length > 0 ? Math.max(...editClasses.map((c) => c.zIndex)) + 10 : 10,
category: '未分类',
};
@@ -106,6 +145,69 @@ export function TemplateRegistry() {
setEditClasses(editClasses.filter((c) => c.id !== id));
};
const reorderClasses = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return;
const items = [...editClasses];
const [moved] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, moved);
// Recalculate z-index based on new order (top = highest)
const recalculated = items.map((c, i) => ({ ...c, zIndex: (items.length - i) * 10 }));
setEditClasses(recalculated);
};
const handleImport = () => {
try {
const data = JSON.parse(importText);
let colors: number[][] = [];
let names: string[] = [];
if (Array.isArray(data) && data.length === 2 && Array.isArray(data[0]) && Array.isArray(data[1])) {
colors = data[0];
names = data[1];
} else if (Array.isArray(data.colors) && Array.isArray(data.names)) {
colors = data.colors;
names = data.names;
} else {
alert('格式错误:请提供 [[colors...], [names...]] 或 {colors, names}');
return;
}
const imported: TemplateClass[] = names.map((name: string, i: number) => {
const rgb = colors[i] || [100, 100, 100];
const hex = `#${rgb[0].toString(16).padStart(2, '0')}${rgb[1].toString(16).padStart(2, '0')}${rgb[2].toString(16).padStart(2, '0')}`;
return {
id: `cls-import-${Date.now()}-${i}`,
name,
color: hex,
zIndex: (names.length - i) * 10,
category: '批量导入',
};
});
setEditClasses([...editClasses, ...imported]);
setShowImport(false);
setImportText('');
} catch (e) {
alert('JSON 解析失败');
}
};
const loadLaparoscopic = () => {
const imported: TemplateClass[] = LAPAROSCOPIC_NAMES.map((name, i) => {
const rgb = LAPAROSCOPIC_COLORS[i];
const hex = `#${rgb[0].toString(16).padStart(2, '0')}${rgb[1].toString(16).padStart(2, '0')}${rgb[2].toString(16).padStart(2, '0')}`;
return {
id: `cls-lap-${Date.now()}-${i}`,
name,
color: hex,
zIndex: (LAPAROSCOPIC_NAMES.length - i) * 10,
category: '腹腔镜胆囊切除术',
};
});
setEditClasses(imported);
setShowImport(false);
};
const activeTemplate = selectedTemplate || templates[0] || null;
return (
@@ -256,22 +358,57 @@ export function TemplateRegistry() {
<div className="mb-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-widest">分类定义</h3>
<button
onClick={addClass}
className="text-cyan-400 hover:text-cyan-300 text-xs transition-colors flex items-center gap-1"
>
<Plus size={12} /> 添加分类
</button>
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-widest">分类定义 ({editClasses.length} 个)</h3>
<div className="flex items-center gap-2">
<button
onClick={() => setShowImport(true)}
className="text-gray-400 hover:text-white text-xs transition-colors flex items-center gap-1 bg-white/5 px-2 py-1 rounded"
>
<Import size={12} /> 批量导入
</button>
<button
onClick={addClass}
className="text-cyan-400 hover:text-cyan-300 text-xs transition-colors flex items-center gap-1"
>
<Plus size={12} /> 添加分类
</button>
</div>
</div>
<div className="space-y-2">
{editClasses.map((cls) => (
<div key={cls.id} className="flex items-center gap-3 bg-[#0d0d0d] border border-white/5 rounded-lg p-3">
{editClasses.map((cls, idx) => (
<div
key={cls.id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('text/plain', String(idx));
e.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(idx);
}}
onDragLeave={() => setDragOverIndex(null)}
onDrop={(e) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
reorderClasses(fromIndex, idx);
setDragOverIndex(null);
}}
onDragEnd={() => setDragOverIndex(null)}
className={cn(
"flex items-center gap-2 bg-[#0d0d0d] border rounded-lg p-2 transition-all",
dragOverIndex === idx ? "border-cyan-500/50 bg-cyan-500/5" : "border-white/5"
)}
>
<div className="text-gray-600 cursor-grab active:cursor-grabbing shrink-0">
<GripVertical size={14} />
</div>
<input
type="color"
value={cls.color}
onChange={(e) => updateClass(cls.id, { color: e.target.value })}
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer"
className="w-8 h-8 rounded bg-transparent border-0 cursor-pointer shrink-0"
/>
{editingClassId === cls.id ? (
<>
@@ -285,21 +422,22 @@ export function TemplateRegistry() {
className="flex-1 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white"
/>
<input
type="number"
value={cls.zIndex}
onChange={(e) => updateClass(cls.id, { zIndex: parseInt(e.target.value) || 0 })}
className="w-20 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white font-mono"
type="text"
value={cls.category || ''}
onChange={(e) => updateClass(cls.id, { category: e.target.value })}
placeholder="类别"
className="w-24 bg-[#1a1a1a] border border-white/10 rounded px-2 py-1 text-sm text-white"
/>
</>
) : (
<>
<span
<span
className="flex-1 text-sm text-gray-300 cursor-pointer"
onClick={() => setEditingClassId(cls.id)}
>
{cls.name}
</span>
<span className="w-20 text-sm text-gray-500 font-mono text-right">z:{cls.zIndex}</span>
<span className="w-16 text-sm text-gray-500 font-mono text-right">z:{cls.zIndex}</span>
</>
)}
<button onClick={() => removeClass(cls.id)} className="text-gray-500 hover:text-red-400 transition-colors">
@@ -337,6 +475,43 @@ export function TemplateRegistry() {
</div>
</div>
)}
{showImport && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="bg-[#111] border border-white/10 rounded-2xl p-6 w-full max-w-lg shadow-2xl">
<h2 className="text-lg font-semibold text-white mb-4">批量导入分类</h2>
<p className="text-xs text-gray-500 mb-2">支持格式: JSON 数组 [[colors], [names]] 或 {'{'}&quot;colors&quot;: [...], &quot;names&quot;: [...]{'}'}</p>
<textarea
value={importText}
onChange={(e) => setImportText(e.target.value)}
placeholder='[[[255,0,0], [0,255,0]], ["分类A", "分类B"]]'
className="w-full h-32 bg-[#1a1a1a] border border-white/10 rounded-lg px-3 py-2 text-xs text-gray-300 font-mono focus:outline-none focus:border-cyan-500/50 resize-none"
/>
<div className="flex justify-between items-center mt-4">
<button
onClick={loadLaparoscopic}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
📋 载入腹腔镜胆囊切除术模板
</button>
<div className="flex gap-3">
<button
onClick={() => { setShowImport(false); setImportText(''); }}
className="px-4 py-2 rounded-lg text-sm text-gray-400 hover:text-white transition-colors"
>
</button>
<button
onClick={handleImport}
className="px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 hover:bg-cyan-400 text-black transition-all"
>
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { useStore } from '../store/useStore';
import { getProjectFrames, parseMedia } from '../lib/api';
import { getProjectFrames, parseMedia, getTemplates } from '../lib/api';
import { CanvasArea } from './CanvasArea';
import { ToolsPalette } from './ToolsPalette';
import { OntologyInspector } from './OntologyInspector';
@@ -63,6 +63,15 @@ export function VideoWorkspace({ onNavigateToAI }: { onNavigateToAI?: () => void
return () => { cancelled = true; };
}, [currentProject?.id, setFrames, setCurrentFrame]);
const templates = useStore((state) => state.templates);
const setTemplates = useStore((state) => state.setTemplates);
useEffect(() => {
if (templates.length === 0) {
getTemplates().then((data) => setTemplates(data)).catch(console.error);
}
}, [templates.length, setTemplates]);
const currentFrameUrl = frames[currentFrameIndex]?.url || '';
return (

View File

@@ -62,6 +62,7 @@ export async function getProjects(): Promise<Project[]> {
export async function createProject(payload: {
name: string;
description?: string;
parse_fps?: number;
}): Promise<Project> {
const response = await apiClient.post('/api/projects', payload);
const p = response.data;
@@ -92,23 +93,39 @@ export async function deleteProject(id: string): Promise<void> {
}
// Templates
function _mapTemplate(t: any): Template {
const mapping = t.mapping_rules || {};
return {
id: String(t.id),
name: t.name,
description: t.description,
classes: mapping.classes || [],
rules: mapping.rules || [],
createdAt: t.created_at,
updatedAt: t.updated_at,
};
}
export async function getTemplates(): Promise<Template[]> {
const response = await apiClient.get('/api/templates');
return response.data;
return response.data.map(_mapTemplate);
}
export async function createTemplate(payload: {
name: string;
description?: string;
color: string;
z_index: number;
classes?: { name: string; color: string; zIndex: number; category?: string }[];
rules?: any[];
}): Promise<Template> {
const response = await apiClient.post('/api/templates', payload);
return response.data;
return _mapTemplate(response.data);
}
export async function updateTemplate(id: string, payload: Partial<Template>): Promise<Template> {
const response = await apiClient.put(`/api/templates/${id}`, payload);
return response.data;
export async function updateTemplate(id: string, payload: Partial<Template> & { color?: string; z_index?: number }): Promise<Template> {
const response = await apiClient.patch(`/api/templates/${id}`, payload);
return _mapTemplate(response.data);
}
export async function deleteTemplate(id: string): Promise<void> {

View File

@@ -111,7 +111,9 @@ export interface AppState {
// Templates
templates: Template[];
activeTemplateId: string | null;
setTemplates: (templates: Template[]) => void;
setActiveTemplateId: (id: string | null) => void;
addTemplate: (template: Template) => void;
updateTemplate: (template: Template) => void;
removeTemplate: (id: string) => void;
@@ -180,7 +182,9 @@ export const useStore = create<AppState>((set) => ({
// Templates
templates: [],
activeTemplateId: null,
setTemplates: (templates: Template[]) => set({ templates }),
setActiveTemplateId: (activeTemplateId: string | null) => set({ activeTemplateId }),
addTemplate: (template: Template) =>
set((state) => ({ templates: [...state.templates, template] })),
updateTemplate: (template: Template) =>