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

@@ -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))

View File

@@ -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)

View File

@@ -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

View File

@@ -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):