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:
@@ -109,6 +109,59 @@ def _seed_default_project_sync() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def _seed_default_templates_sync() -> None:
|
||||
"""Seed default ontology templates on first startup."""
|
||||
from models import Template
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if db.query(Template).first() is not None:
|
||||
return
|
||||
|
||||
# Laparoscopic cholecystectomy template (35 classes)
|
||||
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),
|
||||
]
|
||||
names = [
|
||||
'针', '线', '肿瘤', '血管阻断夹', '棉球', '双极电凝',
|
||||
'肝脏', '胆囊', '分离钳', '脂肪', '止血海绵', '肝总管',
|
||||
'吸引器', '剪刀', '超声刀', '止血纱布', '胆总管', '生物夹',
|
||||
'无损伤钳', '钳夹', '喷洒', '胆囊管', '动脉', '电凝',
|
||||
'静脉', '标本袋', '引流管', '纱布', '金属钛夹', '韧带',
|
||||
'肝蒂', '推结器', '乳胶管-血管阻断', '吻合器', '术中超声',
|
||||
]
|
||||
classes = []
|
||||
for idx, (rgb, name) in enumerate(zip(colors, names)):
|
||||
color_hex = f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
|
||||
classes.append({
|
||||
"id": f"cls-lap-{idx}",
|
||||
"name": name,
|
||||
"color": color_hex,
|
||||
"zIndex": (len(names) - idx) * 10,
|
||||
"category": "腹腔镜胆囊切除术",
|
||||
})
|
||||
|
||||
template = Template(
|
||||
name="腹腔镜胆囊切除术",
|
||||
description="腹腔镜胆囊切除术(LC)手术器械与解剖结构语义分割模板,共35个分类",
|
||||
color="#06b6d4",
|
||||
z_index=0,
|
||||
mapping_rules={"classes": classes, "rules": []},
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
logger.info("Seeded default template '腹腔镜胆囊切除术' with %d classes", len(classes))
|
||||
except Exception as exc:
|
||||
logger.error("Failed to seed default templates: %s", exc)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown hooks."""
|
||||
@@ -134,6 +187,12 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
logger.warning("Redis connection failed.")
|
||||
|
||||
# Seed default templates
|
||||
try:
|
||||
asyncio.create_task(asyncio.to_thread(_seed_default_templates_sync))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Failed to start default template seeding: %s", exc)
|
||||
|
||||
# Seed default project in background thread so it doesn't block startup
|
||||
try:
|
||||
asyncio.create_task(asyncio.to_thread(_seed_default_project_sync))
|
||||
|
||||
@@ -67,6 +67,7 @@ class Template(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
color = Column(String(50), nullable=False)
|
||||
z_index = Column(Integer, default=0, nullable=False)
|
||||
mapping_rules = Column(JSON, nullable=True)
|
||||
|
||||
@@ -14,6 +14,26 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/templates", tags=["Templates"])
|
||||
|
||||
|
||||
def _pack_mapping_rules(data: dict) -> dict:
|
||||
"""Pack classes/rules into mapping_rules for DB storage."""
|
||||
mapping = data.get("mapping_rules") or {}
|
||||
if "classes" in data and data["classes"] is not None:
|
||||
mapping["classes"] = data["classes"]
|
||||
if "rules" in data and data["rules"] is not None:
|
||||
mapping["rules"] = data["rules"]
|
||||
data["mapping_rules"] = mapping
|
||||
return data
|
||||
|
||||
|
||||
def _unpack_template(template: Template) -> Template:
|
||||
"""Unpack mapping_rules into classes/rules for response."""
|
||||
mapping = template.mapping_rules or {}
|
||||
# Set as attributes so Pydantic from_attributes can pick them up
|
||||
template.classes = mapping.get("classes", [])
|
||||
template.rules = mapping.get("rules", [])
|
||||
return template
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=TemplateOut,
|
||||
@@ -22,10 +42,13 @@ router = APIRouter(prefix="/api/templates", tags=["Templates"])
|
||||
)
|
||||
def create_template(payload: TemplateCreate, db: Session = Depends(get_db)) -> Template:
|
||||
"""Create a new ontology template / segmentation class."""
|
||||
template = Template(**payload.model_dump())
|
||||
data = payload.model_dump()
|
||||
data = _pack_mapping_rules(data)
|
||||
template = Template(**data)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
_unpack_template(template)
|
||||
logger.info("Created template id=%s name=%s", template.id, template.name)
|
||||
return template
|
||||
|
||||
@@ -41,7 +64,10 @@ def list_templates(
|
||||
db: Session = Depends(get_db),
|
||||
) -> List[Template]:
|
||||
"""Retrieve all ontology templates."""
|
||||
return db.query(Template).offset(skip).limit(limit).all()
|
||||
templates = db.query(Template).offset(skip).limit(limit).all()
|
||||
for t in templates:
|
||||
_unpack_template(t)
|
||||
return templates
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -54,6 +80,7 @@ def get_template(template_id: int, db: Session = Depends(get_db)) -> Template:
|
||||
template = db.query(Template).filter(Template.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
_unpack_template(template)
|
||||
return template
|
||||
|
||||
|
||||
@@ -72,11 +99,16 @@ def update_template(
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "classes" in data or "rules" in data:
|
||||
data = _pack_mapping_rules(data)
|
||||
|
||||
for key, value in data.items():
|
||||
setattr(template, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
_unpack_template(template)
|
||||
logger.info("Updated template id=%s", template_id)
|
||||
return template
|
||||
|
||||
|
||||
@@ -73,6 +73,8 @@ class TemplateBase(BaseModel):
|
||||
color: str
|
||||
z_index: int = 0
|
||||
mapping_rules: Optional[dict[str, Any]] = None
|
||||
classes: Optional[list[dict[str, Any]]] = None
|
||||
rules: Optional[list[dict[str, Any]]] = None
|
||||
|
||||
|
||||
class TemplateCreate(TemplateBase):
|
||||
@@ -84,6 +86,8 @@ class TemplateUpdate(BaseModel):
|
||||
color: Optional[str] = None
|
||||
z_index: Optional[int] = None
|
||||
mapping_rules: Optional[dict[str, Any]] = None
|
||||
classes: Optional[list[dict[str, Any]]] = None
|
||||
rules: Optional[list[dict[str, Any]]] = None
|
||||
|
||||
|
||||
class TemplateOut(TemplateBase):
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]] 或 {'{'}"colors": [...], "names": [...]{'}'}</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
73
工程分析/实现方案-20260430_222830.md
Normal file
73
工程分析/实现方案-20260430_222830.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 实现方案 — 模板系统与工作区联动改造
|
||||
|
||||
## 后端
|
||||
|
||||
### 1. Template 模型兼容 classes/rules
|
||||
- `schemas.py`: `TemplateBase` 添加 `classes: Optional[list[dict]]`, `rules: Optional[list[dict]]`
|
||||
- `routers/templates.py`: create/update 时将 classes/rules 打包进 `mapping_rules` JSON;返回时解包
|
||||
- `main.py`: lifespan 中种子默认模板(腹腔镜胆囊切除术)
|
||||
|
||||
### 2. 预置模板数据
|
||||
腹腔镜胆囊切除术 35 个分类,RGB 颜色 + 名称数组,存入 `mapping_rules.classes`
|
||||
|
||||
## 前端
|
||||
|
||||
### 3. TemplateRegistry.tsx 修复
|
||||
- `addClass`: 使用颜色轮算法(HSL 色相均匀分布)自动生成不同颜色
|
||||
- `handleSave`: 将 `editClasses` 打包为 `{ classes: editClasses, rules: [] }` 存入 `mapping_rules`
|
||||
- z-index: 添加上下箭头按钮,点击后交换相邻项位置,自动重算 z-index
|
||||
- 批量导入: 新增"批量导入"按钮,支持粘贴 JSON 数组格式 `[colors[], names[]]`
|
||||
|
||||
### 4. OntologyInspector.tsx 联动改造
|
||||
- 从 Zustand store 读取 `templates` 和 `activeTemplateId`
|
||||
- 显示当前激活模板的 classes(真实颜色、名称、z-index)
|
||||
- 顶部添加模板选择下拉框
|
||||
- 支持"从模板添加分类"和"自定义添加分类"两种模式
|
||||
- 分类列表按 z-index 降序排列
|
||||
|
||||
### 5. useStore.ts + api.ts
|
||||
- `getTemplates` / `createTemplate` / `updateTemplate` 做 `mapping_rules` ↔ `classes/rules` 映射
|
||||
|
||||
---
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-04-30 22:38 - 23:00
|
||||
|
||||
**修改文件清单:**
|
||||
- `backend/schemas.py`: `TemplateUpdate` 添加 `classes` / `rules` 字段
|
||||
- `backend/models.py`: `Template` 添加 `description` 列
|
||||
- `backend/main.py`: 已含 `_seed_default_templates_sync()`(腹腔镜35分类模板种子)
|
||||
- `src/lib/api.ts`:
|
||||
- `_mapTemplate`: 移除未在 `Template` 接口中定义的 `color`/`z_index`,避免 TS 编译错误
|
||||
- `createTemplate`: payload 签名增加 `color`/`z_index` 必填字段
|
||||
- `updateTemplate`: 改 `PUT` 为 `PATCH`,与后端 `@router.patch` 对齐;payload 类型增加 `color`/`z_index`
|
||||
- `createProject`: 签名增加 `parse_fps?: number`
|
||||
- `src/store/useStore.ts`: 新增 `activeTemplateId` / `setActiveTemplateId`
|
||||
- `src/components/TemplateRegistry.tsx`:
|
||||
- `addClass`: 已使用 `generateColor()`(HSL 轮盘)自动分配颜色
|
||||
- `handleSave`: 补齐 `color` 和 `z_index`,满足后端 `TemplateBase` 必填要求
|
||||
- 拖拽排序: 将上下箭头改为 HTML5 Drag & Drop(`draggable` + `onDragStart/Drop/End`),拖拽时高亮目标位置,释放后自动重算 z-index
|
||||
- 批量导入: 支持 `[[colors], [names]]` 和 `{colors, names}` 两种 JSON 格式
|
||||
- 预置模板: 一键载入腹腔镜胆囊切除术 35 分类
|
||||
- `src/components/OntologyInspector.tsx`:
|
||||
- 完全重写,从 mock 数据改为读取 Zustand store 的 `templates`/`activeTemplateId`
|
||||
- 顶部模板选择下拉框,未选择时默认第一个模板
|
||||
- 显示模板 classes(颜色方块 + 名称 + z-index)
|
||||
- 支持添加项目级自定义分类(独立 state,不与模板冲突)
|
||||
- `src/components/VideoWorkspace.tsx`:
|
||||
- 新增 `getTemplates` 调用,进入工作区时若 `templates` 为空则自动加载
|
||||
- `src/components/ProjectLibrary.tsx`: 修复状态字符串比较(TS 严格类型检查报错)
|
||||
|
||||
**关键 Bug 修复:**
|
||||
1. `updateTemplate` 前端调用 `PUT` 但后端只接受 `PATCH` → 405 Method Not Allowed
|
||||
2. `createTemplate` payload 缺少 `color`/`z_index` → 后端 `TemplateBase` validation error
|
||||
3. `TemplateUpdate` schema 缺少 `classes`/`rules` → update 时分类数据丢失
|
||||
4. `Template` ORM 模型缺少 `description` 列 → seed 脚本抛 `invalid keyword argument`
|
||||
5. `_mapTemplate` 返回 `color`/`z_index` 但 `Template` TS 接口未定义 → TS2353
|
||||
|
||||
**验证结果:**
|
||||
- `npx tsc --noEmit` 0 errors
|
||||
- `npm run build` 成功(dist 生成)
|
||||
- 后端启动成功,自动 seed 腹腔镜胆囊切除术 35 分类模板
|
||||
- GET `/api/templates` 返回 35 classes,PATCH update 能正确保存/解包 classes
|
||||
25
工程分析/测试方案-20260430_222830.md
Normal file
25
工程分析/测试方案-20260430_222830.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 测试方案 — 模板系统与工作区联动改造
|
||||
|
||||
## TC1 — 模板保存
|
||||
1. 点击"新建方案" → 输入名称 → 添加3个分类
|
||||
2. 点击保存
|
||||
3. 刷新页面,模板和分类应正确显示
|
||||
|
||||
## TC2 — 颜色自动分配
|
||||
1. 连续添加5个分类
|
||||
2. 每个分类颜色应不同(非全 cyan)
|
||||
|
||||
## TC3 — z-index 调整
|
||||
1. 添加3个分类(z:10, 20, 30)
|
||||
2. 点击中间分类的上箭头
|
||||
3. 顺序应变为 20, 10, 30(z-index 自动重算)
|
||||
|
||||
## TC4 — 批量导入
|
||||
1. 点击"批量导入"
|
||||
2. 粘贴腹腔镜胆囊切除术 JSON
|
||||
3. 35个分类应一次性导入
|
||||
|
||||
## TC5 — 工作区联动
|
||||
1. 进入分割工作区
|
||||
2. 右侧语义分类树应显示当前激活模板的分类
|
||||
3. 切换模板,分类树应同步更新
|
||||
37
工程分析/经验记录.md
37
工程分析/经验记录.md
@@ -300,3 +300,40 @@ AI 助手运行的容器/环境与项目实际开发环境分离,后者才装
|
||||
---
|
||||
|
||||
> 新增经验请追加到文件末尾,保持时间倒序或正序均可,但需确保每条经验包含完整的 A/B/C/D 四段。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-30-22-38 — 模板库保存失效 + 颜色重复 + 拖拽排序 + OntologyInspector 联动
|
||||
|
||||
### A. 具体问题
|
||||
1. `TemplateRegistry.tsx` 点击保存无反应,模板数据未写入后端
|
||||
2. `addClass()` 所有新类别颜色相同(硬编码 `#06b6d4`)
|
||||
3. z-index 仅显示文本,无拖拽调整功能
|
||||
4. `OntologyInspector.tsx` 完全硬编码 mock 数据,未与模板库联动
|
||||
5. 后端 seed 脚本启动时报 `description` 是 `Template` 的无效关键字参数
|
||||
|
||||
### B. 产生原因
|
||||
1. 前端 `updateTemplate` 用 `PUT` 请求,但后端 `@router.patch` 只接受 `PATCH` → 405
|
||||
2. `createTemplate` payload 未传 `color`/`z_index`,但后端 `TemplateBase` 要求 `color` 必填
|
||||
3. `TemplateUpdate` schema 缺少 `classes`/`rules` 字段,update 时分类数据被丢弃
|
||||
4. `Template` ORM 模型缺少 `description` 列,seed 脚本直接传 `description=...` 导致 TypeError
|
||||
5. `api.ts` 的 `_mapTemplate` 返回了 `color`/`z_index`,但前端 `Template` TS 接口未定义这些字段,导致 TS 编译错误被忽略后引发运行时字段缺失
|
||||
6. `OntologyInspector` 从未接入 store 或 API,完全独立维护一份 mock 数据
|
||||
|
||||
### C. 解决方案
|
||||
1. **对齐 HTTP 方法**: `api.ts` `updateTemplate` 改为 `apiClient.patch(...)`
|
||||
2. **补齐必填字段**: `handleSave` 中构造 payload 时始终携带 `color` 和 `z_index`
|
||||
3. **扩展 Update Schema**: `backend/schemas.py` `TemplateUpdate` 添加 `classes` 和 `rules` 字段
|
||||
4. **数据库 Schema 补丁**: `ALTER TABLE templates ADD COLUMN description TEXT;` + `models.py` 添加 `description = Column(Text, nullable=True)`
|
||||
5. **TS 类型对齐**: `_mapTemplate` 只返回 `Template` 接口中定义的字段,避免隐式 any
|
||||
6. **HTML5 拖拽排序**: 用 `draggable` + `dataTransfer.setData` 实现项间拖拽,释放后重算 z-index(数组倒序 ×10)
|
||||
7. **OntologyInspector 重写**: 从 store 读取 `templates`/`activeTemplateId`,顶部加模板选择器,支持自定义分类添加
|
||||
8. **工作区预加载**: `VideoWorkspace` 进入时若 `templates` 为空自动 `getTemplates()`
|
||||
|
||||
### D. 后续如何避免问题
|
||||
1. **前后端 HTTP 方法必须严格对齐**: FastAPI `@router.patch` 只响应 PATCH,前端不能用 PUT/POST 代替,否则报 405
|
||||
2. **Schema 变更必须双向同步**: 后端 `BaseModel` 添加字段后,前端 payload 类型、ORM 模型、数据库表必须同时更新
|
||||
3. **TS 接口必须与 API 响应严格一致**: `_mapTemplate` 返回的字段如果不在 `Template` 接口中,必须通过 `&` 扩展接口或过滤字段,避免运行时丢失数据
|
||||
4. **Mock 数据必须尽早替换为真实数据流**: 任何硬编码的 mock 数据在联调阶段都是技术债,应在功能开发初期就接入 store/API
|
||||
5. **seed 脚本必须与 ORM 模型严格一致**: 新增模型字段后,seed 逻辑也要同步更新,否则启动即报错
|
||||
|
||||
21
工程分析/需求分析-20260430_222830.md
Normal file
21
工程分析/需求分析-20260430_222830.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 需求分析 — 模板系统与工作区联动改造
|
||||
|
||||
## 需求背景
|
||||
用户提出模板库和工作区联动的四个问题:
|
||||
|
||||
## 需求拆解
|
||||
| 编号 | 需求 | 优先级 |
|
||||
|------|------|--------|
|
||||
| R1 | 模板库点击"保存"无反应 | P0 |
|
||||
| R2 | 模板库添加类别颜色都是同一个(cyan),需要颜色轮自动分配 | P0 |
|
||||
| R3 | z-index 支持拖拽/上下调整顺序 | P0 |
|
||||
| R4 | 模板支持批量导入模式(颜色+名称数组) | P0 |
|
||||
| R5 | 预置腹腔镜胆囊切除术模板(35个分类) | P0 |
|
||||
| R6 | 工作区右侧语义分类与模板库联动 | P0 |
|
||||
| R7 | 每个项目可以从模板选取分类,也可以自定义添加 | P0 |
|
||||
|
||||
## 根因分析
|
||||
- 后端 `Template` schema 没有 `classes` 字段,前端传的 `classes` 被 Pydantic 忽略
|
||||
- `addClass` 硬编码颜色 `#06b6d4`
|
||||
- z-index 仅支持数字输入,无拖拽/排序交互
|
||||
- `OntologyInspector` 使用硬编码 mock 数据,与模板库完全隔离
|
||||
Reference in New Issue
Block a user