root 1 місяць тому
коміт
92c76ec565

+ 61 - 0
README.md

@@ -0,0 +1,61 @@
+## 洗衣机语音播报模板可视化运营系统
+
+一套基于 FastAPI + Vue 的端到端运营系统,支持:
+
+- 持久化存储播报模板元数据与 Jinja2 模板正文;
+- 在 WebUI 中分组管理模板、配置输入变量、写模板正文、实时预览;
+- 通过统一接口渲染模板,后端只需传入参数即可获取播报语;
+- 提供调用方式、变量说明,方便运营/研发协同。
+
+### 目录结构
+
+```
+backend/
+  app/                FastAPI 应用(模板 API、渲染服务)
+  data/templates.json 默认模板库
+  requirements.txt    Python 依赖
+frontend/             Vue 3 + Vite WebUI
+main.py               uvicorn 入口(指向 backend.app.main:app)
+```
+
+### 后端启动
+
+```bash
+cd backend
+python -m venv .venv
+source .venv/bin/activate
+pip install -r requirements.txt
+uvicorn main:app --reload  # 或 uvicorn backend.app.main:app --reload
+```
+
+接口示例:
+
+- `GET /api/templates`:获取模板列表;
+- `POST /api/templates`:创建模板;
+- `POST /api/templates/render`:根据 template_id + 参数生成播报;
+- `POST /api/templates/preview`:任意 Jinja 正文快速预览。
+
+### 前端启动
+
+当前 Vite 版本需要 **Node.js ≥ 20.19**。在 Node 18 环境下 `npm run build` 会失败(`crypto.hash` 报错),请升级 Node 后再执行构建或预览。
+
+```bash
+cd frontend
+npm install
+npm run dev   # 默认 http://localhost:5173,已允许跨域访问 FastAPI
+npm run build # 需要 Node >=20.19
+```
+
+### 典型前后端协作流程
+
+1. 运营在 WebUI 中创建/编辑模板,配置变量与默认值;
+2. 点击“本地预览”即时查看模板渲染效果;
+3. 保存模板后,后端/IoT 只需调用 `POST /api/templates/render` 并传入参数列表即可生成播报语;
+4. 若需定制调用逻辑,可在“调用方式”字段中为研发团队写下示例。
+
+### 后续可扩展方向
+
+- 接入数据库或对象存储替代 JSON;
+- 为模板引入版本管理、审批流;
+- 增加语音合成调用,直接输出音频;
+- 增加权限体系,为不同运营账号分配模板范围。

BIN
__pycache__/main.cpython-311.pyc


+ 0 - 0
backend/app/__init__.py


BIN
backend/app/__pycache__/__init__.cpython-311.pyc


BIN
backend/app/__pycache__/main.cpython-311.pyc


BIN
backend/app/__pycache__/schemas.cpython-311.pyc


+ 75 - 0
backend/app/main.py

@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from .schemas import (
+    TemplateCreate,
+    TemplatePreviewRequest,
+    TemplateRenderRequest,
+    TemplateRenderResponse,
+    TemplateUpdate,
+)
+from .services.renderer import renderer
+from .services.template_store import TemplateStore, load_store
+
+
+def create_app() -> FastAPI:
+    base_dir = Path(__file__).resolve().parents[1]
+    store: TemplateStore = load_store(base_dir)
+
+    app = FastAPI(title="洗衣机语音播报模板运营系统", version="1.0.0")
+    app.add_middleware(
+        CORSMiddleware,
+        allow_origins=["*"],
+        allow_methods=["*"],
+        allow_headers=["*"],
+    )
+
+    @app.get("/api/health")
+    async def health():
+        return {"status": "ok"}
+
+    @app.get("/api/templates")
+    async def list_templates():
+        return store.list_templates()
+
+    @app.get("/api/templates/categories")
+    async def list_categories():
+        return {"categories": store.list_categories()}
+
+    @app.get("/api/templates/{template_id}")
+    async def get_template(template_id: str):
+        return store.get_template(template_id)
+
+    @app.post("/api/templates", status_code=201)
+    async def create_template(payload: TemplateCreate):
+        return store.create_template(payload)
+
+    @app.put("/api/templates/{template_id}")
+    async def update_template(template_id: str, payload: TemplateUpdate):
+        return store.update_template(template_id, payload)
+
+    @app.delete("/api/templates/{template_id}", status_code=204)
+    async def delete_template(template_id: str):
+        store.delete_template(template_id)
+        return {"status": "deleted"}
+
+    @app.post("/api/templates/render", response_model=TemplateRenderResponse)
+    async def render_template(payload: TemplateRenderRequest):
+        record = store.get_template(payload.template_id)
+        rendered = renderer.render_from_record(record, payload.parameters)
+        return {"rendered_text": rendered}
+
+    @app.post("/api/templates/preview", response_model=TemplateRenderResponse)
+    async def preview_template(payload: TemplatePreviewRequest):
+        rendered = renderer.preview(payload)
+        return {"rendered_text": rendered}
+
+    return app
+
+
+app = create_app()
+

+ 71 - 0
backend/app/schemas.py

@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import List, Optional
+from uuid import uuid4
+
+from pydantic import BaseModel, Field
+
+
+def generate_id() -> str:
+    """Generate deterministic-looking ids for templates and categories."""
+    return uuid4().hex
+
+
+class VariableDefinition(BaseModel):
+    """Schema describing each dynamic input value for a template."""
+
+    name: str = Field(..., description="变量唯一名称,用于在模板中引用")
+    label: str = Field(..., description="变量可视化展示名称")
+    description: Optional[str] = Field(None, description="变量说明提示")
+    data_type: str = Field(
+        "string",
+        description="变量类型, 例如 string/number/boolean/options",
+    )
+    required: bool = Field(False, description="是否为必填字段")
+    default: Optional[str] = Field(None, description="默认值")
+
+
+class TemplateBase(BaseModel):
+    name: str
+    category: str
+    description: Optional[str] = None
+    output_description: Optional[str] = None
+    call_method: Optional[str] = None
+    input_variables: List[VariableDefinition] = Field(default_factory=list)
+    template_body: str = Field(..., description="Jinja2 模板正文")
+
+
+class TemplateCreate(TemplateBase):
+    pass
+
+
+class TemplateUpdate(BaseModel):
+    name: Optional[str] = None
+    category: Optional[str] = None
+    description: Optional[str] = None
+    output_description: Optional[str] = None
+    call_method: Optional[str] = None
+    input_variables: Optional[List[VariableDefinition]] = None
+    template_body: Optional[str] = None
+
+
+class TemplateRecord(TemplateBase):
+    id: str = Field(default_factory=generate_id)
+    created_at: datetime = Field(default_factory=datetime.utcnow)
+    updated_at: datetime = Field(default_factory=datetime.utcnow)
+
+
+class TemplateRenderRequest(BaseModel):
+    template_id: str
+    parameters: dict
+
+
+class TemplatePreviewRequest(BaseModel):
+    template_body: str
+    parameters: dict = Field(default_factory=dict)
+
+
+class TemplateRenderResponse(BaseModel):
+    rendered_text: str
+

BIN
backend/app/services/__pycache__/renderer.cpython-311.pyc


BIN
backend/app/services/__pycache__/template_store.cpython-311.pyc


+ 97 - 0
backend/app/services/renderer.py

@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import re
+from typing import Dict, Iterable, Optional
+
+from jinja2 import BaseLoader, Environment, StrictUndefined
+from jinja2.exceptions import TemplateError
+
+from fastapi import HTTPException
+
+from ..schemas import (
+    TemplatePreviewRequest,
+    TemplateRecord,
+    TemplateRenderRequest,
+    VariableDefinition,
+)
+
+
+class TemplateRenderer:
+    """Render templates with strict undefined variables to catch errors early."""
+
+    def __init__(self) -> None:
+        self.env = Environment(
+            loader=BaseLoader(),
+            undefined=StrictUndefined,
+            trim_blocks=True,
+            lstrip_blocks=True,
+            autoescape=False,
+        )
+
+    def render_from_record(self, record: TemplateRecord, parameters: dict) -> str:
+        normalized = self._normalize_parameters(parameters, record.input_variables)
+        return self._render(record.template_body, normalized)
+
+    def preview(self, payload: TemplatePreviewRequest) -> str:
+        normalized = self._normalize_parameters(payload.parameters)
+        return self._render(payload.template_body, normalized)
+
+    def _render(self, template_body: str, parameters: dict) -> str:
+        try:
+            template = self.env.from_string(template_body)
+            rendered = template.render(**parameters)
+            paragraphs = [p.strip() for p in rendered.split("\n\n") if p.strip()]
+            return "\n\n".join(paragraphs)
+        except TemplateError as exc:
+            raise HTTPException(status_code=400, detail=f"模板渲染出错: {exc}") from exc
+
+    def _normalize_parameters(
+        self,
+        parameters: Dict[str, object],
+        definitions: Optional[Iterable[VariableDefinition]] = None,
+    ) -> Dict[str, object]:
+        type_map = {}
+        for definition in definitions or []:
+            # Accept both pydantic models and plain dicts from persisted records.
+            if isinstance(definition, VariableDefinition):
+                name = (definition.name or "").strip()
+                data_type = (definition.data_type or "").lower()
+            elif isinstance(definition, dict):
+                name = (definition.get("name") or "").strip()
+                data_type = (definition.get("data_type") or "").lower()
+            else:
+                continue
+            if name:
+                type_map[name] = data_type
+        return {
+            key: self._convert_value(value, type_map.get(key))
+            for key, value in parameters.items()
+        }
+
+    def _convert_value(self, value: object, data_type: Optional[str]) -> object:
+        if value is None or isinstance(value, (int, float, bool)):
+            return value
+        if isinstance(value, str):
+            stripped = value.strip()
+            if stripped == "":
+                return None
+            if (data_type or "").startswith("bool"):
+                lowered = stripped.lower()
+                if lowered in {"true", "1", "yes", "y", "on"}:
+                    return True
+                if lowered in {"false", "0", "no", "n", "off"}:
+                    return False
+            if (data_type or "") in {"number", "integer"} or self._looks_numeric(
+                stripped
+            ):
+                try:
+                    return int(stripped) if stripped.isdigit() else float(stripped)
+                except ValueError:
+                    return value
+        return value
+
+    def _looks_numeric(self, value: str) -> bool:
+        return bool(re.fullmatch(r"[+-]?\d+(\.\d+)?", value))
+
+
+renderer = TemplateRenderer()

+ 73 - 0
backend/app/services/template_store.py

@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Dict, List, Optional
+
+from fastapi import HTTPException
+
+from ..schemas import TemplateCreate, TemplateRecord, TemplateUpdate
+
+
+class TemplateStore:
+    """Simple JSON-file based store for template definitions."""
+
+    def __init__(self, data_path: Path):
+        self.data_path = data_path
+        self.data_path.parent.mkdir(parents=True, exist_ok=True)
+        if not self.data_path.exists():
+            self.data_path.write_text(json.dumps([], ensure_ascii=False, indent=2))
+        self._cache: Dict[str, TemplateRecord] = {}
+        self._load()
+
+    def _load(self) -> None:
+        raw = json.loads(self.data_path.read_text())
+        self._cache = {item["id"]: TemplateRecord(**item) for item in raw}
+
+    def _persist(self) -> None:
+        # Use JSON mode so datetime fields are converted to ISO strings.
+        serializable = [record.model_dump(mode="json") for record in self._cache.values()]
+        self.data_path.write_text(
+            json.dumps(serializable, ensure_ascii=False, indent=2)
+        )
+
+    def list_templates(self) -> List[TemplateRecord]:
+        return list(self._cache.values())
+
+    def get_template(self, template_id: str) -> TemplateRecord:
+        try:
+            return self._cache[template_id]
+        except KeyError as exc:
+            raise HTTPException(status_code=404, detail="模板不存在") from exc
+
+    def create_template(self, payload: TemplateCreate) -> TemplateRecord:
+        record = TemplateRecord(**payload.model_dump())
+        self._cache[record.id] = record
+        self._persist()
+        return record
+
+    def update_template(self, template_id: str, payload: TemplateUpdate) -> TemplateRecord:
+        record = self.get_template(template_id)
+        update_data = payload.model_dump(exclude_none=True)
+        updated = record.model_copy(update=update_data)
+        from datetime import datetime
+
+        updated.updated_at = datetime.utcnow()
+        self._cache[template_id] = updated
+        self._persist()
+        return updated
+
+    def delete_template(self, template_id: str) -> None:
+        if template_id not in self._cache:
+            raise HTTPException(status_code=404, detail="模板不存在")
+        del self._cache[template_id]
+        self._persist()
+
+    def list_categories(self) -> List[str]:
+        categories = {record.category for record in self._cache.values()}
+        return sorted(categories)
+
+
+def load_store(base_dir: Path) -> TemplateStore:
+    data_path = base_dir / "data" / "templates.json"
+    return TemplateStore(data_path)

Різницю між файлами не показано, бо вона завелика
+ 81 - 0
backend/data/templates.json


+ 5 - 0
backend/requirements.txt

@@ -0,0 +1,5 @@
+fastapi==0.115.5
+uvicorn[standard]==0.32.0
+jinja2==3.1.4
+pydantic==2.9.2
+python-multipart==0.0.10

+ 24 - 0
frontend/.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 3 - 0
frontend/.vscode/extensions.json

@@ -0,0 +1,3 @@
+{
+  "recommendations": ["Vue.volar"]
+}

+ 5 - 0
frontend/README.md

@@ -0,0 +1,5 @@
+# Vue 3 + Vite
+
+This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
+
+Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

+ 13 - 0
frontend/index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>frontend</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 1208 - 0
frontend/package-lock.json

@@ -0,0 +1,1208 @@
+{
+  "name": "frontend",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "frontend",
+      "version": "0.0.0",
+      "dependencies": {
+        "vue": "^3.5.24"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^6.0.1",
+        "vite": "^7.2.4"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
+      "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
+      "dependencies": {
+        "@babel/types": "^7.28.5"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
+      "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
+      "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
+      "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
+      "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
+      "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
+      "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
+      "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
+      "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
+      "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
+      "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
+      "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
+      "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
+      "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
+      "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
+      "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
+      "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
+      "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
+      "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
+      "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
+      "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
+      "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
+      "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
+      "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
+      "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
+      "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-beta.53",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
+      "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
+      "dev": true
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
+      "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
+      "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
+      "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
+      "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
+      "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
+      "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
+      "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
+      "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
+      "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
+      "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
+      "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
+      "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
+      "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
+      "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
+      "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
+      "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
+      "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
+      "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
+      "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
+      "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
+      "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
+      "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
+      "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==",
+      "dev": true,
+      "dependencies": {
+        "@rolldown/pluginutils": "1.0.0-beta.53"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
+      "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "@vue/shared": "3.5.26",
+        "entities": "^7.0.0",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
+      "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.26",
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
+      "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "@vue/compiler-core": "3.5.26",
+        "@vue/compiler-dom": "3.5.26",
+        "@vue/compiler-ssr": "3.5.26",
+        "@vue/shared": "3.5.26",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.6",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
+      "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.26",
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
+      "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
+      "dependencies": {
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
+      "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
+      "dependencies": {
+        "@vue/reactivity": "3.5.26",
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
+      "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
+      "dependencies": {
+        "@vue/reactivity": "3.5.26",
+        "@vue/runtime-core": "3.5.26",
+        "@vue/shared": "3.5.26",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
+      "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.26",
+        "@vue/shared": "3.5.26"
+      },
+      "peerDependencies": {
+        "vue": "3.5.26"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
+      "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A=="
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
+    },
+    "node_modules/entities": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
+      "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.27.2",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
+      "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.27.2",
+        "@esbuild/android-arm": "0.27.2",
+        "@esbuild/android-arm64": "0.27.2",
+        "@esbuild/android-x64": "0.27.2",
+        "@esbuild/darwin-arm64": "0.27.2",
+        "@esbuild/darwin-x64": "0.27.2",
+        "@esbuild/freebsd-arm64": "0.27.2",
+        "@esbuild/freebsd-x64": "0.27.2",
+        "@esbuild/linux-arm": "0.27.2",
+        "@esbuild/linux-arm64": "0.27.2",
+        "@esbuild/linux-ia32": "0.27.2",
+        "@esbuild/linux-loong64": "0.27.2",
+        "@esbuild/linux-mips64el": "0.27.2",
+        "@esbuild/linux-ppc64": "0.27.2",
+        "@esbuild/linux-riscv64": "0.27.2",
+        "@esbuild/linux-s390x": "0.27.2",
+        "@esbuild/linux-x64": "0.27.2",
+        "@esbuild/netbsd-arm64": "0.27.2",
+        "@esbuild/netbsd-x64": "0.27.2",
+        "@esbuild/openbsd-arm64": "0.27.2",
+        "@esbuild/openbsd-x64": "0.27.2",
+        "@esbuild/openharmony-arm64": "0.27.2",
+        "@esbuild/sunos-x64": "0.27.2",
+        "@esbuild/win32-arm64": "0.27.2",
+        "@esbuild/win32-ia32": "0.27.2",
+        "@esbuild/win32-x64": "0.27.2"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
+      "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
+      "dev": true,
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.54.0",
+        "@rollup/rollup-android-arm64": "4.54.0",
+        "@rollup/rollup-darwin-arm64": "4.54.0",
+        "@rollup/rollup-darwin-x64": "4.54.0",
+        "@rollup/rollup-freebsd-arm64": "4.54.0",
+        "@rollup/rollup-freebsd-x64": "4.54.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.54.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.54.0",
+        "@rollup/rollup-linux-arm64-musl": "4.54.0",
+        "@rollup/rollup-linux-loong64-gnu": "4.54.0",
+        "@rollup/rollup-linux-ppc64-gnu": "4.54.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.54.0",
+        "@rollup/rollup-linux-riscv64-musl": "4.54.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.54.0",
+        "@rollup/rollup-linux-x64-gnu": "4.54.0",
+        "@rollup/rollup-linux-x64-musl": "4.54.0",
+        "@rollup/rollup-openharmony-arm64": "4.54.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.54.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.54.0",
+        "@rollup/rollup-win32-x64-gnu": "4.54.0",
+        "@rollup/rollup-win32-x64-msvc": "4.54.0",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.15",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+      "dev": true,
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/vite": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
+      "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
+      "dev": true,
+      "dependencies": {
+        "esbuild": "^0.27.0",
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3",
+        "postcss": "^8.5.6",
+        "rollup": "^4.43.0",
+        "tinyglobby": "^0.2.15"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "lightningcss": "^1.21.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
+      "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.26",
+        "@vue/compiler-sfc": "3.5.26",
+        "@vue/runtime-dom": "3.5.26",
+        "@vue/server-renderer": "3.5.26",
+        "@vue/shared": "3.5.26"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 18 - 0
frontend/package.json

@@ -0,0 +1,18 @@
+{
+  "name": "frontend",
+  "private": true,
+  "version": "0.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "vue": "^3.5.24"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^6.0.1",
+    "vite": "^7.2.4"
+  }
+}

+ 1 - 0
frontend/public/vite.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

+ 672 - 0
frontend/src/App.vue

@@ -0,0 +1,672 @@
+<script setup>
+import { computed, onMounted, reactive, ref, watch } from 'vue'
+import { TemplateAPI } from './services/api'
+
+const templates = ref([])
+const categories = ref([])
+const selectedTemplateId = ref(null)
+const previewResult = ref('')
+const toast = reactive({ type: '', message: '' })
+const loading = ref(false)
+
+const defaultEditor = () => ({
+  name: '',
+  category: '',
+  description: '',
+  output_description: '',
+  call_method: '',
+  template_body: '',
+  input_variables: []
+})
+
+const editor = reactive(defaultEditor())
+const parameterValues = reactive({})
+
+const groupedTemplates = computed(() => {
+  const map = {}
+  templates.value.forEach((tpl) => {
+    map[tpl.category] = map[tpl.category] ?? []
+    map[tpl.category].push(tpl)
+  })
+  return map
+})
+
+const mode = computed(() => (selectedTemplateId.value ? 'update' : 'create'))
+
+const currentTemplate = computed(() =>
+  templates.value.find((tpl) => tpl.id === selectedTemplateId.value)
+)
+
+const callMethodSnippet = computed(() => {
+  const templateId = selectedTemplateId.value || '<template-id>'
+  const parameters = editor.input_variables.reduce((acc, variable, index) => {
+    const key = (variable.name || `variable_${index + 1}`).trim()
+    const valueKey = variable.name?.trim()
+    const previewValue =
+      (valueKey ? parameterValues[valueKey] : undefined) ?? variable.default ?? ''
+    acc[key] = previewValue
+    return acc
+  }, {})
+  const body = JSON.stringify(
+    {
+      template_id: templateId,
+      parameters
+    },
+    null,
+    2
+  )
+  return ['POST /api/templates/render', 'Content-Type: application/json', '', body].join('\n')
+})
+
+function setToast(message, type = 'success', timeout = 2600) {
+  toast.type = type
+  toast.message = message
+  if (!message) return
+  setTimeout(() => {
+    toast.message = ''
+  }, timeout)
+}
+
+async function loadTemplates() {
+  loading.value = true
+  try {
+    const [tplList, { categories: categoryList }] = await Promise.all([
+      TemplateAPI.listTemplates(),
+      TemplateAPI.listCategories()
+    ])
+    templates.value = tplList
+    categories.value = categoryList
+    if (!selectedTemplateId.value && tplList.length) {
+      selectTemplate(tplList[0])
+    }
+  } catch (err) {
+    setToast(err.message ?? '加载失败', 'error', 4000)
+  } finally {
+    loading.value = false
+  }
+}
+
+function resetEditor() {
+  Object.assign(editor, defaultEditor())
+  Object.keys(parameterValues).forEach((key) => delete parameterValues[key])
+  previewResult.value = ''
+}
+
+function hydrateEditor(template) {
+  Object.assign(editor, JSON.parse(JSON.stringify(template)))
+  template.input_variables?.forEach((variable) => {
+    parameterValues[variable.name] =
+      parameterValues[variable.name] ?? variable.default ?? ''
+  })
+}
+
+function selectTemplate(template) {
+  selectedTemplateId.value = template.id
+  resetEditor()
+  hydrateEditor(template)
+  previewResult.value = ''
+}
+
+function startCreate() {
+  selectedTemplateId.value = null
+  resetEditor()
+}
+
+function addVariable() {
+  editor.input_variables.push({
+    name: '',
+    label: '',
+    description: '',
+    data_type: 'string',
+    required: false,
+    default: ''
+  })
+}
+
+function removeVariable(index) {
+  const [removed] = editor.input_variables.splice(index, 1)
+  if (removed?.name) {
+    delete parameterValues[removed.name]
+  }
+}
+
+async function saveTemplate() {
+  try {
+    const payload = JSON.parse(JSON.stringify(editor))
+    payload.call_method = callMethodSnippet.value
+    if (mode.value === 'update') {
+      await TemplateAPI.updateTemplate(selectedTemplateId.value, payload)
+      setToast('模板已更新')
+    } else {
+      const created = await TemplateAPI.createTemplate(payload)
+      selectedTemplateId.value = created.id
+      setToast('模板已创建')
+    }
+    await loadTemplates()
+  } catch (err) {
+    setToast(err.message ?? '保存失败', 'error')
+  }
+}
+
+async function removeTemplate() {
+  if (!selectedTemplateId.value) return
+  if (!confirm('确定要删除当前模板吗?')) return
+  try {
+    await TemplateAPI.deleteTemplate(selectedTemplateId.value)
+    setToast('模板已删除', 'success')
+    startCreate()
+    await loadTemplates()
+  } catch (err) {
+    setToast(err.message ?? '删除失败', 'error')
+  }
+}
+
+async function previewTemplate() {
+  if (!editor.template_body) {
+    setToast('请先填写模板正文', 'error')
+    return
+  }
+  try {
+    const result = await TemplateAPI.previewTemplate({
+      template_body: editor.template_body,
+      parameters: parameterValues
+    })
+    previewResult.value = result.rendered_text
+  } catch (err) {
+    setToast(err.message ?? '预览失败', 'error', 4000)
+  }
+}
+
+async function renderTemplate() {
+  if (!selectedTemplateId.value) {
+    setToast('请先保存模板', 'error')
+    return
+  }
+  try {
+    const result = await TemplateAPI.renderTemplate({
+      template_id: selectedTemplateId.value,
+      parameters: parameterValues
+    })
+    previewResult.value = result.rendered_text
+    setToast('已根据后端模板生成', 'success')
+  } catch (err) {
+    setToast(err.message ?? '生成失败', 'error', 4000)
+  }
+}
+
+onMounted(() => {
+  loadTemplates()
+  startCreate()
+})
+
+watch(
+  () => editor.input_variables.map((variable) => variable.name),
+  (names) => {
+    Object.keys(parameterValues).forEach((param) => {
+      if (!names.includes(param)) {
+        delete parameterValues[param]
+      }
+    })
+  },
+  { deep: true }
+)
+
+</script>
+
+<template>
+  <div class="page">
+    <header class="page-header">
+      <div>
+        <p class="eyebrow">洗衣机语音播报模板运营系统</p>
+        <h1>自定义模板 · 分类管理 · 一键生成播报</h1>
+        <p class="subtitle">
+          在可视化界面配置 Jinja 模板,实时预览变量效果,并把调用方法、输入输出标准化给运营或研发使用。
+        </p>
+      </div>
+      <div class="header-actions">
+        <button @click="startCreate">+ 新建模板</button>
+      </div>
+    </header>
+
+    <section v-if="toast.message" class="toast" :class="toast.type">
+      {{ toast.message }}
+    </section>
+
+    <div class="layout">
+      <aside class="sidebar" :class="{ loading: loading }">
+        <h3>模板清单</h3>
+        <p class="muted">按分类浏览已有模板,点击可切换编辑。</p>
+        <div v-if="!Object.keys(groupedTemplates).length" class="empty-state">
+          暂无模板,点击右上角「新建模板」开始配置。
+        </div>
+        <div v-else class="category-list">
+          <div v-for="(items, category) in groupedTemplates" :key="category" class="category-block">
+            <div class="category-title">
+              <span>{{ category }}</span>
+              <span class="tag">{{ items.length }}</span>
+            </div>
+            <div class="template-list">
+              <button
+                v-for="item in items"
+                :key="item.id"
+                class="template-item"
+                :class="{ active: selectedTemplateId === item.id }"
+                @click="selectTemplate(item)"
+              >
+                <div>
+                  <strong>{{ item.name }}</strong>
+                  <p>{{ item.description }}</p>
+                </div>
+              </button>
+            </div>
+          </div>
+        </div>
+      </aside>
+
+      <main class="content">
+        <section class="card">
+          <div class="card-header">
+            <div>
+              <p class="eyebrow">模板基础信息</p>
+              <h2>定义模板 & 调用说明</h2>
+            </div>
+            <div class="gap-8">
+              <button class="ghost" v-if="selectedTemplateId" @click="removeTemplate">删除模板</button>
+              <button @click="saveTemplate">{{ mode === 'update' ? '保存修改' : '创建模板' }}</button>
+            </div>
+          </div>
+
+          <div class="form-grid">
+            <label>
+              模板名称
+              <input v-model="editor.name" placeholder="如:智能推荐播报" />
+            </label>
+            <label>
+              分类
+              <input v-model="editor.category" placeholder="如:洗衣机推荐" />
+            </label>
+            <label>
+              调用方式
+              <p class="muted">系统已根据模板 ID 和输入变量生成示例,无需手动填写。</p>
+              <pre class="call-snippet">{{ callMethodSnippet }}</pre>
+            </label>
+            <label>
+              模板说明
+              <textarea v-model="editor.description" placeholder="描述模板的使用场景、亮点" rows="3" />
+            </label>
+            <label>
+              输出说明
+              <textarea v-model="editor.output_description" placeholder="向运营/研发说明生成结果结构、段落" rows="3" />
+            </label>
+          </div>
+        </section>
+
+        <section class="card">
+          <div class="card-header">
+            <div>
+              <p class="eyebrow">输入变量</p>
+              <h2>定义前端可视化填写项</h2>
+            </div>
+            <button class="ghost" @click="addVariable">+ 新增变量</button>
+          </div>
+
+          <div v-if="!editor.input_variables.length" class="empty-state">
+            还没有变量,点击「新增变量」来定义前端可配置项。
+          </div>
+          <div v-else class="variable-grid">
+            <div v-for="(variable, index) in editor.input_variables" :key="index" class="variable-item">
+              <div class="variable-header">
+                <strong>{{ variable.label || '未命名变量' }}</strong>
+                <button class="ghost" @click="removeVariable(index)">移除</button>
+              </div>
+              <div class="variable-fields">
+                <label>
+                  变量名
+                  <input v-model="variable.name" placeholder="在模板中引用的变量名" />
+                </label>
+                <label>
+                  展示名称
+                  <input v-model="variable.label" placeholder="用于前端输入表单的名称" />
+                </label>
+                <label>
+                  类型
+                  <select v-model="variable.data_type">
+                    <option value="string">文本</option>
+                    <option value="number">数字</option>
+                    <option value="boolean">布尔</option>
+                    <option value="options">枚举</option>
+                  </select>
+                </label>
+                <label class="inline-label">
+                  <input type="checkbox" v-model="variable.required" />
+                  必填
+                </label>
+                <label>
+                  默认值
+                  <input v-model="variable.default" placeholder="可选默认值,便于预览" />
+                </label>
+                <label>
+                  变量描述
+                  <textarea v-model="variable.description" rows="2" placeholder="提示运营如何填写" />
+                </label>
+              </div>
+            </div>
+          </div>
+        </section>
+
+        <section class="card">
+          <div class="card-header">
+            <div>
+              <p class="eyebrow">模板正文</p>
+              <h2>编写 Jinja 播报模版</h2>
+            </div>
+            <div class="gap-8">
+              <button class="ghost" @click="previewTemplate">本地预览</button>
+              <button class="ghost" @click="saveTemplate">
+                {{ mode === 'update' ? '保存修改' : '创建模板' }}
+              </button>
+              <button @click="renderTemplate">调用后端生成</button>
+            </div>
+          </div>
+
+          <div class="editor-grid">
+            <label>
+              模板正文 (支持 Jinja2)
+              <textarea v-model="editor.template_body" placeholder="在此编写模板,可引用 {{ variable }} 等变量" />
+            </label>
+            <div>
+              <h3>变量填充区</h3>
+              <p class="muted">输入样例值,便于预览和对接。</p>
+              <div v-if="!editor.input_variables.length" class="empty-state small">
+                添加变量后可在此填充预览数据。
+              </div>
+              <div class="parameter-grid" v-else>
+                <label v-for="variable in editor.input_variables" :key="variable.name || variable.label">
+                  {{ variable.label || variable.name }}
+                  <span class="tag">{{ variable.data_type }}</span>
+                  <input
+                    :placeholder="variable.description"
+                    v-model="parameterValues[variable.name]"
+                  />
+                </label>
+              </div>
+            </div>
+          </div>
+
+          <div class="preview-panel" v-if="previewResult">
+            <div class="preview-header">
+              <h3>生成结果</h3>
+              <span class="muted">{{ editor.output_description || '播报示例' }}</span>
+            </div>
+            <pre>{{ previewResult }}</pre>
+          </div>
+        </section>
+      </main>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.page {
+  padding: 2.5rem;
+  max-width: 1400px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  gap: 1.5rem;
+}
+
+.page-header {
+  background: #fff;
+  border-radius: 16px;
+  padding: 1.75rem;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 1.5rem;
+  border: 1px solid #e2e8f3;
+}
+
+.eyebrow {
+  font-size: 0.85rem;
+  text-transform: uppercase;
+  letter-spacing: 0.08rem;
+  color: #64748b;
+  margin-bottom: 0.25rem;
+}
+
+.subtitle {
+  color: #475467;
+  margin-top: 0.4rem;
+}
+
+.header-actions {
+  display: flex;
+  align-items: center;
+  gap: 0.75rem;
+}
+
+.layout {
+  display: grid;
+  grid-template-columns: 320px 1fr;
+  gap: 1.5rem;
+}
+
+.sidebar {
+  background: #fff;
+  border-radius: 16px;
+  padding: 1.5rem;
+  border: 1px solid #e2e8f3;
+  min-height: 500px;
+}
+
+.sidebar.loading {
+  opacity: 0.6;
+}
+
+.category-list {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  margin-top: 1rem;
+}
+
+.category-title {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.template-list {
+  display: flex;
+  flex-direction: column;
+  gap: 0.6rem;
+  margin-top: 0.7rem;
+}
+
+.template-item {
+  width: 100%;
+  text-align: left;
+  border-radius: 10px;
+  padding: 0.75rem;
+  border: 1px solid transparent;
+  background: #f8fafc;
+}
+
+.template-item strong {
+  display: block;
+  color:#1f2937;
+  margin-bottom: 0.25rem;
+}
+
+.template-item p {
+  margin: 0;
+  color: #475467;
+  font-size: 0.85rem;
+}
+
+.template-item.active {
+  background: #eef2ff;
+  border-color: #4338ca;
+}
+
+.content {
+  display: flex;
+  flex-direction: column;
+  gap: 1.25rem;
+}
+
+.card {
+  background: #fff;
+  border-radius: 16px;
+  border: 1px solid #e2e8f3;
+  padding: 1.5rem;
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 1rem;
+}
+
+.gap-8 {
+  display: flex;
+  gap: 0.75rem;
+}
+
+.form-grid {
+  display: grid;
+  gap: 1rem;
+}
+
+.call-snippet {
+  background: #0f172a;
+  color: #e2e8f0;
+  font-family: "JetBrains Mono", Consolas, monospace;
+  padding: 0.75rem;
+  border-radius: 10px;
+  font-size: 0.9rem;
+  white-space: pre-wrap;
+  border: 1px solid #1e293b;
+  margin-top: 0.5rem;
+}
+
+.variable-grid {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+}
+
+.variable-item {
+  border: 1px solid #e2e8f3;
+  border-radius: 12px;
+  padding: 1rem;
+}
+
+.variable-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 1rem;
+}
+
+.variable-fields {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+  gap: 0.75rem;
+}
+
+.inline-label {
+  display: flex;
+  align-items: center;
+  gap: 0.4rem;
+  margin-top: 1.6rem;
+  font-size: 0.85rem;
+  color: #1f2937;
+}
+
+.editor-grid {
+  display: grid;
+  grid-template-columns: 1fr 320px;
+  gap: 1rem;
+}
+
+.parameter-grid {
+  display: flex;
+  flex-direction: column;
+  gap: 0.75rem;
+  margin-top: 0.5rem;
+}
+
+.preview-panel {
+  border: 1px solid #c7d2fe;
+  border-radius: 12px;
+  padding: 1rem;
+  background: #f8f9ff;
+}
+
+.preview-panel pre {
+  white-space: pre-wrap;
+  margin: 0;
+  font-family: "JetBrains Mono", Consolas, monospace;
+  font-size: 0.95rem;
+}
+
+.preview-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  margin-bottom: 0.8rem;
+}
+
+.muted {
+  color: #6b7280;
+  font-size: 0.9rem;
+}
+
+.toast {
+  padding: 0.85rem 1rem;
+  border-radius: 10px;
+  font-weight: 600;
+}
+
+.toast.success {
+  background: #ecfdf3;
+  color: #027a48;
+  border: 1px solid #a7f3d0;
+}
+
+.toast.error {
+  background: #fef3f2;
+  color: #b42318;
+  border: 1px solid #fecdcf;
+}
+
+.empty-state {
+  padding: 1rem;
+  background: #f8fafc;
+  border: 1px dashed #cbd5f5;
+  border-radius: 10px;
+  color: #475467;
+}
+
+.empty-state.small {
+  padding: 0.8rem;
+}
+
+@media (max-width: 1100px) {
+  .layout {
+    grid-template-columns: 1fr;
+  }
+
+  .editor-grid {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 1 - 0
frontend/src/assets/vue.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

+ 43 - 0
frontend/src/components/HelloWorld.vue

@@ -0,0 +1,43 @@
+<script setup>
+import { ref } from 'vue'
+
+defineProps({
+  msg: String,
+})
+
+const count = ref(0)
+</script>
+
+<template>
+  <h1>{{ msg }}</h1>
+
+  <div class="card">
+    <button type="button" @click="count++">count is {{ count }}</button>
+    <p>
+      Edit
+      <code>components/HelloWorld.vue</code> to test HMR
+    </p>
+  </div>
+
+  <p>
+    Check out
+    <a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
+      >create-vue</a
+    >, the official Vue + Vite starter
+  </p>
+  <p>
+    Learn more about IDE Support for Vue in the
+    <a
+      href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
+      target="_blank"
+      >Vue Docs Scaling up Guide</a
+    >.
+  </p>
+  <p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
+</template>
+
+<style scoped>
+.read-the-docs {
+  color: #888;
+}
+</style>

+ 5 - 0
frontend/src/main.js

@@ -0,0 +1,5 @@
+import { createApp } from 'vue'
+import './style.css'
+import App from './App.vue'
+
+createApp(App).mount('#app')

+ 56 - 0
frontend/src/services/api.js

@@ -0,0 +1,56 @@
+const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://110.42.102.94:8000/api'
+
+async function request(path, options = {}) {
+  const response = await fetch(`${API_BASE}${path}`, {
+    headers: {
+      'Content-Type': 'application/json',
+      ...(options.headers ?? {})
+    },
+    ...options
+  })
+
+  if (!response.ok) {
+    const message = await response.text()
+    throw new Error(message || '请求失败')
+  }
+  if (response.status === 204) {
+    return null
+  }
+  return response.json()
+}
+
+export const TemplateAPI = {
+  listTemplates() {
+    return request('/templates')
+  },
+  listCategories() {
+    return request('/templates/categories')
+  },
+  createTemplate(payload) {
+    return request('/templates', {
+      method: 'POST',
+      body: JSON.stringify(payload)
+    })
+  },
+  updateTemplate(id, payload) {
+    return request(`/templates/${id}`, {
+      method: 'PUT',
+      body: JSON.stringify(payload)
+    })
+  },
+  deleteTemplate(id) {
+    return request(`/templates/${id}`, { method: 'DELETE' })
+  },
+  previewTemplate(payload) {
+    return request('/templates/preview', {
+      method: 'POST',
+      body: JSON.stringify(payload)
+    })
+  },
+  renderTemplate(payload) {
+    return request('/templates/render', {
+      method: 'POST',
+      body: JSON.stringify(payload)
+    })
+  }
+}

+ 92 - 0
frontend/src/style.css

@@ -0,0 +1,92 @@
+:root {
+  font-family: "Inter", "PingFang SC", system-ui, -apple-system, BlinkMacSystemFont,
+    "Segoe UI", sans-serif;
+  line-height: 1.6;
+  font-weight: 400;
+  font-synthesis: none;
+  text-rendering: optimizeLegibility;
+  -webkit-font-smoothing: antialiased;
+  color: #0b1c33;
+  background-color: #f5f7fb;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  background: #f5f7fb;
+}
+
+#app {
+  min-height: 100vh;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+  margin: 0;
+  font-weight: 600;
+  color: #0f172a;
+}
+
+button {
+  border: none;
+  border-radius: 6px;
+  padding: 0.55rem 1.1rem;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  background-color: #2563eb;
+  color: #fff;
+}
+
+button[disabled] {
+  opacity: 0.6;
+  cursor: not-allowed;
+}
+
+button.ghost {
+  background: transparent;
+  color: #2563eb;
+  border: 1px solid rgba(37, 99, 235, 0.5);
+}
+
+input,
+select,
+textarea {
+  width: 100%;
+  padding: 0.55rem 0.75rem;
+  border-radius: 6px;
+  border: 1px solid #d4d8e6;
+  background: #fff;
+  font-size: 0.95rem;
+}
+
+textarea {
+  resize: vertical;
+  min-height: 180px;
+  font-family: "JetBrains Mono", Consolas, monospace;
+}
+
+label {
+  font-size: 0.85rem;
+  color: #475467;
+  margin-bottom: 0.3rem;
+  display: inline-flex;
+  align-items: center;
+  gap: 0.25rem;
+}
+
+.tag {
+  background: #e0e7ff;
+  color: #1e3a8a;
+  padding: 0.15rem 0.4rem;
+  border-radius: 4px;
+  font-size: 0.75rem;
+  font-weight: 600;
+}

+ 11 - 0
frontend/vite.config.js

@@ -0,0 +1,11 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+// https://vite.dev/config/
+export default defineConfig({
+  plugins: [vue()],
+  server:{
+    host:'0.0.0.0',
+    port:'8020'
+  }
+})

+ 3 - 0
main.py

@@ -0,0 +1,3 @@
+from backend.app.main import app
+
+# 这个入口仅用于 `uvicorn main:app` 的快捷引用。

+ 64 - 0
templates/wash_recommend copy.jinja

@@ -0,0 +1,64 @@
+{%- set greeting = '亲爱的用户' if hour < 11 else '中午好' if hour < 14 else '下午好' if hour < 18 else '晚上好' -%}
+{%- set period = '早晨' if hour < 9 else '上午' if hour < 12 else '中午' if hour < 14 else '下午' if hour < 18 else '晚上' -%}
+
+{%- set is_hot = temperature is defined and temperature >= 30 -%}
+{%- set is_cold = temperature is defined and temperature <= 10 -%}
+{%- set has_rain = weather is defined and weather is string and '雨' in weather -%}
+{%- set condition = 
+    '炎热' if is_hot else 
+    '寒冷' if is_cold else 
+    '下雨' if has_rain else 
+    weather|default('美好') -%}
+
+{%- set proportion_pct = (user_proportion * 100)|round(1) if user_proportion is defined and user_proportion is not none else none -%}
+{%- set popularity_level = 
+    '最受欢迎' if proportion_pct is defined and proportion_pct is not none and proportion_pct >= 70 else 
+    '人气很高' if proportion_pct is defined and proportion_pct is not none and proportion_pct >= 50 else 
+    '很多人选择' if proportion_pct is defined and proportion_pct is not none and proportion_pct >= 30 else 
+    '值得一试' -%}
+
+{%- set user_desc = 
+    '超过' ~ user_count ~ '位用户' if user_count is defined and user_count is not none and user_count >= 10000 else 
+    user_count ~ '位用户' if user_count is defined and user_count is not none else 
+    '很多用户' -%}
+
+{# 第一段:基础推荐 + 数据支撑 #}
+{{ greeting }},为你推荐{{ feature_name|default('这类衣物') }}的{{ program_name }}程序,
+{%- if user_count is defined or user_proportion is defined %}
+这可是你的老朋友了,数据显示在{{ season }}{{ city|default('本地') }}{{ period }},
+{{ user_desc }}{% if proportion_pct is defined and proportion_pct is not none %},占比高达{{ proportion_pct }}%{% endif %}都爱用它洗呢。
+{%- else %}
+非常适合当前季节和衣物类型。
+{%- endif %}
+
+{# 第二段:季节场景化推荐 #}
+{%- if season in ['夏季', '冬天'] or is_hot or is_cold or has_rain %}
+{{ season }}洗衣时,
+{%- if is_hot %}高温天气下,{% elif is_cold %}寒冷天气下,{% elif has_rain %}雨天,{% endif %}
+{{ feature_name|default('这类衣物') }}的{{ program_name }}程序最懂你心意,
+{%- if proportion_pct is defined and proportion_pct is not none %}
+{{ city|default('本地') }}{{ period }}时段有{{ proportion_pct }}%用户选择它呢。
+{%- else %}
+很适合现在使用。
+{%- endif %}
+{%- endif %}
+
+{# 第三段:助手提醒(总是输出,增强亲切感) #}
+洗衣助手提醒你,{{ feature_name|default('这类衣物') }}的{{ program_name }}程序真不错,
+{%- if proportion_pct is defined and proportion_pct is not none %}
+{{ city|default('本地') }}{{ period }}时段人气也很旺,属于{{ popularity_level }}的洗衣方案。
+{%- else %}
+非常实用,值得一试。
+{%- endif %}
+
+{# 第四段:天气+时段氛围感推荐 #}
+又是一个{{ season }}{{ condition }}的{{ period }},
+为你推荐{{ feature_name|default('这类衣物') }}的{{ program_name }}程序,
+{%- if proportion_pct is defined and proportion_pct is not none %}
+这是{{ popularity_level }}的洗衣方案之一,{{ city|default('本地') }}{{ proportion_pct }}%用户都在用。
+{%- else %}
+这是当前最合适的洗涤方式。
+{%- endif %}
+
+{# 结尾温柔提醒 #}
+祝你洗衣愉快,衣物干净如新!

+ 64 - 0
templates/wash_recommend.j2

@@ -0,0 +1,64 @@
+{%- set greeting = '亲爱的用户' if hour < 11 else '中午好' if hour < 14 else '下午好' if hour < 18 else '晚上好' -%}
+{%- set period = '早晨' if hour < 9 else '上午' if hour < 12 else '中午' if hour < 14 else '下午' if hour < 18 else '晚上' -%}
+
+{%- set is_hot = temperature is defined and temperature >= 30 -%}
+{%- set is_cold = temperature is defined and temperature <= 10 -%}
+{%- set has_rain = weather is defined and weather is string and '雨' in weather -%}
+{%- set condition = 
+    '炎热' if is_hot else 
+    '寒冷' if is_cold else 
+    '下雨' if has_rain else 
+    weather|default('美好') -%}
+
+{%- set proportion_pct = (user_proportion * 100)|round(1) if user_proportion is defined and user_proportion is not none else none -%}
+{%- set popularity_level = 
+    '最受欢迎' if proportion_pct is defined and proportion_pct is not none and proportion_pct >= 70 else 
+    '人气很高' if proportion_pct is defined and proportion_pct is not none and proportion_pct >= 50 else 
+    '很多人选择' if proportion_pct is defined and proportion_pct is not none and proportion_pct >= 30 else 
+    '值得一试' -%}
+
+{%- set user_desc = 
+    '超过' ~ user_count ~ '位用户' if user_count is defined and user_count is not none and user_count >= 10000 else 
+    user_count ~ '位用户' if user_count is defined and user_count is not none else 
+    '很多用户' -%}
+
+{# 第一段:基础推荐 + 数据支撑 #}
+{{ greeting }},为你推荐{{ feature_name|default('这类衣物') }}的{{ program_name }}程序,
+{%- if user_count is defined or user_proportion is defined %}
+这可是你的老朋友了,数据显示在{{ season }}{{ city|default('本地') }}{{ period }},
+{{ user_desc }}{% if proportion_pct is defined and proportion_pct is not none %},占比高达{{ proportion_pct }}%{% endif %}都爱用它洗呢。
+{%- else %}
+非常适合当前季节和衣物类型。
+{%- endif %}
+
+{# 第二段:季节场景化推荐 #}
+{%- if season in ['夏季', '冬天'] or is_hot or is_cold or has_rain %}
+{{ season }}洗衣时,
+{%- if is_hot %}高温天气下,{% elif is_cold %}寒冷天气下,{% elif has_rain %}雨天,{% endif %}
+{{ feature_name|default('这类衣物') }}的{{ program_name }}程序最懂你心意,
+{%- if proportion_pct is defined and proportion_pct is not none %}
+{{ city|default('本地') }}{{ period }}时段有{{ proportion_pct }}%用户选择它呢。
+{%- else %}
+很适合现在使用。
+{%- endif %}
+{%- endif %}
+
+{# 第三段:助手提醒(总是输出,增强亲切感) #}
+洗衣助手提醒你,{{ feature_name|default('这类衣物') }}的{{ program_name }}程序真不错,
+{%- if proportion_pct is defined and proportion_pct is not none %}
+{{ city|default('本地') }}{{ period }}时段人气也很旺,属于{{ popularity_level }}的洗衣方案。
+{%- else %}
+非常实用,值得一试。
+{%- endif %}
+
+{# 第四段:天气+时段氛围感推荐 #}
+又是一个{{ season }}{{ condition }}的{{ period }},
+为你推荐{{ feature_name|default('这类衣物') }}的{{ program_name }}程序,
+{%- if proportion_pct is defined and proportion_pct is not none %}
+这是{{ popularity_level }}的洗衣方案之一,{{ city|default('本地') }}{{ proportion_pct }}%用户都在用。
+{%- else %}
+这是当前最合适的洗涤方式。
+{%- endif %}
+
+{# 结尾温柔提醒 #}
+祝你洗衣愉快,衣物干净如新!

+ 64 - 0
templates/ww.j2

@@ -0,0 +1,64 @@
+{%- set greeting = '亲爱的用户' if hour < 11 else '中午好' if hour < 14 else '下午好' if hour < 18 else '晚上好' -%}
+{%- set period = '早晨' if hour < 9 else '上午' if hour < 12 else '中午' if hour < 14 else '下午' if hour < 18 else '晚上' -%}
+
+{%- set is_hot = temperature is defined and temperature >= 30 -%}
+{%- set is_cold = temperature is defined and temperature <= 10 -%}
+{%- set has_rain = weather is defined and weather is string and '雨' in weather -%}
+{%- set condition = 
+    '炎热' if is_hot else 
+    '寒冷' if is_cold else 
+    '下雨' if has_rain else 
+    weather|default('美好') -%}
+
+{%- set proportion_pct = (user_proportion * 100)|round(1) if user_proportion is defined and user_proportion is not none else none -%}
+{%- set popularity_level = 
+    '最受欢迎' if proportion_pct is defined and proportion_pct is not none and proportion_pct >= 70 else 
+    '人气很高' if proportion_pct is defined and proportion_pct is not none and proportion_pct >= 50 else 
+    '很多人选择' if proportion_pct is defined and proportion_pct is not none and proportion_pct >= 30 else 
+    '值得一试' -%}
+
+{%- set user_desc = 
+    '超过' ~ user_count ~ '位用户' if user_count is defined and user_count is not none and user_count >= 10000 else 
+    user_count ~ '位用户' if user_count is defined and user_count is not none else 
+    '很多用户' -%}
+
+{# 第一段:基础推荐 + 数据支撑 #}
+{{ greeting }},为你推荐{{ feature_name|default('这类衣物') }}的{{ program_name }}程序,
+{%- if user_count is defined or user_proportion is defined %}
+这可是你的老朋友了,数据显示在{{ season }}{{ city|default('本地') }}{{ period }},
+{{ user_desc }}{% if proportion_pct is defined and proportion_pct is not none %},占比高达{{ proportion_pct }}%{% endif %}都爱用它洗呢。
+{%- else %}
+非常适合当前季节和衣物类型。
+{%- endif %}
+
+{# 第二段:季节场景化推荐 #}
+{%- if season in ['夏季', '冬天'] or is_hot or is_cold or has_rain %}
+{{ season }}洗衣时,
+{%- if is_hot %}高温天气下,{% elif is_cold %}寒冷天气下,{% elif has_rain %}雨天,{% endif %}
+{{ feature_name|default('这类衣物') }}的{{ program_name }}程序最懂你心意,
+{%- if proportion_pct is defined and proportion_pct is not none %}
+{{ city|default('本地') }}{{ period }}时段有{{ proportion_pct }}%用户选择它呢。
+{%- else %}
+很适合现在使用。
+{%- endif %}
+{%- endif %}
+
+{# 第三段:助手提醒(总是输出,增强亲切感) #}
+洗衣助手提醒你,{{ feature_name|default('这类衣物') }}的{{ program_name }}程序真不错,
+{%- if proportion_pct is defined and proportion_pct is not none %}
+{{ city|default('本地') }}{{ period }}时段人气也很旺,属于{{ popularity_level }}的洗衣方案。
+{%- else %}
+非常实用,值得一试。
+{%- endif %}
+
+{# 第四段:天气+时段氛围感推荐 #}
+又是一个{{ season }}{{ condition }}的{{ period }},
+为你推荐{{ feature_name|default('这类衣物') }}的{{ program_name }}程序,
+{%- if proportion_pct is defined and proportion_pct is not none %}
+这是{{ popularity_level }}的洗衣方案之一,{{ city|default('本地') }}{{ proportion_pct }}%用户都在用。
+{%- else %}
+这是当前最合适的洗涤方式。
+{%- endif %}
+
+{# 结尾温柔提醒 #}
+祝你洗衣愉快,衣物干净如新!

+ 17 - 0
test.http

@@ -0,0 +1,17 @@
+POST http://110.42.102.94:8000/api/templates/render
+Content-Type: application/json
+
+{
+  "template_id": "wash_recommend",
+  "parameters": {
+    "hour": "12",
+    "season": "夏季",
+    "city": "北京",
+    "program_name": "快速洗",
+    "feature_name": "日常衣物",
+    "temperature": "12",
+    "weather": "多云",
+    "user_count": "12",
+    "user_proportion": "0.12"
+  }
+}

Деякі файли не було показано, через те що забагато файлів було змінено