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):
|
||||
|
||||
Reference in New Issue
Block a user