Files
cmbot/src/services/template_service.py
T
adminandClaude Opus 4.8 8a34fe93ff feat: thumbnail grid, aspect-aware templates, and UI hierarchy polish
Material list:
- Switch asset list from one-per-row to a wrapping thumbnail grid
  (IconMode cards: thumbnail + corner checkbox + elided name, subfolder
  moved to tooltip).

Templates:
- Template.to_transform_state now contains-fits the print inside the
  target box by the print's native aspect ratio (no stretch); falls back
  to the box when print size is unknown.
- Feed print native size into template_panel for aspect-aware fitting.
- Shrink built-in template boxes to realistic print proportions.

UI hierarchy:
- Promote 打开文件夹 buttons to primary (filled accent).
- Add inline 归零 (reset angle) and a 重置位置/尺寸/角度 button to the
  transform panel; wire it to re-apply the selected template.
- Hide 保存 for built-in templates instead of leaving it greyed.

Docs: record template box/contain behavior (PRD) and the grid layout,
button hierarchy, and reset/save-visibility rules (UI design).
Tests: add 3 aspect-fit cases for to_transform_state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:11:56 +08:00

192 lines
6.1 KiB
Python

import json
import logging
from typing import List, Optional
from core.models import Template
logger = logging.getLogger(__name__)
_TEMPLATES_FILENAME = "templates.json"
# ---------------------------------------------------------------------------
# 内置模板(代码定义,不可删除/修改)
# ---------------------------------------------------------------------------
BUILTIN_TEMPLATES: List[Template] = [
Template(
name="正方形模板",
x_ratio=0.34, y_ratio=0.22,
width_ratio=0.32, height_ratio=0.32,
rotation=0.0, type="builtin",
),
Template(
name="纵向长方形模板",
x_ratio=0.35, y_ratio=0.16,
width_ratio=0.30, height_ratio=0.46,
rotation=0.0, type="builtin",
),
Template(
name="横向长方形模板",
x_ratio=0.27, y_ratio=0.26,
width_ratio=0.46, height_ratio=0.30,
rotation=0.0, type="builtin",
),
Template(
name="左胸小号模板",
x_ratio=0.50, y_ratio=0.22,
width_ratio=0.16, height_ratio=0.16,
rotation=0.0, type="builtin",
),
]
# ---------------------------------------------------------------------------
# 内部辅助
# ---------------------------------------------------------------------------
def _templates_file():
from services.file_service import get_config_path
return get_config_path(_TEMPLATES_FILENAME)
def _validate_entry(data) -> Optional[Template]:
"""Parse one raw dict into a Template; return None and log on any error."""
try:
name = str(data["name"]).strip()
if not name:
raise ValueError("name is empty")
w = float(data["width_ratio"])
h = float(data["height_ratio"])
if w <= 0 or h <= 0:
raise ValueError("width_ratio and height_ratio must be > 0")
return Template(
name=name,
x_ratio=float(data.get("x_ratio", 0.0)),
y_ratio=float(data.get("y_ratio", 0.0)),
width_ratio=w,
height_ratio=h,
rotation=float(data.get("rotation", 0.0)),
type="custom",
)
except (KeyError, ValueError, TypeError) as exc:
logger.warning("Skipping invalid template entry (%s): %s", exc, data)
return None
def _save_custom_templates(templates: List[Template]):
"""Write custom templates to JSON. Logs error on failure, does not raise."""
path = _templates_file()
try:
path.parent.mkdir(exist_ok=True)
payload = {
"templates": [
{
"name": t.name,
"x_ratio": t.x_ratio,
"y_ratio": t.y_ratio,
"width_ratio": t.width_ratio,
"height_ratio": t.height_ratio,
"rotation": t.rotation,
}
for t in templates
]
}
with open(str(path), "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
logger.info("Saved %d custom template(s) to %s", len(templates), path)
except OSError as exc:
logger.error("Failed to save templates: %s", exc)
# ---------------------------------------------------------------------------
# 公开 API
# ---------------------------------------------------------------------------
def get_builtin_templates() -> List[Template]:
"""Return a copy of the built-in template list."""
return list(BUILTIN_TEMPLATES)
def load_custom_templates() -> List[Template]:
"""Load user-defined templates from JSON.
Returns [] on missing file. Skips corrupt individual entries so a
single bad record never blocks the rest.
"""
path = _templates_file()
if not path.exists():
return []
try:
with open(str(path), encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as exc:
logger.warning("Cannot read templates.json (%s), ignoring.", exc)
return []
if not isinstance(data, dict) or "templates" not in data:
logger.warning("templates.json has unexpected structure, ignoring.")
return []
results = []
for entry in data["templates"]:
t = _validate_entry(entry)
if t is not None:
results.append(t)
logger.info("Loaded %d custom template(s) from %s", len(results), path)
return results
def get_all_templates() -> List[Template]:
"""Return builtin templates followed by custom templates."""
return get_builtin_templates() + load_custom_templates()
def add_template(template: Template):
"""Add or overwrite a custom template, then persist.
If a custom template with the same name already exists it is replaced.
Built-in templates cannot be added via this function.
"""
template.type = "custom"
customs = load_custom_templates()
customs = [t for t in customs if t.name != template.name]
customs.append(template)
_save_custom_templates(customs)
logger.info("Template added: %s", template.name)
def rename_template(old_name: str, new_name: str):
"""Rename a custom template and persist.
Raises ValueError if *old_name* is not found or *new_name* is empty.
"""
new_name = new_name.strip()
if not new_name:
raise ValueError("New template name cannot be empty")
customs = load_custom_templates()
found = False
for t in customs:
if t.name == old_name:
t.name = new_name
found = True
break
if not found:
raise ValueError("Custom template not found: {}".format(old_name))
_save_custom_templates(customs)
logger.info("Template renamed: %s -> %s", old_name, new_name)
def delete_template(name: str):
"""Delete a custom template by name and persist.
Raises ValueError if *name* is not found among custom templates.
"""
customs = load_custom_templates()
filtered = [t for t in customs if t.name != name]
if len(filtered) == len(customs):
raise ValueError("Custom template not found: {}".format(name))
_save_custom_templates(filtered)
logger.info("Template deleted: %s", name)