Prechádzať zdrojové kódy

feat(P2-M1): Web端基础框架 - FastAPI后端 + Vue3前端 + SQLite + 双系统API客户端

后端:
- FastAPI应用入口 + CORS + 健康检查
- SQLAlchemy ORM: Project/SimulationPlan/SimulationResult/ExperienceCase
- Pydantic v2 schemas
- 项目CRUD + 方案CRUD + 经验库CRUD
- 方案下载接口(按ID/按UUID) - 系统二兼容格式
- 结果上传接口(CSV解析入库)
- API集成测试脚本

前端:
- Vue3 + TypeScript + Element Plus + ECharts + Pinia + Vue Router
- 主布局(侧边栏导航)
- 项目列表/项目详情/方案详情/经验库 四个页面
- axios API层对接后端
- Vite配置(代理/api到8000)
- 构建验证通过(vue-tsc + vite build)

系统二集成:
- src/api_client.py: WebAPIClient(方案下载/结果上传/完整工作流)
- scripts/test_api_client.py: 客户端测试

工程:
- 全部Python源码纯ASCII
- .gitignore更新(node_modules/dist/*.db)
- 依赖锁定: typescript@~5.5.4 + vue-tsc@latest
- CONVERSATION_LOG.md记录P2-M1完成
javen.ye 1 týždeň pred
rodič
commit
1ea7653c62
40 zmenil súbory, kde vykonal 4219 pridanie a 0 odobranie
  1. 8 0
      .gitignore
  2. 128 0
      docs/CONVERSATION_LOG.md
  3. 33 0
      scripts/test_api_client.py
  4. 149 0
      src/api_client.py
  5. 1 0
      web/backend/app/__init__.py
  6. 26 0
      web/backend/app/config.py
  7. 30 0
      web/backend/app/database.py
  8. 50 0
      web/backend/app/main.py
  9. 7 0
      web/backend/app/models/__init__.py
  10. 35 0
      web/backend/app/models/experience_case.py
  11. 30 0
      web/backend/app/models/project.py
  12. 37 0
      web/backend/app/models/simulation_plan.py
  13. 39 0
      web/backend/app/models/simulation_result.py
  14. 1 0
      web/backend/app/routers/__init__.py
  15. 85 0
      web/backend/app/routers/experience.py
  16. 265 0
      web/backend/app/routers/plans.py
  17. 96 0
      web/backend/app/routers/projects.py
  18. 13 0
      web/backend/app/schemas/__init__.py
  19. 42 0
      web/backend/app/schemas/project.py
  20. 48 0
      web/backend/app/schemas/simulation_plan.py
  21. 38 0
      web/backend/app/schemas/simulation_result.py
  22. 5 0
      web/backend/requirements.txt
  23. 6 0
      web/backend/run.py
  24. 75 0
      web/backend/test_api.py
  25. 13 0
      web/frontend/index.html
  26. 1964 0
      web/frontend/package-lock.json
  27. 25 0
      web/frontend/package.json
  28. 6 0
      web/frontend/src/App.vue
  29. 48 0
      web/frontend/src/api/index.ts
  30. 121 0
      web/frontend/src/layouts/MainLayout.vue
  31. 13 0
      web/frontend/src/main.ts
  32. 45 0
      web/frontend/src/router/index.ts
  33. 58 0
      web/frontend/src/style.css
  34. 78 0
      web/frontend/src/views/ExperienceList.vue
  35. 192 0
      web/frontend/src/views/PlanDetail.vue
  36. 176 0
      web/frontend/src/views/ProjectDetail.vue
  37. 177 0
      web/frontend/src/views/ProjectList.vue
  38. 25 0
      web/frontend/tsconfig.json
  39. 10 0
      web/frontend/tsconfig.node.json
  40. 21 0
      web/frontend/vite.config.ts

+ 8 - 0
.gitignore

@@ -28,6 +28,14 @@ MessageLogs/
 experience/*.db
 experience/*.db-journal
 
+# === Web端 ===
+web/frontend/node_modules/
+web/frontend/dist/
+web/backend/app/*.db
+web/backend/__pycache__/
+web/backend/app/__pycache__/
+web/backend/app/**/__pycache__/
+
 # === IDE / OS ===
 .vscode/
 .idea/

+ 128 - 0
docs/CONVERSATION_LOG.md

@@ -206,3 +206,131 @@ Car.Lin(项目负责人)
 - 内网API通信(方案下载 + 结果上传)
 - DRSS拓扑支持
 - 更复杂的优化算法(Morris/LHS/Kriging/NSGA-II)
+
+---
+
+## 2026-08-27 16:00 — Phase 2 启动与关键决策
+
+### 参与者
+Car.Lin(项目负责人)
+
+### Phase 2 目标
+搭建系统一(Web端方案生成及优化系统),与Phase 1已完成的本地执行端(系统二)API自动联调,实现完整闭环。
+
+### 关键决策(用户确认)
+
+1. **前端框架**:Vue 3 + TypeScript + Element Plus + ECharts
+   - 理由:数据密集型仪表盘(参数矩阵/结果表/敏感性热力图/Pareto/收敛曲线),Element Plus表格表单组件丰富,中文生态好,ECharts在Vue中集成成熟
+   - 不选React:Ant Design对复杂数据表格支持不如Element Plus灵活,国内工业软件Vue更主流
+
+2. **AI接口**:初期不接外部LLM,用规则引擎+经验库检索生成方案;后期接DeepSeek(用户提供API Key)
+   - Phase 2简化,AI方案生成推迟到Phase 3
+
+3. **数据库**:初期用SQLite(SQLAlchemy ORM抽象层),后期迁移PostgreSQL+pgvector只需改连接字符串
+   - 理由:SQLite零配置,本地测试方便;SQLAlchemy ORM确保迁移平滑
+   - 向量检索功能推迟到PostgreSQL迁移后
+
+4. **部署**:本地裸装运行,不用Docker
+
+5. **P2范围简化**:AI方案生成推迟到Phase 3,P2用规则+经验的简化方案生成
+
+6. **双系统通信**:直接做API自动联调(不做手动文件交换)
+   - 系统一API:GET /api/plans/{id}/download, POST /api/plans/{id}/upload-results
+   - 系统二增加API客户端:自动拉取方案、回传结果
+
+### Phase 2 里程碑
+- P2-M1:Web端基础框架(FastAPI后端 + Vue3前端 + SQLite + 基础CRUD API)
+- P2-M2:边界条件输入 + 方案编辑器(规则引擎生成方案)
+- P2-M3:经验库Web端 + 结果分析仪表盘
+- P2-M4:双系统API联调 + 知识库管理
+- P2-M5:Phase 2验收
+
+### 开发纪律
+- 积累开发经验和踩坑记录到CONVERSATION_LOG.md
+- 后端Python代码纯ASCII
+- 前端TypeScript/Vue代码遵循ESLint规范
+- 每次里程碑完成后Git提交
+- API接口与系统二的simulation_plan.json格式完全一致
+
+---
+
+## 2026-08-27 16:30 — P2-M1 完成:Web端基础框架
+
+### 成果
+- **后端(FastAPI + SQLAlchemy + SQLite)**:
+  - `web/backend/app/main.py`:FastAPI应用入口,CORS中间件,路由注册,健康检查
+  - `web/backend/app/config.py`:应用配置(数据库/服务器/CORS/元数据),环境变量覆盖
+  - `web/backend/app/database.py`:SQLAlchemy引擎/Session/Base,init_db建表
+  - `web/backend/app/models/`:Project, SimulationPlan, SimulationResult, ExperienceCase 四个ORM模型
+  - `web/backend/app/schemas/`:Pydantic v2 请求/响应Schema(创建/更新/响应/列表/下载)
+  - `web/backend/app/routers/`:
+    - `projects.py`:项目CRUD(GET/POST/PUT/DELETE + 列表筛选)
+    - `plans.py`:方案CRUD + 下载接口(按ID/按UUID)+ 结果上传(CSV解析)+ 结果查询
+    - `experience.py`:经验库CRUD + 相似检索
+  - `web/backend/test_api.py`:后端API集成测试脚本(健康检查/项目/方案/下载/列表)
+  - `web/backend/run.py`:uvicorn启动入口
+  - `web/backend/requirements.txt`:fastapi/uvicorn/sqlalchemy/pydantic/python-multipart
+
+- **前端(Vue 3 + TypeScript + Element Plus + ECharts + Pinia + Vue Router)**:
+  - `web/frontend/src/main.ts`:应用入口,Element Plus/Pinia/Router注册
+  - `web/frontend/src/App.vue`:根组件
+  - `web/frontend/src/router/index.ts`:路由配置(项目列表/项目详情/方案详情/经验库)
+  - `web/frontend/src/api/index.ts`:axios封装,项目/方案/经验库API调用
+  - `web/frontend/src/layouts/MainLayout.vue`:主布局(侧边栏导航 + 顶部栏 + 内容区)
+  - `web/frontend/src/views/`:
+    - `ProjectList.vue`:项目列表页(表格/新建/删除/搜索)
+    - `ProjectDetail.vue`:项目详情页(基本信息 + 方案列表)
+    - `PlanDetail.vue`:方案详情页(方案信息 + 变量表 + 结果表)
+    - `ExperienceList.vue`:经验库列表页
+  - `web/frontend/vite.config.ts`:Vite配置(代理/api到后端8000端口)
+  - `web/frontend/tsconfig.json`:TypeScript配置
+  - `web/frontend/package.json`:依赖与脚本
+
+- **系统二API客户端**:
+  - `src/api_client.py`:WebAPIClient类(健康检查/项目/方案下载/结果上传/完整工作流),纯urllib无额外依赖
+  - `scripts/test_api_client.py`:API客户端测试脚本
+
+### 关键API端点(双系统联调用)
+| 方法 | 路径 | 用途 |
+|---|---|---|
+| GET | `/api/health` | 健康检查 |
+| GET | `/api/projects` | 项目列表 |
+| POST | `/api/projects` | 创建项目 |
+| GET | `/api/plans/{id}/download` | 按ID下载方案(系统二拉取) |
+| GET | `/api/plans/by-plan-id/{uuid}/download` | 按plan_id字符串下载方案 |
+| POST | `/api/plans/{id}/upload-results` | 上传scan_results.csv(系统二回传) |
+| GET | `/api/plans/{id}/results` | 查询方案结果 |
+
+### 验证结果
+1. **后端API测试**:全部通过(健康检查/创建项目/列表/创建方案/下载/按UUID下载/列表)
+2. **前端构建**:vue-tsc类型检查通过 + vite build成功(9.79s,1676模块)
+3. **ASCII检查**:全部.py/.ps1文件纯ASCII,无违规
+4. **依赖版本**:fastapi 0.141.1 / sqlalchemy 2.0.52 / pydantic 2.13.4 / Vue 3.3 / Element Plus 2.4
+
+### 踩坑记录
+1. **vue-tsc与TypeScript版本兼容**:
+   - 初始安装TypeScript 5.9.3 + vue-tsc 1.x → 报"Search string not found"错误
+   - 升级vue-tsc@latest(版本号与TS对齐到5.5.4)→ 解决
+   - 教训:vue-tsc版本必须与TypeScript主版本严格匹配,建议锁定typescript@~5.5.4
+
+2. **PowerShell npm退出码误报**:
+   - npm将警告信息输出到stderr,PowerShell误判为失败(exit code 1)
+   - 实际构建成功,需看stdout中的"✓ built"确认
+
+### 工程规范
+- 后端Python代码纯ASCII
+- 前端TypeScript严格模式
+- SQLite数据库文件不入库(.gitignore已配置)
+- node_modules/dist不入库
+- API响应格式与系统二simulation_plan.json完全一致
+
+### P2-M1 验收标准
+- [x] FastAPI后端可启动,健康检查正常
+- [x] 项目/方案/经验库 CRUD API完整
+- [x] 方案下载接口(按ID/按UUID)返回系统二兼容格式
+- [x] 结果上传接口可解析scan_results.csv
+- [x] Vue3前端可构建,路由/布局/页面完整
+- [x] 前端API层对接后端
+- [x] 系统二API客户端可调用后端
+- [x] 全部源码纯ASCII
+- [x] .gitignore配置完整

+ 33 - 0
scripts/test_api_client.py

@@ -0,0 +1,33 @@
+"""Quick test for API client."""
+import sys
+sys.path.insert(0, ".")
+from src.api_client import WebAPIClient
+
+client = WebAPIClient("http://127.0.0.1:8000")
+
+print("Health:", client.health_check())
+
+projects = client.list_projects()
+print(f"Projects: {len(projects)}")
+for p in projects:
+    print(f"  #{p['id']}: {p['name']} ({p['topology']})")
+
+plans = client.list_plans(project_id=1)
+print(f"Plans for project 1: {len(plans)}")
+for pl in plans:
+    print(f"  #{pl['id']}: {pl['name']} ({pl['plan_id']}) status={pl['status']}")
+
+if plans:
+    plan = client.download_plan(plans[0]["id"])
+    print(f"Downloaded plan: {plan['plan_id']}")
+    vars_list = [v["name"] for v in plan["plan_data"].get("variables", [])]
+    print(f"  Variables: {vars_list}")
+
+    results = client.get_plan_results(plans[0]["id"])
+    print(f"Results: {len(results)}")
+    for r in results[:2]:
+        tavg = r["metrics"].get("tavg_nm")
+        eff = r["metrics"].get("efficiency_pct")
+        print(f"  #{r['run_index']}: params={r['params']}, tavg={tavg}, eff={eff}")
+
+print("API client test PASSED")

+ 149 - 0
src/api_client.py

@@ -0,0 +1,149 @@
+"""API client for web backend integration (System 2 <-> System 1).
+
+Allows the local executor to:
+- Fetch simulation plans from the web backend
+- Upload scan results back to the web backend
+
+All source is ASCII.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from typing import Any
+
+import urllib.request
+import urllib.error
+
+
+class WebAPIClient:
+    """Client for the web backend REST API."""
+
+    def __init__(self, base_url: str = "http://127.0.0.1:8000"):
+        self.base_url = base_url.rstrip("/")
+        self.timeout = 30
+
+    def _request(self, method: str, path: str, data: dict | None = None,
+                 files: dict | None = None) -> Any:
+        """Make an HTTP request to the API."""
+        url = f"{self.base_url}{path}"
+        if files:
+            # Multipart form upload
+            import uuid
+            boundary = uuid.uuid4().hex
+            body = b""
+            for field_name, file_info in files.items():
+                body += f"--{boundary}\r\n".encode()
+                body += f'Content-Disposition: form-data; name="{field_name}"; filename="{file_info["filename"]}"\r\n'.encode()
+                body += b"Content-Type: application/octet-stream\r\n\r\n"
+                body += file_info["content"]
+                body += b"\r\n"
+            body += f"--{boundary}--\r\n".encode()
+            req = urllib.request.Request(url, data=body, method=method)
+            req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
+        else:
+            body = json.dumps(data).encode() if data else None
+            req = urllib.request.Request(url, data=body, method=method)
+            req.add_header("Content-Type", "application/json")
+
+        try:
+            with urllib.request.urlopen(req, timeout=self.timeout) as resp:
+                return json.loads(resp.read())
+        except urllib.error.HTTPError as e:
+            error_body = e.read().decode()
+            raise RuntimeError(f"API {method} {path} failed: {e.code} {error_body}")
+        except urllib.error.URLError as e:
+            raise RuntimeError(f"API connection failed: {e.reason}")
+
+    # -- Health --
+
+    def health_check(self) -> bool:
+        """Check if the web backend is reachable."""
+        try:
+            result = self._request("GET", "/api/health")
+            return result.get("status") == "ok"
+        except Exception:
+            return False
+
+    # -- Projects --
+
+    def list_projects(self, topology: str | None = None) -> list[dict]:
+        """List all projects."""
+        path = "/api/projects"
+        if topology:
+            path += f"?topology={topology}"
+        result = self._request("GET", path)
+        return result.get("items", [])
+
+    def get_project(self, project_id: int) -> dict:
+        """Get a project by ID."""
+        return self._request("GET", f"/api/projects/{project_id}")
+
+    # -- Plans --
+
+    def list_plans(self, project_id: int | None = None) -> list[dict]:
+        """List plans, optionally filtered by project."""
+        path = "/api/plans"
+        if project_id:
+            path += f"?project_id={project_id}"
+        result = self._request("GET", path)
+        return result.get("items", [])
+
+    def get_plan(self, plan_id: int) -> dict:
+        """Get a plan by ID."""
+        return self._request("GET", f"/api/plans/{plan_id}")
+
+    def download_plan(self, plan_id: int) -> dict:
+        """Download a plan as simulation_plan.json (compatible with local executor)."""
+        return self._request("GET", f"/api/plans/{plan_id}/download")
+
+    def download_plan_by_uuid(self, plan_uuid: str) -> dict:
+        """Download a plan by its plan_id string (e.g. SP-20260827-001)."""
+        return self._request("GET", f"/api/plans/by-plan-id/{plan_uuid}/download")
+
+    def upload_results(self, plan_id: int, csv_path: str | Path) -> dict:
+        """Upload scan_results.csv to the web backend."""
+        csv_path = Path(csv_path)
+        if not csv_path.exists():
+            raise FileNotFoundError(f"CSV file not found: {csv_path}")
+        with open(csv_path, "rb") as f:
+            content = f.read()
+        return self._request(
+            "POST",
+            f"/api/plans/{plan_id}/upload-results",
+            files={"file": {"filename": csv_path.name, "content": content}},
+        )
+
+    def get_plan_results(self, plan_id: int) -> list[dict]:
+        """Get all results for a plan."""
+        result = self._request("GET", f"/api/plans/{plan_id}/results")
+        return result.get("items", [])
+
+    # -- Convenience: full workflow --
+
+    def fetch_and_save_plan(self, plan_id: int, output_dir: str | Path) -> Path:
+        """Download a plan and save it as simulation_plan.json.
+
+        Args:
+            plan_id: Plan ID in the web backend.
+            output_dir: Directory to save the plan JSON.
+
+        Returns:
+            Path to the saved simulation_plan.json.
+        """
+        plan_data = self.download_plan(plan_id)
+        output_dir = Path(output_dir)
+        output_dir.mkdir(parents=True, exist_ok=True)
+        plan_uuid = plan_data.get("plan_id", "plan")
+        output_path = output_dir / f"{plan_uuid}.json"
+        with open(output_path, "w", encoding="utf-8") as f:
+            json.dump(plan_data.get("plan_data", plan_data), f, indent=2, ensure_ascii=False)
+        return output_path
+
+
+def get_api_client() -> WebAPIClient:
+    """Get API client with URL from environment variable or default."""
+    base_url = os.environ.get("AFM_WEB_API_URL", "http://127.0.0.1:8000")
+    return WebAPIClient(base_url)

+ 1 - 0
web/backend/app/__init__.py

@@ -0,0 +1 @@
+"""PCB Axial Flux Motor Simulation System - Web Backend."""

+ 26 - 0
web/backend/app/config.py

@@ -0,0 +1,26 @@
+"""Application configuration."""
+import os
+from pathlib import Path
+
+# Project paths
+BACKEND_DIR = Path(__file__).resolve().parent
+PROJECT_ROOT = BACKEND_DIR.parent.parent.parent
+
+# Database (SQLite for Phase 2, migrate to PostgreSQL later)
+DB_PATH = os.environ.get("AFM_DB_PATH", str(BACKEND_DIR / "afm_sim.db"))
+DATABASE_URL = f"sqlite:///{DB_PATH}"
+
+# Server
+HOST = os.environ.get("AFM_HOST", "127.0.0.1")
+PORT = int(os.environ.get("AFM_PORT", "8000"))
+
+# CORS (allow frontend dev server)
+CORS_ORIGINS = os.environ.get(
+    "AFM_CORS_ORIGINS",
+    "http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000"
+).split(",")
+
+# App metadata
+APP_NAME = "PCB Axial Flux Motor Simulation System"
+APP_VERSION = "0.1.0"
+APP_DESCRIPTION = "Web-based simulation plan generation and optimization system for PCB axial flux motors"

+ 30 - 0
web/backend/app/database.py

@@ -0,0 +1,30 @@
+"""Database connection and session management (SQLite + SQLAlchemy)."""
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, declarative_base
+
+from .config import DATABASE_URL
+
+# SQLite engine (check_same_thread=False for FastAPI async)
+engine = create_engine(
+    DATABASE_URL,
+    connect_args={"check_same_thread": False},
+    echo=False,
+)
+
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+Base = declarative_base()
+
+
+def get_db():
+    """Dependency for FastAPI route database sessions."""
+    db = SessionLocal()
+    try:
+        yield db
+    finally:
+        db.close()
+
+
+def init_db():
+    """Create all database tables."""
+    from . import models  # noqa: F401 - ensure models are imported
+    Base.metadata.create_all(bind=engine)

+ 50 - 0
web/backend/app/main.py

@@ -0,0 +1,50 @@
+"""FastAPI application entry point."""
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+
+from .config import APP_NAME, APP_VERSION, APP_DESCRIPTION, CORS_ORIGINS
+from .database import init_db
+from .routers import projects, plans, experience
+
+app = FastAPI(
+    title=APP_NAME,
+    version=APP_VERSION,
+    description=APP_DESCRIPTION,
+)
+
+# CORS middleware (allow frontend dev server)
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=CORS_ORIGINS,
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+# Include routers
+app.include_router(projects.router)
+app.include_router(plans.router)
+app.include_router(experience.router)
+
+
+@app.on_event("startup")
+def on_startup():
+    """Initialize database on startup."""
+    init_db()
+
+
+@app.get("/api/health")
+def health_check():
+    """Health check endpoint."""
+    return {"status": "ok", "app": APP_NAME, "version": APP_VERSION}
+
+
+@app.get("/")
+def root():
+    """Root endpoint - redirect to API docs."""
+    return {
+        "message": APP_NAME,
+        "version": APP_VERSION,
+        "docs": "/docs",
+        "api_health": "/api/health",
+    }

+ 7 - 0
web/backend/app/models/__init__.py

@@ -0,0 +1,7 @@
+"""Database models."""
+from .project import Project
+from .simulation_plan import SimulationPlan
+from .simulation_result import SimulationResult
+from .experience_case import ExperienceCase
+
+__all__ = ["Project", "SimulationPlan", "SimulationResult", "ExperienceCase"]

+ 35 - 0
web/backend/app/models/experience_case.py

@@ -0,0 +1,35 @@
+"""Experience case model (for knowledge base / similar case retrieval)."""
+import json
+from datetime import datetime
+from sqlalchemy import Column, Integer, String, Text, DateTime, Float
+
+from ..database import Base
+
+
+class ExperienceCase(Base):
+    """A structured experience case for similarity retrieval."""
+    __tablename__ = "experience_cases"
+
+    id = Column(Integer, primary_key=True, index=True)
+    source_plan_id = Column(String(100), default="")
+    topology = Column(String(20), default="SSSR", index=True)
+    model_path = Column(String(500), default="")
+    params_json = Column(Text, default="{}")  # JSON: parameter values
+    metrics_json = Column(Text, default="{}")  # JSON: metric values
+    boundary_json = Column(Text, default="{}")  # JSON: boundary conditions snapshot
+    conclusion = Column(Text, default="")  # Human/AI generated conclusion
+    tags = Column(String(500), default="")  # Comma-separated tags
+    rating = Column(Integer, default=0)  # 0-5, expert rating
+    created_at = Column(DateTime, default=datetime.utcnow)
+
+    def get_params(self) -> dict:
+        try:
+            return json.loads(self.params_json) if self.params_json else {}
+        except (json.JSONDecodeError, TypeError):
+            return {}
+
+    def get_metrics(self) -> dict:
+        try:
+            return json.loads(self.metrics_json) if self.metrics_json else {}
+        except (json.JSONDecodeError, TypeError):
+            return {}

+ 30 - 0
web/backend/app/models/project.py

@@ -0,0 +1,30 @@
+"""Project model."""
+import json
+from datetime import datetime
+from sqlalchemy import Column, Integer, String, Text, DateTime
+
+from ..database import Base
+
+
+class Project(Base):
+    """A simulation project containing multiple plans and results."""
+    __tablename__ = "projects"
+
+    id = Column(Integer, primary_key=True, index=True)
+    name = Column(String(200), nullable=False, index=True)
+    description = Column(Text, default="")
+    topology = Column(String(20), default="SSSR")  # SSSR / DRSS / SDSR
+    model_path = Column(String(500), default="")
+    status = Column(String(20), default="draft")  # draft / active / completed / archived
+    boundary_conditions = Column(Text, default="{}")  # JSON: dimensions, targets, constraints
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+    def get_boundary_conditions(self) -> dict:
+        try:
+            return json.loads(self.boundary_conditions) if self.boundary_conditions else {}
+        except (json.JSONDecodeError, TypeError):
+            return {}
+
+    def set_boundary_conditions(self, data: dict) -> None:
+        self.boundary_conditions = json.dumps(data, ensure_ascii=False)

+ 37 - 0
web/backend/app/models/simulation_plan.py

@@ -0,0 +1,37 @@
+"""Simulation plan model."""
+import json
+from datetime import datetime
+from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
+from sqlalchemy.orm import relationship
+
+from ..database import Base
+
+
+class SimulationPlan(Base):
+    """A simulation plan (simulation_plan.json format, compatible with local executor)."""
+    __tablename__ = "simulation_plans"
+
+    id = Column(Integer, primary_key=True, index=True)
+    project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
+    name = Column(String(200), nullable=False)
+    plan_id = Column(String(100), unique=True, index=True)  # e.g. SP-20260827-001
+    status = Column(String(20), default="draft")  # draft / confirmed / executing / completed / failed
+    plan_json = Column(Text, nullable=False)  # Full simulation_plan.json content
+    variables_summary = Column(Text, default="")  # JSON: variable names and ranges for display
+    estimated_points = Column(Integer, default=0)
+    estimated_time_min = Column(Integer, default=0)
+    notes = Column(Text, default="")
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+    project = relationship("Project", backref="plans")
+    results = relationship("SimulationResult", backref="plan", cascade="all, delete-orphan")
+
+    def get_plan_dict(self) -> dict:
+        try:
+            return json.loads(self.plan_json) if self.plan_json else {}
+        except (json.JSONDecodeError, TypeError):
+            return {}
+
+    def set_plan_dict(self, data: dict) -> None:
+        self.plan_json = json.dumps(data, ensure_ascii=False, indent=2)

+ 39 - 0
web/backend/app/models/simulation_result.py

@@ -0,0 +1,39 @@
+"""Simulation result model."""
+import json
+from datetime import datetime
+from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Float
+
+from ..database import Base
+
+
+class SimulationResult(Base):
+    """A single simulation result row (from scan_results.csv)."""
+    __tablename__ = "simulation_results"
+
+    id = Column(Integer, primary_key=True, index=True)
+    plan_id = Column(Integer, ForeignKey("simulation_plans.id"), nullable=False, index=True)
+    run_index = Column(Integer, default=0)
+    status = Column(String(20), default="OK")  # OK / FAILED / UNCERTAIN
+    solve_time_s = Column(Float, default=0.0)
+    params_json = Column(Text, default="{}")  # JSON: parameter values
+    metrics_json = Column(Text, default="{}")  # JSON: metric values
+    error_message = Column(Text, default="")
+    created_at = Column(DateTime, default=datetime.utcnow)
+
+    def get_params(self) -> dict:
+        try:
+            return json.loads(self.params_json) if self.params_json else {}
+        except (json.JSONDecodeError, TypeError):
+            return {}
+
+    def set_params(self, data: dict) -> None:
+        self.params_json = json.dumps(data, ensure_ascii=False)
+
+    def get_metrics(self) -> dict:
+        try:
+            return json.loads(self.metrics_json) if self.metrics_json else {}
+        except (json.JSONDecodeError, TypeError):
+            return {}
+
+    def set_metrics(self, data: dict) -> None:
+        self.metrics_json = json.dumps(data, ensure_ascii=False)

+ 1 - 0
web/backend/app/routers/__init__.py

@@ -0,0 +1 @@
+"""API routers."""

+ 85 - 0
web/backend/app/routers/experience.py

@@ -0,0 +1,85 @@
+"""Experience case API router (basic CRUD for Phase 2)."""
+import json
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from ..models.experience_case import ExperienceCase
+
+router = APIRouter(prefix="/api/experience", tags=["experience"])
+
+
+def _case_to_dict(case: ExperienceCase) -> dict:
+    return {
+        "id": case.id,
+        "source_plan_id": case.source_plan_id or "",
+        "topology": case.topology or "SSSR",
+        "model_path": case.model_path or "",
+        "params": case.get_params(),
+        "metrics": case.get_metrics(),
+        "conclusion": case.conclusion or "",
+        "tags": [t.strip() for t in (case.tags or "").split(",") if t.strip()],
+        "rating": case.rating or 0,
+        "created_at": case.created_at,
+    }
+
+
+@router.get("")
+def list_experience(
+    topology: str | None = None,
+    tag: str | None = None,
+    skip: int = 0,
+    limit: int = 50,
+    db: Session = Depends(get_db),
+):
+    """List experience cases with filters."""
+    query = db.query(ExperienceCase)
+    if topology:
+        query = query.filter(ExperienceCase.topology == topology)
+    if tag:
+        query = query.filter(ExperienceCase.tags.contains(tag))
+    total = query.count()
+    cases = query.order_by(ExperienceCase.created_at.desc()).offset(skip).limit(limit).all()
+    return {"total": total, "items": [_case_to_dict(c) for c in cases]}
+
+
+@router.post("", status_code=201)
+def create_experience(
+    data: dict,
+    db: Session = Depends(get_db),
+):
+    """Create an experience case from a dict."""
+    case = ExperienceCase(
+        source_plan_id=data.get("source_plan_id", ""),
+        topology=data.get("topology", "SSSR"),
+        model_path=data.get("model_path", ""),
+        conclusion=data.get("conclusion", ""),
+        tags=",".join(data.get("tags", [])),
+        rating=data.get("rating", 0),
+    )
+    case.params_json = json.dumps(data.get("params", {}), ensure_ascii=False)
+    case.metrics_json = json.dumps(data.get("metrics", {}), ensure_ascii=False)
+    db.add(case)
+    db.commit()
+    db.refresh(case)
+    return _case_to_dict(case)
+
+
+@router.get("/{case_id}")
+def get_experience(case_id: int, db: Session = Depends(get_db)):
+    """Get an experience case by ID."""
+    case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first()
+    if not case:
+        raise HTTPException(status_code=404, detail="Experience case not found")
+    return _case_to_dict(case)
+
+
+@router.delete("/{case_id}", status_code=204)
+def delete_experience(case_id: int, db: Session = Depends(get_db)):
+    """Delete an experience case."""
+    case = db.query(ExperienceCase).filter(ExperienceCase.id == case_id).first()
+    if not case:
+        raise HTTPException(status_code=404, detail="Experience case not found")
+    db.delete(case)
+    db.commit()
+    return None

+ 265 - 0
web/backend/app/routers/plans.py

@@ -0,0 +1,265 @@
+"""Simulation plan API router (CRUD + download + upload results)."""
+import json
+from datetime import datetime
+from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from ..models.project import Project
+from ..models.simulation_plan import SimulationPlan
+from ..models.simulation_result import SimulationResult
+from ..schemas.simulation_plan import (
+    PlanCreate, PlanUpdate, PlanResponse, PlanListResponse, PlanDownloadResponse,
+)
+from ..schemas.simulation_result import ResultListResponse
+
+router = APIRouter(prefix="/api/plans", tags=["plans"])
+
+
+def _generate_plan_id() -> str:
+    return f"SP-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
+
+
+def _plan_to_response(db: Session, plan: SimulationPlan) -> PlanResponse:
+    result_count = db.query(SimulationResult).filter(SimulationResult.plan_id == plan.id).count()
+    variables_summary = {}
+    try:
+        variables_summary = json.loads(plan.variables_summary) if plan.variables_summary else {}
+    except (json.JSONDecodeError, TypeError):
+        pass
+    return PlanResponse(
+        id=plan.id,
+        project_id=plan.project_id,
+        name=plan.name,
+        plan_id=plan.plan_id,
+        status=plan.status,
+        plan_data=plan.get_plan_dict(),
+        variables_summary=variables_summary,
+        estimated_points=plan.estimated_points or 0,
+        estimated_time_min=plan.estimated_time_min or 0,
+        notes=plan.notes or "",
+        result_count=result_count,
+        created_at=plan.created_at,
+        updated_at=plan.updated_at,
+    )
+
+
+@router.get("", response_model=PlanListResponse)
+def list_plans(
+    project_id: int | None = None,
+    skip: int = 0,
+    limit: int = 50,
+    db: Session = Depends(get_db),
+):
+    """List simulation plans, optionally filtered by project."""
+    query = db.query(SimulationPlan)
+    if project_id:
+        query = query.filter(SimulationPlan.project_id == project_id)
+    total = query.count()
+    plans = query.order_by(SimulationPlan.updated_at.desc()).offset(skip).limit(limit).all()
+    return PlanListResponse(total=total, items=[_plan_to_response(db, p) for p in plans])
+
+
+@router.post("", response_model=PlanResponse, status_code=201)
+def create_plan(data: PlanCreate, db: Session = Depends(get_db)):
+    """Create a new simulation plan."""
+    project = db.query(Project).filter(Project.id == data.project_id).first()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    plan_id = _generate_plan_id()
+    plan_data = data.plan_data
+    plan_data["plan_id"] = plan_id
+
+    # Extract variables summary for display
+    variables_summary = {}
+    estimated_points = 1
+    for var in plan_data.get("variables", []):
+        name = var.get("name", "unknown")
+        values = var.get("values", [])
+        variables_summary[name] = {
+            "unit": var.get("unit", ""),
+            "values": values,
+            "count": len(values),
+        }
+        estimated_points *= len(values) if values else 1
+
+    plan = SimulationPlan(
+        project_id=data.project_id,
+        name=data.name,
+        plan_id=plan_id,
+        status="draft",
+        estimated_points=estimated_points,
+        estimated_time_min=estimated_points * 3,  # ~3 min per point estimate
+        notes=data.notes,
+    )
+    plan.set_plan_dict(plan_data)
+    plan.variables_summary = json.dumps(variables_summary, ensure_ascii=False)
+
+    db.add(plan)
+    db.commit()
+    db.refresh(plan)
+    return _plan_to_response(db, plan)
+
+
+@router.get("/{plan_id}", response_model=PlanResponse)
+def get_plan(plan_id: int, db: Session = Depends(get_db)):
+    """Get a plan by ID."""
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+    return _plan_to_response(db, plan)
+
+
+@router.put("/{plan_id}", response_model=PlanResponse)
+def update_plan(plan_id: int, data: PlanUpdate, db: Session = Depends(get_db)):
+    """Update a plan."""
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+    update_data = data.model_dump(exclude_unset=True)
+    if "plan_data" in update_data:
+        plan.set_plan_dict(update_data.pop("plan_data"))
+    for key, value in update_data.items():
+        setattr(plan, key, value)
+    db.commit()
+    db.refresh(plan)
+    return _plan_to_response(db, plan)
+
+
+@router.delete("/{plan_id}", status_code=204)
+def delete_plan(plan_id: int, db: Session = Depends(get_db)):
+    """Delete a plan and its results."""
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+    db.delete(plan)
+    db.commit()
+    return None
+
+
+# ---------------------------------------------------------------------------
+# API integration endpoints (for local executor)
+# ---------------------------------------------------------------------------
+
+@router.get("/{plan_id}/download", response_model=PlanDownloadResponse)
+def download_plan(plan_id: int, db: Session = Depends(get_db)):
+    """Download a plan as simulation_plan.json (compatible with local executor).
+
+    This is the API endpoint used by the local execution system to fetch plans.
+    Returns the raw plan JSON in the exact format expected by src/plan_schema.py.
+    """
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+    # Mark as executing when downloaded
+    if plan.status == "draft" or plan.status == "confirmed":
+        plan.status = "executing"
+        db.commit()
+    return PlanDownloadResponse(plan_id=plan.plan_id, plan_data=plan.get_plan_dict())
+
+
+@router.get("/by-plan-id/{plan_uuid}/download", response_model=PlanDownloadResponse)
+def download_plan_by_uuid(plan_uuid: str, db: Session = Depends(get_db)):
+    """Download a plan by its plan_id string (e.g. SP-20260827-001)."""
+    plan = db.query(SimulationPlan).filter(SimulationPlan.plan_id == plan_uuid).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+    if plan.status in ("draft", "confirmed"):
+        plan.status = "executing"
+        db.commit()
+    return PlanDownloadResponse(plan_id=plan.plan_id, plan_data=plan.get_plan_dict())
+
+
+@router.post("/{plan_id}/upload-results", status_code=201)
+async def upload_results(
+    plan_id: int,
+    file: UploadFile = File(...),
+    db: Session = Depends(get_db),
+):
+    """Upload scan_results.csv from local executor.
+
+    Parses the CSV and stores each row as a SimulationResult.
+    This is the API endpoint used by the local execution system to push results back.
+    """
+    import csv
+    import io
+
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+
+    # Clear existing results for this plan (re-upload replaces)
+    db.query(SimulationResult).filter(SimulationResult.plan_id == plan_id).delete()
+
+    content = await file.read()
+    text = content.decode("utf-8-sig")
+    reader = csv.DictReader(io.StringIO(text))
+
+    # Known metric keys (from solver_core)
+    metric_keys = {
+        "tavg_nm", "tmax_nm", "tmin_nm", "ripple_pct", "ripple_abs_nm",
+        "efficiency_pct", "output_power_w", "input_power_w", "total_losses_w",
+        "copper_loss_w", "iron_loss_w", "magnet_loss_w", "back_emf_v",
+        "no_load_speed_rpm", "power_factor",
+    }
+    standard_keys = {"run_index", "status", "seconds", "error"}
+
+    count = 0
+    for row in reader:
+        params = {}
+        metrics = {}
+        for key, val in row.items():
+            if key in standard_keys or val == "" or val is None:
+                continue
+            try:
+                fval = float(val)
+            except (ValueError, TypeError):
+                continue
+            if key in metric_keys:
+                metrics[key] = fval
+            else:
+                params[key] = fval
+
+        result = SimulationResult(
+            plan_id=plan_id,
+            run_index=int(row.get("run_index", count + 1)),
+            status=row.get("status", "OK"),
+            solve_time_s=float(row.get("seconds", 0) or 0),
+            error_message=row.get("error", ""),
+        )
+        result.set_params(params)
+        result.set_metrics(metrics)
+        db.add(result)
+        count += 1
+
+    # Update plan status
+    plan.status = "completed"
+    db.commit()
+
+    return {"message": f"Uploaded {count} results", "count": count, "plan_id": plan.plan_id}
+
+
+@router.get("/{plan_id}/results", response_model=ResultListResponse)
+def get_plan_results(plan_id: int, db: Session = Depends(get_db)):
+    """Get all results for a plan."""
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+    results = (
+        db.query(SimulationResult)
+        .filter(SimulationResult.plan_id == plan_id)
+        .order_by(SimulationResult.run_index.asc())
+        .all()
+    )
+    from ..schemas.simulation_result import ResultResponse
+    items = [
+        ResultResponse(
+            id=r.id, plan_id=r.plan_id, run_index=r.run_index,
+            status=r.status, solve_time_s=r.solve_time_s,
+            params=r.get_params(), metrics=r.get_metrics(),
+            error_message=r.error_message or "", created_at=r.created_at,
+        )
+        for r in results
+    ]
+    return ResultListResponse(total=len(items), items=items)

+ 96 - 0
web/backend/app/routers/projects.py

@@ -0,0 +1,96 @@
+"""Project API router."""
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from ..models.project import Project
+from ..schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, ProjectListResponse
+
+router = APIRouter(prefix="/api/projects", tags=["projects"])
+
+
+def _project_to_response(db: Session, project: Project) -> ProjectResponse:
+    plan_count = len(project.plans) if project.plans else 0
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description or "",
+        topology=project.topology or "SSSR",
+        model_path=project.model_path or "",
+        status=project.status or "draft",
+        boundary_conditions=project.get_boundary_conditions(),
+        plan_count=plan_count,
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+    )
+
+
+@router.get("", response_model=ProjectListResponse)
+def list_projects(
+    skip: int = 0,
+    limit: int = 50,
+    topology: str | None = None,
+    db: Session = Depends(get_db),
+):
+    """List all projects with optional topology filter."""
+    query = db.query(Project)
+    if topology:
+        query = query.filter(Project.topology == topology)
+    total = query.count()
+    projects = query.order_by(Project.updated_at.desc()).offset(skip).limit(limit).all()
+    return ProjectListResponse(
+        total=total,
+        items=[_project_to_response(db, p) for p in projects],
+    )
+
+
+@router.post("", response_model=ProjectResponse, status_code=201)
+def create_project(data: ProjectCreate, db: Session = Depends(get_db)):
+    """Create a new project."""
+    project = Project(
+        name=data.name,
+        description=data.description,
+        topology=data.topology,
+        model_path=data.model_path,
+    )
+    project.set_boundary_conditions(data.boundary_conditions)
+    db.add(project)
+    db.commit()
+    db.refresh(project)
+    return _project_to_response(db, project)
+
+
+@router.get("/{project_id}", response_model=ProjectResponse)
+def get_project(project_id: int, db: Session = Depends(get_db)):
+    """Get a project by ID."""
+    project = db.query(Project).filter(Project.id == project_id).first()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+    return _project_to_response(db, project)
+
+
+@router.put("/{project_id}", response_model=ProjectResponse)
+def update_project(project_id: int, data: ProjectUpdate, db: Session = Depends(get_db)):
+    """Update a project."""
+    project = db.query(Project).filter(Project.id == project_id).first()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+    update_data = data.model_dump(exclude_unset=True)
+    if "boundary_conditions" in update_data:
+        project.set_boundary_conditions(update_data.pop("boundary_conditions"))
+    for key, value in update_data.items():
+        setattr(project, key, value)
+    db.commit()
+    db.refresh(project)
+    return _project_to_response(db, project)
+
+
+@router.delete("/{project_id}", status_code=204)
+def delete_project(project_id: int, db: Session = Depends(get_db)):
+    """Delete a project and all its plans/results."""
+    project = db.query(Project).filter(Project.id == project_id).first()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+    db.delete(project)
+    db.commit()
+    return None

+ 13 - 0
web/backend/app/schemas/__init__.py

@@ -0,0 +1,13 @@
+"""Pydantic schemas for API request/response."""
+from .project import ProjectCreate, ProjectUpdate, ProjectResponse, ProjectListResponse
+from .simulation_plan import (
+    PlanCreate, PlanUpdate, PlanResponse, PlanListResponse,
+    PlanDownloadResponse,
+)
+from .simulation_result import ResultCreate, ResultResponse, ResultListResponse
+
+__all__ = [
+    "ProjectCreate", "ProjectUpdate", "ProjectResponse", "ProjectListResponse",
+    "PlanCreate", "PlanUpdate", "PlanResponse", "PlanListResponse", "PlanDownloadResponse",
+    "ResultCreate", "ResultResponse", "ResultListResponse",
+]

+ 42 - 0
web/backend/app/schemas/project.py

@@ -0,0 +1,42 @@
+"""Project schemas."""
+from datetime import datetime
+from typing import Optional, Any
+from pydantic import BaseModel, Field
+
+
+class ProjectCreate(BaseModel):
+    name: str = Field(..., min_length=1, max_length=200)
+    description: str = ""
+    topology: str = "SSSR"
+    model_path: str = ""
+    boundary_conditions: dict[str, Any] = Field(default_factory=dict)
+
+
+class ProjectUpdate(BaseModel):
+    name: Optional[str] = None
+    description: Optional[str] = None
+    topology: Optional[str] = None
+    model_path: Optional[str] = None
+    status: Optional[str] = None
+    boundary_conditions: Optional[dict[str, Any]] = None
+
+
+class ProjectResponse(BaseModel):
+    id: int
+    name: str
+    description: str
+    topology: str
+    model_path: str
+    status: str
+    boundary_conditions: dict[str, Any]
+    plan_count: int = 0
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class ProjectListResponse(BaseModel):
+    total: int
+    items: list[ProjectResponse]

+ 48 - 0
web/backend/app/schemas/simulation_plan.py

@@ -0,0 +1,48 @@
+"""Simulation plan schemas."""
+from datetime import datetime
+from typing import Optional, Any
+from pydantic import BaseModel, Field
+
+
+class PlanCreate(BaseModel):
+    project_id: int
+    name: str = Field(..., min_length=1, max_length=200)
+    plan_data: dict[str, Any] = Field(..., description="Full simulation_plan.json content")
+    notes: str = ""
+
+
+class PlanUpdate(BaseModel):
+    name: Optional[str] = None
+    status: Optional[str] = None
+    plan_data: Optional[dict[str, Any]] = None
+    notes: Optional[str] = None
+
+
+class PlanResponse(BaseModel):
+    id: int
+    project_id: int
+    name: str
+    plan_id: str
+    status: str
+    plan_data: dict[str, Any]
+    variables_summary: dict[str, Any] = Field(default_factory=dict)
+    estimated_points: int = 0
+    estimated_time_min: int = 0
+    notes: str
+    result_count: int = 0
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class PlanListResponse(BaseModel):
+    total: int
+    items: list[PlanResponse]
+
+
+class PlanDownloadResponse(BaseModel):
+    """Response for plan download - returns raw JSON compatible with local executor."""
+    plan_id: str
+    plan_data: dict[str, Any]

+ 38 - 0
web/backend/app/schemas/simulation_result.py

@@ -0,0 +1,38 @@
+"""Simulation result schemas."""
+from datetime import datetime
+from typing import Any
+from pydantic import BaseModel, Field
+
+
+class ResultCreate(BaseModel):
+    run_index: int = 0
+    status: str = "OK"
+    solve_time_s: float = 0.0
+    params: dict[str, Any] = Field(default_factory=dict)
+    metrics: dict[str, Any] = Field(default_factory=dict)
+    error_message: str = ""
+
+
+class ResultBatchCreate(BaseModel):
+    """Batch upload results from scan_results.csv."""
+    results: list[ResultCreate]
+
+
+class ResultResponse(BaseModel):
+    id: int
+    plan_id: int
+    run_index: int
+    status: str
+    solve_time_s: float
+    params: dict[str, Any]
+    metrics: dict[str, Any]
+    error_message: str
+    created_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class ResultListResponse(BaseModel):
+    total: int
+    items: list[ResultResponse]

+ 5 - 0
web/backend/requirements.txt

@@ -0,0 +1,5 @@
+fastapi>=0.104.0
+uvicorn[standard]>=0.24.0
+sqlalchemy>=2.0.0
+pydantic>=2.0.0
+python-multipart>=0.0.6

+ 6 - 0
web/backend/run.py

@@ -0,0 +1,6 @@
+"""Run the FastAPI backend server (local development)."""
+import uvicorn
+from app.config import HOST, PORT
+
+if __name__ == "__main__":
+    uvicorn.run("app.main:app", host=HOST, port=PORT, reload=True)

+ 75 - 0
web/backend/test_api.py

@@ -0,0 +1,75 @@
+"""Quick API test for backend."""
+import json
+import urllib.request
+
+BASE = "http://127.0.0.1:8000"
+
+def req(method, path, data=None):
+    url = BASE + path
+    body = json.dumps(data).encode() if data else None
+    r = urllib.request.Request(url, data=body, method=method)
+    r.add_header("Content-Type", "application/json")
+    try:
+        with urllib.request.urlopen(r) as resp:
+            return json.loads(resp.read())
+    except urllib.error.HTTPError as e:
+        return {"error": e.code, "body": e.read().decode()}
+
+# 1. Health
+print("=== Health ===")
+print(req("GET", "/api/health"))
+
+# 2. Create project
+print("\n=== Create Project ===")
+proj = req("POST", "/api/projects", {
+    "name": "MARS Airgap Optimization",
+    "topology": "SSSR",
+    "description": "Test project for P2-M1 API validation",
+    "model_path": "models/MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot",
+    "boundary_conditions": {"outer_diameter_mm": 76, "speed_rpm": 5000, "current_a": 21},
+})
+print(json.dumps(proj, indent=2))
+proj_id = proj.get("id")
+
+# 3. List projects
+print("\n=== List Projects ===")
+print(req("GET", "/api/projects"))
+
+# 4. Create plan
+print("\n=== Create Plan ===")
+plan_data = {
+    "plan_id": "SP-TEST-001",
+    "model_path": "models/MARS-12S10P_SSSR_D76-C150_V5.0-0819.mot",
+    "topology": "SSSR",
+    "variables": [
+        {"name": "Airgap", "display_name": "Airgap", "unit": "mm", "values": [0.6, 1.0, 1.5]}
+    ],
+    "cases": [],
+}
+plan = req("POST", "/api/plans", {
+    "project_id": proj_id,
+    "name": "Airgap Scan 0.6-1.5mm",
+    "plan_data": plan_data,
+    "notes": "Test plan for API validation",
+})
+print(json.dumps(plan, indent=2))
+plan_id = plan.get("id")
+plan_uuid = plan.get("plan_id")
+
+# 5. Download plan
+print("\n=== Download Plan ===")
+dl = req("GET", f"/api/plans/{plan_id}/download")
+print(f"plan_id: {dl.get('plan_id')}")
+print(f"variables: {dl.get('plan_data', {}).get('variables')}")
+
+# 6. Download by UUID
+print("\n=== Download by UUID ===")
+dl2 = req("GET", f"/api/plans/by-plan-id/{plan_uuid}/download")
+print(f"plan_id: {dl2.get('plan_id')}")
+
+# 7. List plans
+print("\n=== List Plans ===")
+plans = req("GET", f"/api/plans?project_id={proj_id}")
+print(f"total: {plans.get('total')}")
+
+print("\n=== ALL API TESTS PASSED ===")

+ 13 - 0
web/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>PCB Axial Flux Motor Simulation</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 1964 - 0
web/frontend/package-lock.json

@@ -0,0 +1,1964 @@
+{
+  "name": "afm-simulation-web",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "afm-simulation-web",
+      "version": "0.1.0",
+      "dependencies": {
+        "axios": "^1.6.0",
+        "echarts": "^5.4.3",
+        "element-plus": "^2.4.0",
+        "pinia": "^2.1.0",
+        "vue": "^3.3.0",
+        "vue-router": "^4.2.0"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^4.4.0",
+        "typescript": "~5.5.4",
+        "vite": "^5.0.0",
+        "vue-tsc": "^3.3.11"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+      "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.29.7",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+      "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.8",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.8.tgz",
+      "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.29.8"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.8",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.8.tgz",
+      "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.29.7",
+        "@babel/helper-validator-identifier": "^7.29.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
+      "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      }
+    },
+    "node_modules/@element-plus/icons-vue": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
+      "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
+      "license": "MIT",
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@floating-ui/core": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz",
+      "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.12"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz",
+      "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/core": "^1.8.0",
+        "@floating-ui/utils": "^0.2.12"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.12",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz",
+      "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@napi-rs/lzma-linux-x64-gnu": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
+      "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^22.20 || ^24.12 || >=25"
+      }
+    },
+    "node_modules/@popperjs/core": {
+      "name": "@sxzz/popperjs-es",
+      "version": "2.11.8",
+      "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
+      "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz",
+      "integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz",
+      "integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz",
+      "integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz",
+      "integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz",
+      "integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz",
+      "integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz",
+      "integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz",
+      "integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz",
+      "integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz",
+      "integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz",
+      "integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz",
+      "integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz",
+      "integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz",
+      "integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz",
+      "integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz",
+      "integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz",
+      "integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz",
+      "integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz",
+      "integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz",
+      "integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz",
+      "integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz",
+      "integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz",
+      "integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz",
+      "integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz",
+      "integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash": {
+      "version": "4.17.25",
+      "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.25.tgz",
+      "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==",
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash-es": {
+      "version": "4.17.12",
+      "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+      "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/lodash": "*"
+      }
+    },
+    "node_modules/@types/web-bluetooth": {
+      "version": "0.0.21",
+      "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
+      "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz",
+      "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.0.0 || ^5.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@volar/language-core": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz",
+      "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/source-map": "2.4.28"
+      }
+    },
+    "node_modules/@volar/source-map": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz",
+      "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@volar/typescript": {
+      "version": "2.4.28",
+      "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz",
+      "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.28",
+        "path-browserify": "^1.0.1",
+        "vscode-uri": "^3.0.8"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.42.tgz",
+      "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.8",
+        "@vue/shared": "3.5.42",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz",
+      "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz",
+      "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.29.8",
+        "@vue/compiler-core": "3.5.42",
+        "@vue/compiler-dom": "3.5.42",
+        "@vue/compiler-ssr": "3.5.42",
+        "@vue/shared": "3.5.42",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.19",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz",
+      "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/language-core": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.11.tgz",
+      "integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "2.4.28",
+        "@vue/compiler-dom": "^3.5.0",
+        "@vue/shared": "^3.5.0",
+        "alien-signals": "^3.2.1",
+        "muggle-string": "^0.4.1",
+        "path-browserify": "^1.0.1",
+        "picomatch": "^4.0.4"
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.42.tgz",
+      "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.42.tgz",
+      "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz",
+      "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.42",
+        "@vue/runtime-core": "3.5.42",
+        "@vue/shared": "3.5.42",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.42.tgz",
+      "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.42",
+        "@vue/runtime-dom": "3.5.42",
+        "@vue/shared": "3.5.42"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.42.tgz",
+      "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
+      "license": "MIT"
+    },
+    "node_modules/@vueuse/core": {
+      "version": "14.4.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-14.4.0.tgz",
+      "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/web-bluetooth": "^0.0.21",
+        "@vueuse/metadata": "14.4.0",
+        "@vueuse/shared": "14.4.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/@vueuse/metadata": {
+      "version": "14.4.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-14.4.0.tgz",
+      "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/shared": {
+      "version": "14.4.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-14.4.0.tgz",
+      "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/alien-signals": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz",
+      "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.20.0",
+      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.20.0.tgz",
+      "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.16.0",
+        "form-data": "^4.0.6",
+        "https-proxy-agent": "^5.0.1",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.23",
+      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.23.tgz",
+      "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/echarts": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
+      "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "5.6.1"
+      }
+    },
+    "node_modules/element-plus": {
+      "version": "2.14.5",
+      "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.14.5.tgz",
+      "integrity": "sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==",
+      "license": "MIT",
+      "dependencies": {
+        "@ctrl/tinycolor": "^4.2.0",
+        "@element-plus/icons-vue": "^2.3.2",
+        "@floating-ui/dom": "^1.8.0",
+        "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
+        "@types/lodash": "^4.17.24",
+        "@types/lodash-es": "^4.17.12",
+        "@vueuse/core": "14.4.0",
+        "async-validator": "^4.2.5",
+        "dayjs": "^1.11.20",
+        "lodash": "^4.18.1",
+        "lodash-es": "^4.18.1",
+        "lodash-unified": "^1.0.3",
+        "memoize-one": "^6.0.0",
+        "normalize-wheel-es": "^1.2.0",
+        "vue-component-type-helpers": "^3.3.9"
+      },
+      "peerDependencies": {
+        "vue": "^3.3.7"
+      }
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz",
+      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz",
+      "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.4",
+        "mime-types": "^2.1.35"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz",
+      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-unified": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
+      "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/lodash-es": "*",
+        "lodash": "*",
+        "lodash-es": "*"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+      "license": "MIT"
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/muggle-string": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz",
+      "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.18",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.18.tgz",
+      "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/normalize-wheel-es": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
+      "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz",
+      "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pinia": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz",
+      "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.3",
+        "vue-demi": "^0.14.10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.4.4",
+        "vue": "^2.7.0 || ^3.5.11"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.26",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.26.tgz",
+      "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+      "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"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.17",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.63.0",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.0.tgz",
+      "integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.9"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@napi-rs/lzma-linux-x64-gnu": "1.5.1",
+        "@rollup/rollup-android-arm-eabi": "4.63.0",
+        "@rollup/rollup-android-arm64": "4.63.0",
+        "@rollup/rollup-darwin-arm64": "4.63.0",
+        "@rollup/rollup-darwin-x64": "4.63.0",
+        "@rollup/rollup-freebsd-arm64": "4.63.0",
+        "@rollup/rollup-freebsd-x64": "4.63.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.63.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.63.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.63.0",
+        "@rollup/rollup-linux-arm64-musl": "4.63.0",
+        "@rollup/rollup-linux-loong64-gnu": "4.63.0",
+        "@rollup/rollup-linux-loong64-musl": "4.63.0",
+        "@rollup/rollup-linux-ppc64-gnu": "4.63.0",
+        "@rollup/rollup-linux-ppc64-musl": "4.63.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.63.0",
+        "@rollup/rollup-linux-riscv64-musl": "4.63.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.63.0",
+        "@rollup/rollup-linux-x64-gnu": "4.63.0",
+        "@rollup/rollup-linux-x64-musl": "4.63.0",
+        "@rollup/rollup-openbsd-x64": "4.63.0",
+        "@rollup/rollup-openharmony-arm64": "4.63.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.63.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.63.0",
+        "@rollup/rollup-win32-x64-gnu": "4.63.0",
+        "@rollup/rollup-win32-x64-msvc": "4.63.0",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
+    "node_modules/typescript": {
+      "version": "5.5.4",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.5.4.tgz",
+      "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vscode-uri": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.2.0.tgz",
+      "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vue": {
+      "version": "3.5.42",
+      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz",
+      "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.42",
+        "@vue/compiler-sfc": "3.5.42",
+        "@vue/runtime-dom": "3.5.42",
+        "@vue/server-renderer": "3.5.42",
+        "@vue/shared": "3.5.42"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-component-type-helpers": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz",
+      "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==",
+      "license": "MIT"
+    },
+    "node_modules/vue-demi": {
+      "version": "0.14.10",
+      "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
+      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-router": {
+      "version": "4.6.4",
+      "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
+      "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/vue-tsc": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.11.tgz",
+      "integrity": "sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/typescript": "2.4.28",
+        "@vue/language-core": "3.3.11"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": ">=5.0.0"
+      }
+    },
+    "node_modules/zrender": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
+      "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    }
+  }
+}

+ 25 - 0
web/frontend/package.json

@@ -0,0 +1,25 @@
+{
+  "name": "afm-simulation-web",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc && vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "axios": "^1.6.0",
+    "echarts": "^5.4.3",
+    "element-plus": "^2.4.0",
+    "pinia": "^2.1.0",
+    "vue": "^3.3.0",
+    "vue-router": "^4.2.0"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^4.4.0",
+    "typescript": "~5.5.4",
+    "vite": "^5.0.0",
+    "vue-tsc": "^3.3.11"
+  }
+}

+ 6 - 0
web/frontend/src/App.vue

@@ -0,0 +1,6 @@
+<template>
+  <router-view />
+</template>
+
+<script setup lang="ts">
+</script>

+ 48 - 0
web/frontend/src/api/index.ts

@@ -0,0 +1,48 @@
+import axios from 'axios'
+
+const api = axios.create({
+  baseURL: '/api',
+  timeout: 30000,
+  headers: { 'Content-Type': 'application/json' }
+})
+
+// Project APIs
+export const projectApi = {
+  list: (params?: { topology?: string; skip?: number; limit?: number }) =>
+    api.get('/projects', { params }),
+  get: (id: number) => api.get(`/projects/${id}`),
+  create: (data: any) => api.post('/projects', data),
+  update: (id: number, data: any) => api.put(`/projects/${id}`, data),
+  delete: (id: number) => api.delete(`/projects/${id}`)
+}
+
+// Plan APIs
+export const planApi = {
+  list: (params?: { project_id?: number; skip?: number; limit?: number }) =>
+    api.get('/plans', { params }),
+  get: (id: number) => api.get(`/plans/${id}`),
+  create: (data: any) => api.post('/plans', data),
+  update: (id: number, data: any) => api.put(`/plans/${id}`, data),
+  delete: (id: number) => api.delete(`/plans/${id}`),
+  download: (id: number) => api.get(`/plans/${id}/download`),
+  downloadByUuid: (uuid: string) => api.get(`/plans/by-plan-id/${uuid}/download`),
+  uploadResults: (id: number, file: File) => {
+    const formData = new FormData()
+    formData.append('file', file)
+    return api.post(`/plans/${id}/upload-results`, formData, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })
+  },
+  getResults: (id: number) => api.get(`/plans/${id}/results`)
+}
+
+// Experience APIs
+export const experienceApi = {
+  list: (params?: { topology?: string; tag?: string; skip?: number; limit?: number }) =>
+    api.get('/experience', { params }),
+  get: (id: number) => api.get(`/experience/${id}`),
+  create: (data: any) => api.post('/experience', data),
+  delete: (id: number) => api.delete(`/experience/${id}`)
+}
+
+export default api

+ 121 - 0
web/frontend/src/layouts/MainLayout.vue

@@ -0,0 +1,121 @@
+<template>
+  <el-container class="layout-container">
+    <el-aside width="220px" class="sidebar">
+      <div class="logo">
+        <span class="logo-icon">AFM</span>
+        <span class="logo-text">Simulation System</span>
+      </div>
+      <el-menu
+        :default-active="activeMenu"
+        router
+        background-color="#1e293b"
+        text-color="#94a3b8"
+        active-text-color="#3b82f6"
+      >
+        <el-menu-item index="/projects">
+          <el-icon><Folder /></el-icon>
+          <span>Projects</span>
+        </el-menu-item>
+        <el-menu-item index="/experience">
+          <el-icon><Collection /></el-icon>
+          <span>Experience</span>
+        </el-menu-item>
+      </el-menu>
+      <div class="sidebar-footer">
+        <span class="version">v0.1.0 (Phase 2)</span>
+      </div>
+    </el-aside>
+    <el-container>
+      <el-header class="header">
+        <div class="header-left">
+          <el-breadcrumb separator="/">
+            <el-breadcrumb-item :to="{ path: '/projects' }">Home</el-breadcrumb-item>
+            <el-breadcrumb-item v-if="currentTitle">{{ currentTitle }}</el-breadcrumb-item>
+          </el-breadcrumb>
+        </div>
+        <div class="header-right">
+          <el-tag type="success" size="small">Backend Connected</el-tag>
+        </div>
+      </el-header>
+      <el-main class="main-content">
+        <router-view />
+      </el-main>
+    </el-container>
+  </el-container>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+import { Folder, Collection } from '@element-plus/icons-vue'
+
+const route = useRoute()
+const activeMenu = computed(() => route.path)
+const currentTitle = computed(() => route.meta.title as string)
+</script>
+
+<style scoped>
+.layout-container {
+  height: 100vh;
+}
+
+.sidebar {
+  background: #1e293b;
+  display: flex;
+  flex-direction: column;
+}
+
+.logo {
+  height: 60px;
+  display: flex;
+  align-items: center;
+  padding: 0 20px;
+  border-bottom: 1px solid #334155;
+}
+
+.logo-icon {
+  background: #3b82f6;
+  color: #fff;
+  font-weight: 700;
+  font-size: 14px;
+  padding: 4px 8px;
+  border-radius: 4px;
+  margin-right: 10px;
+}
+
+.logo-text {
+  color: #e2e8f0;
+  font-size: 13px;
+  font-weight: 600;
+}
+
+.sidebar :deep(.el-menu) {
+  border-right: none;
+  flex: 1;
+}
+
+.sidebar-footer {
+  padding: 16px 20px;
+  border-top: 1px solid #334155;
+}
+
+.version {
+  color: #64748b;
+  font-size: 11px;
+}
+
+.header {
+  background: #fff;
+  border-bottom: 1px solid #e5e7eb;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 24px;
+}
+
+.main-content {
+  background: #f0f2f5;
+  padding: 0;
+  overflow-y: auto;
+}
+</style>

+ 13 - 0
web/frontend/src/main.ts

@@ -0,0 +1,13 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import App from './App.vue'
+import router from './router'
+import './style.css'
+
+const app = createApp(App)
+app.use(createPinia())
+app.use(router)
+app.use(ElementPlus)
+app.mount('#app')

+ 45 - 0
web/frontend/src/router/index.ts

@@ -0,0 +1,45 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    {
+      path: '/',
+      component: () => import('@/layouts/MainLayout.vue'),
+      redirect: '/projects',
+      children: [
+        {
+          path: 'projects',
+          name: 'ProjectList',
+          component: () => import('@/views/ProjectList.vue'),
+          meta: { title: 'Projects' }
+        },
+        {
+          path: 'projects/:id',
+          name: 'ProjectDetail',
+          component: () => import('@/views/ProjectDetail.vue'),
+          meta: { title: 'Project Detail' }
+        },
+        {
+          path: 'plans/:id',
+          name: 'PlanDetail',
+          component: () => import('@/views/PlanDetail.vue'),
+          meta: { title: 'Plan Detail' }
+        },
+        {
+          path: 'experience',
+          name: 'Experience',
+          component: () => import('@/views/ExperienceList.vue'),
+          meta: { title: 'Experience Library' }
+        }
+      ]
+    }
+  ]
+})
+
+router.beforeEach((to, _from, next) => {
+  document.title = `${to.meta.title || 'AFM Simulation'} - PCB Axial Flux Motor`
+  next()
+})
+
+export default router

+ 58 - 0
web/frontend/src/style.css

@@ -0,0 +1,58 @@
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+html, body, #app {
+  height: 100%;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+body {
+  background: #f0f2f5;
+}
+
+.page-container {
+  padding: 20px;
+}
+
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+}
+
+.page-title {
+  font-size: 20px;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.card {
+  background: #fff;
+  border-radius: 8px;
+  padding: 20px;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+  margin-bottom: 16px;
+}
+
+.stat-card {
+  background: #fff;
+  border-radius: 8px;
+  padding: 16px 20px;
+  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+}
+
+.stat-label {
+  font-size: 12px;
+  color: #6b7280;
+  margin-bottom: 4px;
+}
+
+.stat-value {
+  font-size: 24px;
+  font-weight: 700;
+  color: #1f2937;
+}

+ 78 - 0
web/frontend/src/views/ExperienceList.vue

@@ -0,0 +1,78 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <span class="page-title">Experience Library</span>
+      <el-select v-model="topologyFilter" placeholder="Topology" style="width: 150px" clearable @change="loadExperience">
+        <el-option label="SSSR" value="SSSR" />
+        <el-option label="DRSS" value="DRSS" />
+      </el-select>
+    </div>
+
+    <div class="card">
+      <el-table :data="cases" v-loading="loading" stripe>
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="topology" label="Topology" width="90">
+          <template #default="{ row }">
+            <el-tag size="small">{{ row.topology }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="source_plan_id" label="Source Plan" width="180" />
+        <el-table-column label="Params" min-width="200">
+          <template #default="{ row }">
+            <span v-for="(v, k) in row.params" :key="k" style="margin-right: 12px; font-size: 12px">
+              {{ k }}={{ v }}
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column label="Key Metrics" min-width="250">
+          <template #default="{ row }">
+            <span style="font-size: 12px; color: #6b7280">
+              Tavg={{ row.metrics?.tavg_nm?.toFixed(3) }}Nm,
+              Eff={{ row.metrics?.efficiency_pct?.toFixed(1) }}%,
+              Ripple={{ row.metrics?.ripple_pct?.toFixed(2) }}%
+            </span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="conclusion" label="Conclusion" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="rating" label="Rating" width="80">
+          <template #default="{ row }">
+            <el-rate v-model="row.rating" disabled size="small" />
+          </template>
+        </el-table-column>
+        <el-table-column prop="created_at" label="Created" width="160">
+          <template #default="{ row }">{{ formatDate(row.created_at) }}</template>
+        </el-table-column>
+      </el-table>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { experienceApi } from '@/api'
+
+const loading = ref(false)
+const cases = ref<any[]>([])
+const topologyFilter = ref('')
+
+async function loadExperience() {
+  loading.value = true
+  try {
+    const params: any = { limit: 100 }
+    if (topologyFilter.value) params.topology = topologyFilter.value
+    const res = await experienceApi.list(params)
+    cases.value = res.data.items
+  } catch (e: any) {
+    ElMessage.error('Failed to load experience: ' + e.message)
+  } finally {
+    loading.value = false
+  }
+}
+
+function formatDate(d: string) {
+  return new Date(d).toLocaleString()
+}
+
+onMounted(loadExperience)
+</script>

+ 192 - 0
web/frontend/src/views/PlanDetail.vue

@@ -0,0 +1,192 @@
+<template>
+  <div class="page-container" v-loading="loading">
+    <div class="page-header">
+      <div>
+        <el-button text @click="goBack" style="padding: 0; margin-bottom: 8px">
+          <el-icon><ArrowLeft /></el-icon> Back
+        </el-button>
+        <span class="page-title">{{ plan?.name }}</span>
+        <el-tag size="small" type="primary" style="margin-left: 12px">{{ plan?.plan_id }}</el-tag>
+        <el-tag size="small" :type="planStatusType(plan?.status)" style="margin-left: 8px">{{ plan?.status }}</el-tag>
+      </div>
+      <div>
+        <el-button @click="downloadPlan">
+          <el-icon><Download /></el-icon> Download JSON
+        </el-button>
+        <el-upload :show-file-list="false" :before-upload="uploadResults" accept=".csv">
+          <el-button type="success">
+            <el-icon><Upload /></el-icon> Upload Results
+          </el-button>
+        </el-upload>
+      </div>
+    </div>
+
+    <el-row :gutter="16" v-if="plan">
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">Estimated Points</div>
+          <div class="stat-value">{{ plan.estimated_points }}</div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">Est. Time</div>
+          <div class="stat-value">{{ plan.estimated_time_min }} min</div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">Results Uploaded</div>
+          <div class="stat-value">{{ results.length }}</div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">OK / Failed</div>
+          <div class="stat-value">{{ okCount }} / {{ failedCount }}</div>
+        </div>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="16" v-if="plan">
+      <el-col :span="10">
+        <div class="card">
+          <h3 style="margin-bottom: 12px">Scan Variables</h3>
+          <el-table :data="variablesList" size="small" border>
+            <el-table-column prop="name" label="Variable" width="120" />
+            <el-table-column prop="unit" label="Unit" width="70" />
+            <el-table-column prop="count" label="Count" width="70" />
+            <el-table-column prop="values" label="Values" />
+          </el-table>
+        </div>
+      </el-col>
+      <el-col :span="14">
+        <div class="card">
+          <h3 style="margin-bottom: 12px">Plan JSON</h3>
+          <pre style="background: #f8fafc; padding: 12px; border-radius: 4px; font-size: 11px; max-height: 300px; overflow: auto">{{ JSON.stringify(plan.plan_data, null, 2) }}</pre>
+        </div>
+      </el-col>
+    </el-row>
+
+    <!-- Results Section -->
+    <div class="card" v-if="results.length > 0">
+      <h3 style="margin-bottom: 12px">Simulation Results</h3>
+      <el-table :data="results" stripe border size="small" max-height="400">
+        <el-table-column prop="run_index" label="#" width="50" fixed />
+        <el-table-column v-for="param in paramKeys" :key="param" :label="param" :prop="`params.${param}`" width="100">
+          <template #default="{ row }">{{ row.params[param] }}</template>
+        </el-table-column>
+        <el-table-column prop="metrics.tavg_nm" label="Tavg (Nm)" width="100">
+          <template #default="{ row }">{{ row.metrics.tavg_nm?.toFixed(4) }}</template>
+        </el-table-column>
+        <el-table-column prop="metrics.ripple_pct" label="Ripple (%)" width="100">
+          <template #default="{ row }">{{ row.metrics.ripple_pct?.toFixed(3) }}</template>
+        </el-table-column>
+        <el-table-column prop="metrics.efficiency_pct" label="Eff (%)" width="90">
+          <template #default="{ row }">{{ row.metrics.efficiency_pct?.toFixed(2) }}</template>
+        </el-table-column>
+        <el-table-column prop="metrics.total_losses_w" label="Loss (W)" width="90">
+          <template #default="{ row }">{{ row.metrics.total_losses_w?.toFixed(2) }}</template>
+        </el-table-column>
+        <el-table-column prop="metrics.copper_loss_w" label="Cu (W)" width="80" />
+        <el-table-column prop="metrics.iron_loss_w" label="Fe (W)" width="80" />
+        <el-table-column prop="metrics.back_emf_v" label="BEMF (V)" width="90" />
+        <el-table-column prop="metrics.no_load_speed_rpm" label="NoLoad RPM" width="100" />
+        <el-table-column prop="solve_time_s" label="Time (s)" width="80" />
+        <el-table-column prop="status" label="Status" width="80">
+          <template #default="{ row }">
+            <el-tag size="small" :type="row.status === 'OK' ? 'success' : 'danger'">{{ row.status }}</el-tag>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <el-empty v-else description="No results uploaded yet. Upload scan_results.csv to see results." />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { ArrowLeft, Download, Upload } from '@element-plus/icons-vue'
+import { planApi } from '@/api'
+
+const route = useRoute()
+const router = useRouter()
+const loading = ref(false)
+const plan = ref<any>(null)
+const results = ref<any[]>([])
+
+const variablesList = computed(() => {
+  if (!plan.value?.plan_data?.variables) return []
+  return plan.value.plan_data.variables.map((v: any) => ({
+    name: v.name,
+    unit: v.unit || '',
+    count: v.values?.length || 0,
+    values: v.values?.join(', ') || ''
+  }))
+})
+
+const paramKeys = computed(() => {
+  if (results.value.length === 0) return []
+  return Object.keys(results.value[0].params || {})
+})
+
+const okCount = computed(() => results.value.filter(r => r.status === 'OK').length)
+const failedCount = computed(() => results.value.filter(r => r.status !== 'OK').length)
+
+async function loadPlan() {
+  loading.value = true
+  try {
+    const id = Number(route.params.id)
+    const res = await planApi.get(id)
+    plan.value = res.data
+    const resultsRes = await planApi.getResults(id)
+    results.value = resultsRes.data.items
+  } catch (e: any) {
+    ElMessage.error('Failed to load plan: ' + e.message)
+  } finally {
+    loading.value = false
+  }
+}
+
+function downloadPlan() {
+  const data = JSON.stringify(plan.value.plan_data, null, 2)
+  const blob = new Blob([data], { type: 'application/json' })
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = `${plan.value.plan_id}.json`
+  a.click()
+  URL.revokeObjectURL(url)
+  ElMessage.success('Plan JSON downloaded')
+}
+
+async function uploadResults(file: File) {
+  try {
+    const id = Number(route.params.id)
+    const res = await planApi.uploadResults(id, file)
+    ElMessage.success(`Uploaded ${res.data.count} results`)
+    await loadPlan()
+  } catch (e: any) {
+    ElMessage.error('Upload failed: ' + e.message)
+  }
+  return false
+}
+
+function goBack() {
+  if (plan.value?.project_id) {
+    router.push(`/projects/${plan.value.project_id}`)
+  } else {
+    router.push('/projects')
+  }
+}
+
+function planStatusType(status: string) {
+  const map: Record<string, string> = { draft: 'info', confirmed: 'warning', executing: 'primary', completed: 'success', failed: 'danger' }
+  return map[status] || 'info'
+}
+
+onMounted(loadPlan)
+</script>

+ 176 - 0
web/frontend/src/views/ProjectDetail.vue

@@ -0,0 +1,176 @@
+<template>
+  <div class="page-container" v-loading="loading">
+    <div class="page-header">
+      <div>
+        <el-button text @click="$router.push('/projects')" style="padding: 0; margin-bottom: 8px">
+          <el-icon><ArrowLeft /></el-icon> Back to Projects
+        </el-button>
+        <span class="page-title">{{ project?.name }}</span>
+        <el-tag size="small" style="margin-left: 12px">{{ project?.topology }}</el-tag>
+        <el-tag size="small" type="success" style="margin-left: 8px">{{ project?.status }}</el-tag>
+      </div>
+      <el-button type="primary" @click="showCreatePlan = true">
+        <el-icon><Plus /></el-icon> New Plan
+      </el-button>
+    </div>
+
+    <el-row :gutter="16" v-if="project">
+      <el-col :span="8">
+        <div class="card">
+          <h3 style="margin-bottom: 12px">Project Info</h3>
+          <el-descriptions :column="1" border size="small">
+            <el-descriptions-item label="ID">{{ project.id }}</el-descriptions-item>
+            <el-descriptions-item label="Topology">{{ project.topology }}</el-descriptions-item>
+            <el-descriptions-item label="Model">{{ project.model_path || '-' }}</el-descriptions-item>
+            <el-descriptions-item label="Status">{{ project.status }}</el-descriptions-item>
+            <el-descriptions-item label="Plans">{{ project.plan_count }}</el-descriptions-item>
+            <el-descriptions-item label="Created">{{ formatDate(project.created_at) }}</el-descriptions-item>
+            <el-descriptions-item label="Updated">{{ formatDate(project.updated_at) }}</el-descriptions-item>
+          </el-descriptions>
+          <div v-if="project.description" style="margin-top: 12px">
+            <div style="color: #6b7280; font-size: 12px; margin-bottom: 4px">Description</div>
+            <div style="font-size: 13px">{{ project.description }}</div>
+          </div>
+        </div>
+      </el-col>
+      <el-col :span="16">
+        <div class="card">
+          <h3 style="margin-bottom: 12px">Boundary Conditions</h3>
+          <pre v-if="Object.keys(project.boundary_conditions).length" style="background: #f8fafc; padding: 12px; border-radius: 4px; font-size: 12px">{{ JSON.stringify(project.boundary_conditions, null, 2) }}</pre>
+          <el-empty v-else description="No boundary conditions set" :image-size="60" />
+        </div>
+      </el-col>
+    </el-row>
+
+    <div class="card">
+      <h3 style="margin-bottom: 12px">Simulation Plans</h3>
+      <el-table :data="plans" stripe @row-click="goToPlan">
+        <el-table-column prop="id" label="ID" width="60" />
+        <el-table-column prop="name" label="Name" min-width="200">
+          <template #default="{ row }">
+            <span style="color: #3b82f6; cursor: pointer">{{ row.name }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="plan_id" label="Plan UUID" width="180" />
+        <el-table-column prop="status" label="Status" width="100">
+          <template #default="{ row }">
+            <el-tag size="small" :type="planStatusType(row.status)">{{ row.status }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="estimated_points" label="Points" width="80" />
+        <el-table-column prop="estimated_time_min" label="Est. (min)" width="90" />
+        <el-table-column prop="result_count" label="Results" width="80" />
+        <el-table-column prop="updated_at" label="Updated" width="160">
+          <template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <!-- Create Plan Dialog -->
+    <el-dialog v-model="showCreatePlan" title="Create New Plan" width="600px">
+      <el-form :model="newPlan" label-width="120px">
+        <el-form-item label="Plan Name">
+          <el-input v-model="newPlan.name" placeholder="e.g. Airgap Scan 0.6-1.5mm" />
+        </el-form-item>
+        <el-form-item label="Model Path">
+          <el-input v-model="newPlan.model_path" />
+        </el-form-item>
+        <el-form-item label="Variables (JSON)">
+          <el-input v-model="newPlan.variables_json" type="textarea" :rows="6"
+            placeholder='[{"name":"Airgap","display_name":"Airgap","unit":"mm","values":[0.6,1.0,1.5]}]' />
+        </el-form-item>
+        <el-form-item label="Notes">
+          <el-input v-model="newPlan.notes" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showCreatePlan = false">Cancel</el-button>
+        <el-button type="primary" @click="createPlan" :loading="creating">Create</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { ArrowLeft, Plus } from '@element-plus/icons-vue'
+import { projectApi, planApi } from '@/api'
+
+const route = useRoute()
+const router = useRouter()
+const loading = ref(false)
+const creating = ref(false)
+const project = ref<any>(null)
+const plans = ref<any[]>([])
+const showCreatePlan = ref(false)
+const newPlan = ref({ name: '', model_path: '', variables_json: '', notes: '' })
+
+async function loadProject() {
+  loading.value = true
+  try {
+    const id = Number(route.params.id)
+    const res = await projectApi.get(id)
+    project.value = res.data
+    const plansRes = await planApi.list({ project_id: id, limit: 100 })
+    plans.value = plansRes.data.items
+  } catch (e: any) {
+    ElMessage.error('Failed to load project: ' + e.message)
+  } finally {
+    loading.value = false
+  }
+}
+
+async function createPlan() {
+  if (!newPlan.value.name.trim()) {
+    ElMessage.warning('Please enter a plan name')
+    return
+  }
+  let variables = []
+  try {
+    variables = JSON.parse(newPlan.value.variables_json || '[]')
+  } catch {
+    ElMessage.error('Invalid variables JSON')
+    return
+  }
+  creating.value = true
+  try {
+    const planData = {
+      model_path: newPlan.value.model_path || project.value?.model_path || '',
+      topology: project.value?.topology || 'SSSR',
+      variables,
+      cases: []
+    }
+    await planApi.create({
+      project_id: Number(route.params.id),
+      name: newPlan.value.name,
+      plan_data: planData,
+      notes: newPlan.value.notes
+    })
+    ElMessage.success('Plan created')
+    showCreatePlan.value = false
+    newPlan.value = { name: '', model_path: '', variables_json: '', notes: '' }
+    await loadProject()
+  } catch (e: any) {
+    ElMessage.error('Failed to create plan: ' + e.message)
+  } finally {
+    creating.value = false
+  }
+}
+
+function goToPlan(row: any) {
+  router.push(`/plans/${row.id}`)
+}
+
+function planStatusType(status: string) {
+  const map: Record<string, string> = { draft: 'info', confirmed: 'warning', executing: 'primary', completed: 'success', failed: 'danger' }
+  return map[status] || 'info'
+}
+
+function formatDate(d: string) {
+  return new Date(d).toLocaleString()
+}
+
+onMounted(loadProject)
+</script>

+ 177 - 0
web/frontend/src/views/ProjectList.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="page-container">
+    <div class="page-header">
+      <span class="page-title">Simulation Projects</span>
+      <el-button type="primary" @click="showCreateDialog = true">
+        <el-icon><Plus /></el-icon> New Project
+      </el-button>
+    </div>
+
+    <el-row :gutter="16" class="stats-row">
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">Total Projects</div>
+          <div class="stat-value">{{ total }}</div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">SSSR Topology</div>
+          <div class="stat-value">{{ sssrCount }}</div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">Active</div>
+          <div class="stat-value">{{ activeCount }}</div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">Completed</div>
+          <div class="stat-value">{{ completedCount }}</div>
+        </div>
+      </el-col>
+    </el-row>
+
+    <div class="card">
+      <el-table :data="projects" v-loading="loading" stripe @row-click="goToProject">
+        <el-table-column prop="id" label="ID" width="70" />
+        <el-table-column prop="name" label="Name" min-width="200">
+          <template #default="{ row }">
+            <span style="color: #3b82f6; cursor: pointer; font-weight: 500">{{ row.name }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column prop="topology" label="Topology" width="100">
+          <template #default="{ row }">
+            <el-tag size="small" :type="row.topology === 'SSSR' ? 'primary' : 'warning'">{{ row.topology }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="status" label="Status" width="110">
+          <template #default="{ row }">
+            <el-tag size="small" :type="statusType(row.status)">{{ row.status }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="plan_count" label="Plans" width="80" />
+        <el-table-column prop="description" label="Description" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="updated_at" label="Updated" width="170">
+          <template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
+        </el-table-column>
+        <el-table-column label="Actions" width="100" fixed="right">
+          <template #default="{ row }">
+            <el-button size="small" text type="danger" @click.stop="deleteProject(row.id)">Delete</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <!-- Create Project Dialog -->
+    <el-dialog v-model="showCreateDialog" title="Create New Project" width="500px">
+      <el-form :model="newProject" label-width="120px">
+        <el-form-item label="Project Name">
+          <el-input v-model="newProject.name" placeholder="e.g. MARS Airgap Optimization" />
+        </el-form-item>
+        <el-form-item label="Topology">
+          <el-select v-model="newProject.topology">
+            <el-option label="SSSR (Single Stator Single Rotor)" value="SSSR" />
+            <el-option label="DRSS (Double Rotor Single Stator)" value="DRSS" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="Model Path">
+          <el-input v-model="newProject.model_path" placeholder="models/xxx.mot" />
+        </el-form-item>
+        <el-form-item label="Description">
+          <el-input v-model="newProject.description" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showCreateDialog = false">Cancel</el-button>
+        <el-button type="primary" @click="createProject" :loading="creating">Create</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Plus } from '@element-plus/icons-vue'
+import { projectApi } from '@/api'
+
+const router = useRouter()
+const loading = ref(false)
+const creating = ref(false)
+const projects = ref<any[]>([])
+const total = ref(0)
+const showCreateDialog = ref(false)
+const newProject = ref({ name: '', topology: 'SSSR', model_path: '', description: '' })
+
+const sssrCount = computed(() => projects.value.filter(p => p.topology === 'SSSR').length)
+const activeCount = computed(() => projects.value.filter(p => p.status === 'active').length)
+const completedCount = computed(() => projects.value.filter(p => p.status === 'completed').length)
+
+async function loadProjects() {
+  loading.value = true
+  try {
+    const res = await projectApi.list({ limit: 100 })
+    projects.value = res.data.items
+    total.value = res.data.total
+  } catch (e: any) {
+    ElMessage.error('Failed to load projects: ' + e.message)
+  } finally {
+    loading.value = false
+  }
+}
+
+async function createProject() {
+  if (!newProject.value.name.trim()) {
+    ElMessage.warning('Please enter a project name')
+    return
+  }
+  creating.value = true
+  try {
+    await projectApi.create(newProject.value)
+    ElMessage.success('Project created')
+    showCreateDialog.value = false
+    newProject.value = { name: '', topology: 'SSSR', model_path: '', description: '' }
+    await loadProjects()
+  } catch (e: any) {
+    ElMessage.error('Failed to create project: ' + e.message)
+  } finally {
+    creating.value = false
+  }
+}
+
+async function deleteProject(id: number) {
+  try {
+    await ElMessageBox.confirm('Delete this project and all its plans/results?', 'Confirm', { type: 'warning' })
+    await projectApi.delete(id)
+    ElMessage.success('Project deleted')
+    await loadProjects()
+  } catch (e: any) {
+    if (e !== 'cancel') ElMessage.error('Failed to delete')
+  }
+}
+
+function goToProject(row: any) {
+  router.push(`/projects/${row.id}`)
+}
+
+function statusType(status: string) {
+  const map: Record<string, string> = { draft: 'info', active: 'primary', completed: 'success', archived: 'warning' }
+  return map[status] || 'info'
+}
+
+function formatDate(d: string) {
+  return new Date(d).toLocaleString()
+}
+
+onMounted(loadProjects)
+</script>
+
+<style scoped>
+.stats-row {
+  margin-bottom: 16px;
+}
+</style>

+ 25 - 0
web/frontend/tsconfig.json

@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "preserve",
+    "strict": true,
+    "noUnusedLocals": false,
+    "noUnusedParameters": false,
+    "noFallthroughCasesInSwitch": true,
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  },
+  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
+  "references": [{ "path": "./tsconfig.node.json" }]
+}

+ 10 - 0
web/frontend/tsconfig.node.json

@@ -0,0 +1,10 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "skipLibCheck": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 21 - 0
web/frontend/vite.config.ts

@@ -0,0 +1,21 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { fileURLToPath, URL } from 'node:url'
+
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': fileURLToPath(new URL('./src', import.meta.url))
+    }
+  },
+  server: {
+    port: 5173,
+    proxy: {
+      '/api': {
+        target: 'http://127.0.0.1:8000',
+        changeOrigin: true
+      }
+    }
+  }
+})