Browse Source

feat(P2-M3): experience library enhancement + results analytics dashboard

Backend:
- analytics service: stats/trend/pareto/sensitivity/similar retrieval
- analytics router: 8 new API endpoints
- register analytics router in main.py

Frontend:
- Dashboard.vue: ECharts charts (trend scatter, pareto frontier, sensitivity bar) + stats cards + results table
- ExperienceList.vue: stats cards, multi-filter, detail dialog, similar case search
- router: add /dashboard route
- MainLayout: add Dashboard menu item
- api: add analyticsApi (8 endpoints)

Docs:
- README.md update to V0.4
- CONVERSATION_LOG.md: P2-M3 completion record

Tests:
- test_analytics.py: 6 unit tests all passing
carlin 1 week ago
parent
commit
988543784c

+ 14 - 6
README.md

@@ -4,10 +4,10 @@
 
 | 项目 | 内容 |
 |---|---|
-| 文档版本 | V0.3(Phase 2 P2-M2 完成) |
+| 文档版本 | V0.4(Phase 2 P2-M3 完成) |
 | 作者 | Car.Lin |
 | 启动日期 | 2026-08-27 |
-| 当前状态 | Phase 2 开发中(P2-M2 已完成) |
+| 当前状态 | Phase 2 开发中(P2-M3 已完成) |
 | 设计方案 | [PCB轴向磁通电机自动化仿真系统设计方案介绍.md](PCB轴向磁通电机自动化仿真系统设计方案介绍.md) |
 | 远程仓库 | https://gogsgit.ez4l.com/carlin/pcb-afm-simulation-system |
 
@@ -44,7 +44,7 @@
 |---|---|---|---|
 | P2-M1 | Web端基础框架(FastAPI + Vue3 + SQLite + CRUD API) | ✅ 完成 | `1ea7653` |
 | P2-M2 | 边界条件输入 + 方案编辑器(规则引擎生成方案) | ✅ 完成 | `a95c0db` |
-| P2-M3 | 经验库Web端 + 结果分析仪表盘 | 🔲 待开始 | — |
+| P2-M3 | 经验库Web端增强 + 结果分析仪表盘(ECharts) | ✅ 完成 | 待提交 |
 | P2-M4 | 双系统API联调 + 知识库管理 | 🔲 待开始 | — |
 | P2-M5 | Phase 2 验收 | 🔲 待开始 | — |
 
@@ -106,6 +106,13 @@ npm run dev
 | POST | `/api/projects/{id}/generate-plan` | 为项目生成方案 |
 | GET | `/api/plans/{id}/download` | 下载方案(系统二兼容格式) |
 | POST | `/api/plans/{id}/upload-results` | 上传仿真结果CSV |
+| GET | `/api/analytics/metrics` | 指标定义列表(11项) |
+| GET | `/api/analytics/experience/stats` | 经验库统计(总数/拓扑分布/指标范围) |
+| POST | `/api/analytics/experience/similar` | 相似案例检索(参数距离匹配) |
+| GET | `/api/analytics/plans/{id}/trend` | 参数-指标趋势数据(散点图) |
+| GET | `/api/analytics/plans/{id}/pareto` | Pareto前沿(效率vs损耗) |
+| GET | `/api/analytics/plans/{id}/sensitivity` | 参数敏感性排名(Pearson相关) |
+| GET | `/api/analytics/projects/{id}/overview` | 项目概览统计 |
 
 ---
 
@@ -141,9 +148,10 @@ PCB轴向磁通电机自动化仿真系统/
 │   │   │   ├── database.py            # SQLAlchemy数据库
 │   │   │   ├── models/                # ORM模型(Project/Plan/Result/Experience)
 │   │   │   ├── schemas/               # Pydantic Schema
-│   │   │   ├── routers/               # API路由(projects/plans/experience/generation)
+│   │   │   ├── routers/               # API路由(projects/plans/experience/generation/analytics
 │   │   │   └── services/
-│   │   │       └── rule_engine.py     # 规则引擎(参数注册表/范围推荐/方案生成)
+│   │   │       ├── rule_engine.py     # 规则引擎(参数注册表/范围推荐/方案生成)
+│   │   │       └── analytics.py       # 分析服务(统计/趋势/Pareto/敏感性/相似检索)
 │   │   ├── requirements.txt
 │   │   └── run.py
 │   └── frontend/                       # Vue3前端
@@ -151,7 +159,7 @@ PCB轴向磁通电机自动化仿真系统/
 │       │   ├── api/index.ts           # API调用层
 │       │   ├── router/index.ts        # 路由配置
 │       │   ├── layouts/MainLayout.vue # 主布局
-│       │   └── views/                 # 页面(项目列表/详情/方案详情/经验库)
+│       │   └── views/                 # 页面(项目列表/详情/方案详情/经验库/仪表盘
 │       ├── package.json
 │       └── vite.config.ts
 ├── models/

+ 90 - 1
docs/CONVERSATION_LOG.md

@@ -1,4 +1,4 @@
-# 对话与决策记录
+# 对话与决策记录
 
 > 所有关键决策、技术选择、问题排查均带时间戳记录于此。
 > 格式:`## YYYY-MM-DD HH:MM — 主题`
@@ -422,3 +422,92 @@ Car.Lin(项目负责人)
 - [x] 前端构建通过
 - [x] 全部Python源码纯ASCII
 - [x] Git提交完成(a95c0db)
+
+---
+
+## 2026-08-27 17:45 — P2-M3 完成:经验库Web端增强 + 结果分析仪表盘
+
+### 成果
+
+#### 后端:分析服务(analytics.py,~340行)
+- `web/backend/app/services/analytics.py`:
+  - **METRIC_DEFS**:11项指标定义(转矩/脉动/效率/各类损耗/反电动势/功率/转速),含label/unit/higher_is_better
+  - **compute_experience_stats()**:经验库聚合统计(总数/拓扑分布/指标范围min-max-avg/参数覆盖率/平均评分)
+  - **find_similar_cases()**:相似案例检索(归一化欧氏距离,共享参数匹配,相似度评分0-1)
+  - **compute_trend_data()**:参数-指标趋势数据(散点图用,按X排序,含统计摘要)
+  - **compute_pareto_frontier()**:Pareto前沿计算(双指标支配关系,返回全部点+前沿点)
+  - **compute_sensitivity()**:参数敏感性排名(Pearson相关系数,绝对值排序,正负方向标注)
+
+#### 后端:分析API路由(analytics.py,~180行)
+- `web/backend/app/routers/analytics.py`:
+  - `GET /api/analytics/metrics`:指标定义列表
+  - `GET /api/analytics/experience/stats`:经验库统计(支持拓扑筛选)
+  - `POST /api/analytics/experience/similar`:相似案例检索(body: params,query: topology/top_k/tolerance)
+  - `GET /api/analytics/plans/{id}/trend`:方案趋势数据(param_key + metric_key)
+  - `GET /api/analytics/plans/{id}/pareto`:Pareto前沿(x_metric + y_metric)
+  - `GET /api/analytics/plans/{id}/sensitivity`:参数敏感性(metric_key)
+  - `GET /api/analytics/projects/{id}/overview`:项目概览(方案数/结果数/最佳效率转矩)
+- `web/backend/app/main.py`:已注册analytics路由
+
+#### 前端:结果分析仪表盘(Dashboard.vue,~400行)
+- `web/frontend/src/views/Dashboard.vue`:
+  - **项目/方案选择器**:级联选择,切换自动加载数据
+  - **统计卡片**:总方案数/结果数(OK/Failed)/最佳效率/最佳转矩
+  - **参数-指标趋势图**(ECharts散点图):可切换X参数和Y指标,含平均值参考线
+  - **Pareto前沿图**(ECharts散点+折线):可切换X/Y指标,全部点灰色+前沿点红色高亮
+  - **参数敏感性柱状图**(ECharts横向柱状图):正相关绿色/负相关红色,显示相关系数
+  - **结果数据表**:参数列+关键指标列+状态标签,支持滚动
+  - 响应式布局,窗口resize自动调整图表
+
+#### 前端:经验库页面增强(ExperienceList.vue,~450行)
+- 重写 `web/frontend/src/views/ExperienceList.vue`:
+  - **统计卡片**:总案例数/SSSR-DRSS分布/平均评分/跟踪参数数
+  - **多条件筛选**:关键词搜索(结论/标签)/拓扑/标签/最低评分
+  - **详情弹窗**:点击行打开,含参数表/指标表/结论/标签/评分
+  - **相似案例检索弹窗**:输入8个常用参数值,设置拓扑/结果数,返回相似度进度条+共享参数标签
+  - 表格增强:参数chip样式、指标分三行展示、标签彩色、行点击高亮
+
+#### 前端:路由与布局
+- `router/index.ts`:新增 `/dashboard` 路由
+- `layouts/MainLayout.vue`:侧边栏新增Dashboard菜单项(DataAnalysis图标)
+- `api/index.ts`:新增 `analyticsApi`(8个端点)
+
+### Git提交
+- 待提交(本轮完成后统一提交)
+
+### 验证结果
+1. **分析服务单元测试**(test_analytics.py):6项全部通过
+   - 指标定义:11项
+   - 经验库统计:总数/拓扑/评分/参数覆盖/指标范围正确
+   - 相似检索:找到完全匹配案例(相似度1.0)
+   - 趋势数据:4个OK点按X排序,统计摘要正确
+   - Pareto前沿:4个点全部在前沿(测试数据无支配关系)
+   - 参数敏感性:RMSCurrent正相关0.75,Airgap负相关-0.66
+2. **后端导入验证**:`from app.main import app` 成功,analytics路由已注册
+3. **前端构建**:vite build成功,生成Dashboard.js/css和ExperienceList.js/css
+4. **ASCII检查**:所有新增.py文件纯ASCII
+
+### 设计要点
+1. **分析服务纯函数设计**:所有分析函数接收dict列表,不依赖数据库,便于单元测试和复用
+2. **Pareto前沿算法**:O(n^2)支配关系判断,考虑higher_is_better方向,适合百级数据点
+3. **敏感性分析**:Pearson相关系数,要求参数与指标样本数一致,自动跳过不匹配参数
+4. **相似检索归一化**:按参数值域归一化后计算欧氏距离,避免量纲影响,相似度=1-距离
+5. **ECharts按需初始化**:图表在数据加载后初始化,组件卸载时dispose,避免内存泄漏
+6. **前端图表交互**:所有图表支持指标切换,tooltip显示完整信息,颜色编码方向(正绿负红)
+
+### 待优化(后续里程碑)
+- Dashboard当前基于单方案分析,P2-M4可增加跨方案对比
+- 经验库相似检索当前基于参数距离,P3可结合指标相似度做联合检索
+- 图表可增加导出PNG功能
+- 结果数据表可增加排序和导出CSV
+
+### P2-M3 验收标准
+- [x] 后端分析服务(统计/趋势/Pareto/敏感性/相似检索)
+- [x] 分析API路由(8个新端点)
+- [x] 前端结果分析仪表盘(4类ECharts图表+统计卡片+结果表)
+- [x] 前端经验库页面增强(统计/搜索/筛选/详情/相似检索)
+- [x] 路由和侧边栏更新
+- [x] 分析服务单元测试通过(6项)
+- [x] 前端构建通过
+- [x] 全部Python源码纯ASCII
+- [x] README.md更新至V0.4

+ 2 - 1
web/backend/app/main.py

@@ -4,7 +4,7 @@ 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, generation
+from .routers import projects, plans, experience, generation, analytics
 
 app = FastAPI(
     title=APP_NAME,
@@ -26,6 +26,7 @@ app.include_router(projects.router)
 app.include_router(plans.router)
 app.include_router(experience.router)
 app.include_router(generation.router)
+app.include_router(analytics.router)
 
 
 @app.on_event("startup")

+ 220 - 0
web/backend/app/routers/analytics.py

@@ -0,0 +1,220 @@
+"""Analytics API router (experience stats, result trends, Pareto, sensitivity)."""
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from ..database import get_db
+from ..models.experience_case import ExperienceCase
+from ..models.simulation_plan import SimulationPlan
+from ..models.simulation_result import SimulationResult
+from ..services.analytics import (
+    compute_experience_stats,
+    find_similar_cases,
+    compute_trend_data,
+    compute_pareto_frontier,
+    compute_sensitivity,
+    get_metric_defs,
+)
+
+router = APIRouter(prefix="/api/analytics", tags=["analytics"])
+
+
+def _result_to_dict(r: SimulationResult) -> dict:
+    return {
+        "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 "",
+    }
+
+
+def _case_to_dict(c: ExperienceCase) -> dict:
+    return {
+        "id": c.id,
+        "source_plan_id": c.source_plan_id or "",
+        "topology": c.topology or "SSSR",
+        "model_path": c.model_path or "",
+        "params": c.get_params(),
+        "metrics": c.get_metrics(),
+        "conclusion": c.conclusion or "",
+        "tags": [t.strip() for t in (c.tags or "").split(",") if t.strip()],
+        "rating": c.rating or 0,
+        "created_at": c.created_at,
+    }
+
+
+# ---------------------------------------------------------------------------
+# Metric definitions
+# ---------------------------------------------------------------------------
+
+@router.get("/metrics")
+def list_metrics():
+    """List all available metric definitions for frontend charts."""
+    return {"metrics": get_metric_defs()}
+
+
+# ---------------------------------------------------------------------------
+# Experience library analytics
+# ---------------------------------------------------------------------------
+
+@router.get("/experience/stats")
+def experience_stats(
+    topology: str | None = None,
+    db: Session = Depends(get_db),
+):
+    """Compute aggregate statistics for the experience library."""
+    query = db.query(ExperienceCase)
+    if topology:
+        query = query.filter(ExperienceCase.topology == topology)
+    cases = [_case_to_dict(c) for c in query.all()]
+    return compute_experience_stats(cases)
+
+
+@router.post("/experience/similar")
+def experience_similar(
+    data: dict,
+    topology: str | None = None,
+    top_k: int = Query(5, ge=1, le=50),
+    tolerance: float = Query(0.3, ge=0.0, le=1.0),
+    db: Session = Depends(get_db),
+):
+    """Find experience cases similar to target parameters.
+
+    Request body: {"params": {"Airgap": 1.0, "RMSCurrent": 21, ...}}
+    """
+    target_params = data.get("params", {})
+    if not target_params:
+        raise HTTPException(status_code=400, detail="params field is required")
+
+    query = db.query(ExperienceCase)
+    if topology:
+        query = query.filter(ExperienceCase.topology == topology)
+    cases = [_case_to_dict(c) for c in query.all()]
+    similar = find_similar_cases(target_params, cases, top_k=top_k, tolerance=tolerance)
+    return {"total": len(similar), "items": similar}
+
+
+# ---------------------------------------------------------------------------
+# Simulation result analytics
+# ---------------------------------------------------------------------------
+
+@router.get("/plans/{plan_id}/trend")
+def plan_trend(
+    plan_id: int,
+    param_key: str = Query(..., description="Parameter name for X axis"),
+    metric_key: str = Query(..., description="Metric name for Y axis"),
+    db: Session = Depends(get_db),
+):
+    """Compute trend data for a parameter vs metric scatter plot."""
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+    results = [_result_to_dict(r) for r in db.query(SimulationResult).filter(
+        SimulationResult.plan_id == plan_id
+    ).order_by(SimulationResult.run_index.asc()).all()]
+    return compute_trend_data(results, param_key, metric_key)
+
+
+@router.get("/plans/{plan_id}/pareto")
+def plan_pareto(
+    plan_id: int,
+    x_metric: str = Query("total_losses_w", description="X axis metric"),
+    y_metric: str = Query("efficiency_pct", description="Y axis metric"),
+    db: Session = Depends(get_db),
+):
+    """Compute Pareto frontier for two metrics in 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 = [_result_to_dict(r) for r in db.query(SimulationResult).filter(
+        SimulationResult.plan_id == plan_id
+    ).all()]
+    return compute_pareto_frontier(results, x_metric=x_metric, y_metric=y_metric)
+
+
+@router.get("/plans/{plan_id}/sensitivity")
+def plan_sensitivity(
+    plan_id: int,
+    metric_key: str = Query(..., description="Target metric for sensitivity analysis"),
+    db: Session = Depends(get_db),
+):
+    """Compute parameter sensitivity ranking for a target metric."""
+    plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
+    if not plan:
+        raise HTTPException(status_code=404, detail="Plan not found")
+    results = [_result_to_dict(r) for r in db.query(SimulationResult).filter(
+        SimulationResult.plan_id == plan_id
+    ).all()]
+    sensitivities = compute_sensitivity(results, metric_key)
+    return {"metric": metric_key, "total_params": len(sensitivities), "items": sensitivities}
+
+
+# ---------------------------------------------------------------------------
+# Project-level analytics (aggregate all plans in a project)
+# ---------------------------------------------------------------------------
+
+@router.get("/projects/{project_id}/overview")
+def project_overview(
+    project_id: int,
+    db: Session = Depends(get_db),
+):
+    """Get overview analytics for a project (all plans and results)."""
+    from ..models.project import Project
+
+    project = db.query(Project).filter(Project.id == project_id).first()
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    plans = db.query(SimulationPlan).filter(SimulationPlan.project_id == project_id).all()
+    plan_ids = [p.id for p in plans]
+
+    total_results = 0
+    ok_results = 0
+    failed_results = 0
+    all_results = []
+
+    if plan_ids:
+        results = db.query(SimulationResult).filter(
+            SimulationResult.plan_id.in_(plan_ids)
+        ).all()
+        total_results = len(results)
+        ok_results = sum(1 for r in results if r.status == "OK")
+        failed_results = sum(1 for r in results if r.status != "OK")
+        all_results = [_result_to_dict(r) for r in results]
+
+    # Best efficiency and torque from OK results
+    best_eff = 0.0
+    best_torque = 0.0
+    for r in all_results:
+        if r["status"] == "OK":
+            eff = r["metrics"].get("efficiency_pct", 0)
+            torque = r["metrics"].get("tavg_nm", 0)
+            if isinstance(eff, (int, float)):
+                best_eff = max(best_eff, eff)
+            if isinstance(torque, (int, float)):
+                best_torque = max(best_torque, torque)
+
+    return {
+        "project_id": project_id,
+        "project_name": project.name,
+        "total_plans": len(plans),
+        "total_results": total_results,
+        "ok_results": ok_results,
+        "failed_results": failed_results,
+        "best_efficiency_pct": round(best_eff, 2),
+        "best_torque_nm": round(best_torque, 4),
+        "plans": [
+            {
+                "id": p.id,
+                "name": p.name,
+                "plan_id": p.plan_id,
+                "status": p.status,
+                "estimated_points": p.estimated_points,
+                "result_count": sum(1 for r in all_results if r["plan_id"] == p.id),
+            }
+            for p in plans
+        ],
+    }

+ 395 - 0
web/backend/app/services/analytics.py

@@ -0,0 +1,395 @@
+"""Analytics service for experience library and simulation results.
+
+Provides:
+- Experience library statistics (count, topology distribution, metric ranges)
+- Similar case retrieval (parameter distance matching)
+- Result trend analysis (parameter vs metric scatter/line data)
+- Pareto frontier (efficiency vs torque, losses vs torque)
+- Parameter sensitivity ranking (correlation-based)
+
+All source is ASCII.
+"""
+
+from __future__ import annotations
+
+import math
+from typing import Any
+
+
+# ---------------------------------------------------------------------------
+# Metric definitions (for display and analysis)
+# ---------------------------------------------------------------------------
+
+METRIC_DEFS = {
+    "tavg_nm": {"label": "Average Torque", "unit": "Nm", "higher_is_better": True},
+    "ripple_pct": {"label": "Torque Ripple", "unit": "%", "higher_is_better": False},
+    "efficiency_pct": {"label": "Efficiency", "unit": "%", "higher_is_better": True},
+    "total_losses_w": {"label": "Total Losses", "unit": "W", "higher_is_better": False},
+    "copper_loss_w": {"label": "Copper Loss", "unit": "W", "higher_is_better": False},
+    "iron_loss_w": {"label": "Iron Loss", "unit": "W", "higher_is_better": False},
+    "magnet_loss_w": {"label": "Magnet Loss", "unit": "W", "higher_is_better": False},
+    "back_emf_v": {"label": "Back EMF", "unit": "V", "higher_is_better": True},
+    "output_power_w": {"label": "Output Power", "unit": "W", "higher_is_better": True},
+    "input_power_w": {"label": "Input Power", "unit": "W", "higher_is_better": True},
+    "no_load_speed_rpm": {"label": "No-Load Speed", "unit": "rpm", "higher_is_better": True},
+}
+
+
+def get_metric_defs() -> list[dict[str, Any]]:
+    """Return metric definitions for frontend display."""
+    return [
+        {"key": k, "label": v["label"], "unit": v["unit"], "higher_is_better": v["higher_is_better"]}
+        for k, v in METRIC_DEFS.items()
+    ]
+
+
+# ---------------------------------------------------------------------------
+# Experience library statistics
+# ---------------------------------------------------------------------------
+
+def compute_experience_stats(cases: list[dict[str, Any]]) -> dict[str, Any]:
+    """Compute aggregate statistics from experience cases.
+
+    Args:
+        cases: List of experience case dicts (from _case_to_dict).
+
+    Returns:
+        Stats dict with total, topology distribution, metric ranges, param coverage.
+    """
+    total = len(cases)
+    if total == 0:
+        return {
+            "total": 0,
+            "topology_distribution": {},
+            "metric_ranges": {},
+            "param_coverage": {},
+            "avg_rating": 0,
+        }
+
+    # Topology distribution
+    topology_dist: dict[str, int] = {}
+    for c in cases:
+        topo = c.get("topology", "Unknown")
+        topology_dist[topo] = topology_dist.get(topo, 0) + 1
+
+    # Metric ranges
+    metric_ranges: dict[str, dict[str, float]] = {}
+    for c in cases:
+        metrics = c.get("metrics", {}) or {}
+        for key, val in metrics.items():
+            if not isinstance(val, (int, float)) or math.isnan(val):
+                continue
+            if key not in metric_ranges:
+                metric_ranges[key] = {"min": val, "max": val, "sum": 0.0, "count": 0}
+            r = metric_ranges[key]
+            r["min"] = min(r["min"], val)
+            r["max"] = max(r["max"], val)
+            r["sum"] += val
+            r["count"] += 1
+
+    # Compute averages
+    for key, r in metric_ranges.items():
+        r["avg"] = round(r["sum"] / r["count"], 4) if r["count"] > 0 else 0
+        del r["sum"]
+
+    # Parameter coverage (which params appear in how many cases)
+    param_coverage: dict[str, int] = {}
+    for c in cases:
+        params = c.get("params", {}) or {}
+        for key in params:
+            param_coverage[key] = param_coverage.get(key, 0) + 1
+
+    # Average rating
+    ratings = [c.get("rating", 0) for c in cases if c.get("rating", 0) > 0]
+    avg_rating = round(sum(ratings) / len(ratings), 2) if ratings else 0
+
+    return {
+        "total": total,
+        "topology_distribution": topology_dist,
+        "metric_ranges": metric_ranges,
+        "param_coverage": param_coverage,
+        "avg_rating": avg_rating,
+    }
+
+
+# ---------------------------------------------------------------------------
+# Similar case retrieval
+# ---------------------------------------------------------------------------
+
+def find_similar_cases(
+    target_params: dict[str, float],
+    cases: list[dict[str, Any]],
+    top_k: int = 5,
+    tolerance: float = 0.3,
+) -> list[dict[str, Any]]:
+    """Find experience cases similar to target parameters.
+
+    Uses normalized Euclidean distance on shared parameters.
+    Only cases sharing at least one parameter with the target are considered.
+
+    Args:
+        target_params: Dict of parameter_name -> value.
+        cases: List of experience case dicts.
+        top_k: Number of top matches to return.
+        tolerance: Maximum normalized distance to consider (0.0-1.0).
+
+    Returns:
+        List of similar cases with added 'similarity_score' field (0-1, higher=more similar).
+    """
+    if not target_params or not cases:
+        return []
+
+    # Compute value ranges for normalization
+    all_params: dict[str, list[float]] = {}
+    for c in cases:
+        params = c.get("params", {}) or {}
+        for key, val in params.items():
+            if isinstance(val, (int, float)):
+                all_params.setdefault(key, []).append(float(val))
+
+    param_ranges: dict[str, float] = {}
+    for key, vals in all_params.items():
+        rng = max(vals) - min(vals) if len(vals) > 1 else 1.0
+        param_ranges[key] = rng if rng > 0 else 1.0
+
+    scored = []
+    for c in cases:
+        params = c.get("params", {}) or {}
+        shared = [k for k in target_params if k in params]
+        if not shared:
+            continue
+
+        # Normalized Euclidean distance
+        dist_sq = 0.0
+        for key in shared:
+            tv = float(target_params[key])
+            cv = float(params[key])
+            rng = param_ranges.get(key, 1.0)
+            dist_sq += ((tv - cv) / rng) ** 2
+        dist = math.sqrt(dist_sq / len(shared))
+
+        if dist > tolerance:
+            continue
+
+        similarity = max(0.0, 1.0 - dist)
+        result = dict(c)
+        result["similarity_score"] = round(similarity, 4)
+        result["shared_params"] = shared
+        scored.append(result)
+
+    scored.sort(key=lambda x: x["similarity_score"], reverse=True)
+    return scored[:top_k]
+
+
+# ---------------------------------------------------------------------------
+# Result trend analysis
+# ---------------------------------------------------------------------------
+
+def compute_trend_data(
+    results: list[dict[str, Any]],
+    param_key: str,
+    metric_key: str,
+) -> dict[str, Any]:
+    """Compute trend data for a parameter vs metric scatter plot.
+
+    Args:
+        results: List of simulation result dicts (with params and metrics).
+        param_key: Parameter name for X axis.
+        metric_key: Metric name for Y axis.
+
+    Returns:
+        Dict with x_key, y_key, points (list of [x, y] sorted by x), and stats.
+    """
+    points = []
+    for r in results:
+        if r.get("status") != "OK":
+            continue
+        params = r.get("params", {}) or {}
+        metrics = r.get("metrics", {}) or {}
+        if param_key in params and metric_key in metrics:
+            try:
+                x = float(params[param_key])
+                y = float(metrics[metric_key])
+                if not math.isnan(x) and not math.isnan(y):
+                    points.append([x, y])
+            except (ValueError, TypeError):
+                continue
+
+    points.sort(key=lambda p: p[0])
+
+    # Basic stats
+    if points:
+        ys = [p[1] for p in points]
+        stats = {
+            "count": len(points),
+            "min": round(min(ys), 4),
+            "max": round(max(ys), 4),
+            "avg": round(sum(ys) / len(ys), 4),
+        }
+    else:
+        stats = {"count": 0, "min": 0, "max": 0, "avg": 0}
+
+    return {
+        "x_key": param_key,
+        "y_key": metric_key,
+        "x_label": METRIC_DEFS.get(param_key, {}).get("label", param_key),
+        "y_label": METRIC_DEFS.get(metric_key, {}).get("label", metric_key),
+        "y_unit": METRIC_DEFS.get(metric_key, {}).get("unit", ""),
+        "points": points,
+        "stats": stats,
+    }
+
+
+# ---------------------------------------------------------------------------
+# Pareto frontier
+# ---------------------------------------------------------------------------
+
+def compute_pareto_frontier(
+    results: list[dict[str, Any]],
+    x_metric: str = "total_losses_w",
+    y_metric: str = "efficiency_pct",
+) -> dict[str, Any]:
+    """Compute Pareto frontier for two metrics.
+
+    A point is Pareto-optimal if no other point is better in both metrics
+    (considering higher_is_better for each).
+
+    Args:
+        results: List of simulation result dicts.
+        x_metric: Metric for X axis (typically losses, lower is better).
+        y_metric: Metric for Y axis (typically efficiency, higher is better).
+
+    Returns:
+        Dict with all_points, pareto_points, and labels.
+    """
+    x_higher_better = METRIC_DEFS.get(x_metric, {}).get("higher_is_better", True)
+    y_higher_better = METRIC_DEFS.get(y_metric, {}).get("higher_is_better", True)
+
+    all_points = []
+    for r in results:
+        if r.get("status") != "OK":
+            continue
+        metrics = r.get("metrics", {}) or {}
+        if x_metric in metrics and y_metric in metrics:
+            try:
+                x = float(metrics[x_metric])
+                y = float(metrics[y_metric])
+                if not math.isnan(x) and not math.isnan(y):
+                    all_points.append({"x": x, "y": y, "params": r.get("params", {})})
+            except (ValueError, TypeError):
+                continue
+
+    # Find Pareto-optimal points
+    pareto = []
+    for i, p in enumerate(all_points):
+        dominated = False
+        for j, q in enumerate(all_points):
+            if i == j:
+                continue
+            # q dominates p if q is better or equal in both, and strictly better in at least one
+            x_better = (q["x"] >= p["x"]) if x_higher_better else (q["x"] <= p["x"])
+            y_better = (q["y"] >= p["y"]) if y_higher_better else (q["y"] <= p["y"])
+            x_strict = (q["x"] > p["x"]) if x_higher_better else (q["x"] < p["x"])
+            y_strict = (q["y"] > p["y"]) if y_higher_better else (q["y"] < p["y"])
+            if x_better and y_better and (x_strict or y_strict):
+                dominated = True
+                break
+        if not dominated:
+            pareto.append(p)
+
+    # Sort pareto by x
+    pareto.sort(key=lambda p: p["x"])
+
+    return {
+        "x_metric": x_metric,
+        "y_metric": y_metric,
+        "x_label": METRIC_DEFS.get(x_metric, {}).get("label", x_metric),
+        "y_label": METRIC_DEFS.get(y_metric, {}).get("label", y_metric),
+        "x_unit": METRIC_DEFS.get(x_metric, {}).get("unit", ""),
+        "y_unit": METRIC_DEFS.get(y_metric, {}).get("unit", ""),
+        "all_points": [{"x": p["x"], "y": p["y"]} for p in all_points],
+        "pareto_points": [{"x": p["x"], "y": p["y"], "params": p["params"]} for p in pareto],
+        "total_count": len(all_points),
+        "pareto_count": len(pareto),
+    }
+
+
+# ---------------------------------------------------------------------------
+# Parameter sensitivity ranking
+# ---------------------------------------------------------------------------
+
+def compute_sensitivity(
+    results: list[dict[str, Any]],
+    metric_key: str,
+) -> list[dict[str, Any]]:
+    """Compute parameter sensitivity ranking for a target metric.
+
+    Uses Pearson correlation coefficient between each parameter and the metric.
+    Absolute value indicates sensitivity strength; sign indicates direction.
+
+    Args:
+        results: List of simulation result dicts.
+        metric_key: Target metric key.
+
+    Returns:
+        List of {param, correlation, abs_correlation, direction} sorted by abs correlation.
+    """
+    # Collect parameter values and metric values
+    param_values: dict[str, list[float]] = {}
+    metric_values: list[float] = []
+
+    for r in results:
+        if r.get("status") != "OK":
+            continue
+        metrics = r.get("metrics", {}) or {}
+        if metric_key not in metrics:
+            continue
+        try:
+            mv = float(metrics[metric_key])
+        except (ValueError, TypeError):
+            continue
+        if math.isnan(mv):
+            continue
+
+        params = r.get("params", {}) or {}
+        for key, val in params.items():
+            try:
+                pv = float(val)
+                if not math.isnan(pv):
+                    param_values.setdefault(key, []).append(pv)
+            except (ValueError, TypeError):
+                continue
+        metric_values.append(mv)
+
+    n = len(metric_values)
+    if n < 3:
+        return []
+
+    sensitivities = []
+    for param, pvals in param_values.items():
+        if len(pvals) != n:
+            continue  # Must have same number of samples
+
+        # Pearson correlation
+        mean_p = sum(pvals) / n
+        mean_m = sum(metric_values) / n
+        cov = sum((pvals[i] - mean_p) * (metric_values[i] - mean_m) for i in range(n))
+        var_p = sum((p - mean_p) ** 2 for p in pvals)
+        var_m = sum((m - mean_m) ** 2 for m in metric_values)
+
+        if var_p == 0 or var_m == 0:
+            corr = 0.0
+        else:
+            corr = cov / math.sqrt(var_p * var_m)
+
+        direction = "positive" if corr >= 0 else "negative"
+        sensitivities.append({
+            "param": param,
+            "correlation": round(corr, 4),
+            "abs_correlation": round(abs(corr), 4),
+            "direction": direction,
+            "sample_count": n,
+        })
+
+    sensitivities.sort(key=lambda x: x["abs_correlation"], reverse=True)
+    return sensitivities

+ 83 - 0
web/backend/test_analytics.py

@@ -0,0 +1,83 @@
+"""Test analytics service logic without database."""
+import sys
+sys.path.insert(0, '.')
+
+from app.services.analytics import (
+    compute_experience_stats,
+    find_similar_cases,
+    compute_trend_data,
+    compute_pareto_frontier,
+    compute_sensitivity,
+    get_metric_defs,
+)
+
+# Test data
+mock_cases = [
+    {
+        "id": 1, "topology": "SSSR", "source_plan_id": "plan-001",
+        "params": {"Airgap": 1.0, "RMSCurrent": 21, "MagnetThickness": 5},
+        "metrics": {"tavg_nm": 2.5, "efficiency_pct": 85.2, "ripple_pct": 3.5},
+        "conclusion": "Good baseline", "tags": ["baseline", "sssr"], "rating": 4,
+    },
+    {
+        "id": 2, "topology": "SSSR", "source_plan_id": "plan-002",
+        "params": {"Airgap": 1.2, "RMSCurrent": 21, "MagnetThickness": 5},
+        "metrics": {"tavg_nm": 2.3, "efficiency_pct": 84.8, "ripple_pct": 3.2},
+        "conclusion": "Larger airgap reduces torque", "tags": ["airgap-study"], "rating": 3,
+    },
+    {
+        "id": 3, "topology": "DRSS", "source_plan_id": "plan-003",
+        "params": {"Airgap": 1.0, "RMSCurrent": 25, "MagnetThickness": 6},
+        "metrics": {"tavg_nm": 3.1, "efficiency_pct": 87.5, "ripple_pct": 2.8},
+        "conclusion": "DRSS higher torque", "tags": ["drss", "high-torque"], "rating": 5,
+    },
+]
+
+mock_results = [
+    {"status": "OK", "params": {"Airgap": 0.8, "RMSCurrent": 20}, "metrics": {"tavg_nm": 2.8, "efficiency_pct": 86.0, "total_losses_w": 50}},
+    {"status": "OK", "params": {"Airgap": 1.0, "RMSCurrent": 20}, "metrics": {"tavg_nm": 2.5, "efficiency_pct": 85.2, "total_losses_w": 48}},
+    {"status": "OK", "params": {"Airgap": 1.2, "RMSCurrent": 20}, "metrics": {"tavg_nm": 2.3, "efficiency_pct": 84.8, "total_losses_w": 45}},
+    {"status": "OK", "params": {"Airgap": 1.0, "RMSCurrent": 25}, "metrics": {"tavg_nm": 3.0, "efficiency_pct": 86.5, "total_losses_w": 55}},
+    {"status": "FAILED", "params": {"Airgap": 1.5, "RMSCurrent": 20}, "metrics": {}, "error_message": "convergence failed"},
+]
+
+print("=== 1. Metric Definitions ===")
+metrics = get_metric_defs()
+print(f"Total metrics: {len(metrics)}")
+for m in metrics[:3]:
+    print(f"  {m['key']}: {m['label']} ({m['unit']})")
+
+print("\n=== 2. Experience Stats ===")
+stats = compute_experience_stats(mock_cases)
+print(f"Total: {stats['total']}")
+print(f"Topology: {stats['topology_distribution']}")
+print(f"Avg rating: {stats['avg_rating']}")
+print(f"Param coverage: {stats['param_coverage']}")
+print(f"Metric ranges keys: {list(stats['metric_ranges'].keys())}")
+
+print("\n=== 3. Similar Case Search ===")
+target = {"Airgap": 1.0, "RMSCurrent": 21}
+similar = find_similar_cases(target, mock_cases, top_k=3, tolerance=0.5)
+print(f"Found {len(similar)} similar cases for {target}")
+for s in similar:
+    print(f"  Case #{s['id']}: similarity={s['similarity_score']}, shared={s['shared_params']}")
+
+print("\n=== 4. Trend Data ===")
+trend = compute_trend_data(mock_results, "Airgap", "tavg_nm")
+print(f"X: {trend['x_key']}, Y: {trend['y_key']}")
+print(f"Points: {trend['points']}")
+print(f"Stats: {trend['stats']}")
+
+print("\n=== 5. Pareto Frontier ===")
+pareto = compute_pareto_frontier(mock_results, x_metric="total_losses_w", y_metric="efficiency_pct")
+print(f"Total points: {pareto['total_count']}, Pareto points: {pareto['pareto_count']}")
+for p in pareto['pareto_points']:
+    print(f"  losses={p['x']}W, eff={p['y']}%")
+
+print("\n=== 6. Parameter Sensitivity ===")
+sens = compute_sensitivity(mock_results, "tavg_nm")
+print(f"Params analyzed: {len(sens)}")
+for s in sens:
+    print(f"  {s['param']}: corr={s['correlation']} ({s['direction']})")
+
+print("\n=== ALL TESTS PASSED ===")

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

@@ -57,4 +57,21 @@ export const generationApi = {
     api.post(`/projects/${projectId}/generate-plan`, data)
 }
 
+// Analytics APIs
+export const analyticsApi = {
+  listMetrics: () => api.get('/analytics/metrics'),
+  experienceStats: (topology?: string) =>
+    api.get('/analytics/experience/stats', { params: topology ? { topology } : {} }),
+  experienceSimilar: (params: Record<string, number>, topology?: string, topK = 5, tolerance = 0.3) =>
+    api.post('/analytics/experience/similar', { params }, { params: { topology, top_k: topK, tolerance } }),
+  planTrend: (planId: number, paramKey: string, metricKey: string) =>
+    api.get(`/analytics/plans/${planId}/trend`, { params: { param_key: paramKey, metric_key: metricKey } }),
+  planPareto: (planId: number, xMetric = 'total_losses_w', yMetric = 'efficiency_pct') =>
+    api.get(`/analytics/plans/${planId}/pareto`, { params: { x_metric: xMetric, y_metric: yMetric } }),
+  planSensitivity: (planId: number, metricKey: string) =>
+    api.get(`/analytics/plans/${planId}/sensitivity`, { params: { metric_key: metricKey } }),
+  projectOverview: (projectId: number) =>
+    api.get(`/analytics/projects/${projectId}/overview`)
+}
+
 export default api

+ 5 - 1
web/frontend/src/layouts/MainLayout.vue

@@ -20,6 +20,10 @@
           <el-icon><Collection /></el-icon>
           <span>Experience</span>
         </el-menu-item>
+        <el-menu-item index="/dashboard">
+          <el-icon><DataAnalysis /></el-icon>
+          <span>Dashboard</span>
+        </el-menu-item>
       </el-menu>
       <div class="sidebar-footer">
         <span class="version">v0.1.0 (Phase 2)</span>
@@ -47,7 +51,7 @@
 <script setup lang="ts">
 import { computed } from 'vue'
 import { useRoute } from 'vue-router'
-import { Folder, Collection } from '@element-plus/icons-vue'
+import { Folder, Collection, DataAnalysis } from '@element-plus/icons-vue'
 
 const route = useRoute()
 const activeMenu = computed(() => route.path)

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

@@ -31,6 +31,12 @@ const router = createRouter({
           name: 'Experience',
           component: () => import('@/views/ExperienceList.vue'),
           meta: { title: 'Experience Library' }
+        },
+        {
+          path: 'dashboard',
+          name: 'Dashboard',
+          component: () => import('@/views/Dashboard.vue'),
+          meta: { title: 'Results Dashboard' }
         }
       ]
     }

+ 426 - 0
web/frontend/src/views/Dashboard.vue

@@ -0,0 +1,426 @@
+<template>
+  <div class="page-container" v-loading="loading">
+    <div class="page-header">
+      <span class="page-title">Results Dashboard</span>
+      <div class="header-controls">
+        <el-select v-model="selectedProject" placeholder="Select Project" style="width: 220px" @change="onProjectChange" clearable>
+          <el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
+        </el-select>
+        <el-select v-model="selectedPlan" placeholder="Select Plan" style="width: 220px" @change="loadAllCharts" :disabled="!selectedProject" clearable>
+          <el-option v-for="p in planList" :key="p.id" :label="`${p.name} (${p.result_count || 0} results)`" :value="p.id" />
+        </el-select>
+      </div>
+    </div>
+
+    <!-- Stats Cards -->
+    <el-row :gutter="16" class="stats-row" v-if="overview">
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">Total Plans</div>
+          <div class="stat-value">{{ overview.total_plans }}</div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">Results (OK/Failed)</div>
+          <div class="stat-value">{{ overview.ok_results }}<span class="stat-sub">/{{ overview.total_results }}</span></div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card stat-success">
+          <div class="stat-label">Best Efficiency</div>
+          <div class="stat-value">{{ overview.best_efficiency_pct }}<span class="stat-sub">%</span></div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card stat-primary">
+          <div class="stat-label">Best Torque</div>
+          <div class="stat-value">{{ overview.best_torque_nm }}<span class="stat-sub">Nm</span></div>
+        </div>
+      </el-col>
+    </el-row>
+
+    <!-- Chart Controls -->
+    <el-row :gutter="16" class="chart-controls" v-if="selectedPlan">
+      <el-col :span="12">
+        <div class="card">
+          <div class="card-header">
+            <h3>Parameter vs Metric Trend</h3>
+            <div class="chart-selectors">
+              <el-select v-model="trendParam" size="small" style="width: 130px" @change="loadTrendChart">
+                <el-option v-for="p in availableParams" :key="p" :label="p" :value="p" />
+              </el-select>
+              <el-select v-model="trendMetric" size="small" style="width: 150px" @change="loadTrendChart">
+                <el-option v-for="m in metricOptions" :key="m.key" :label="`${m.label} (${m.unit})`" :value="m.key" />
+              </el-select>
+            </div>
+          </div>
+          <div ref="trendChartRef" class="chart-container"></div>
+        </div>
+      </el-col>
+      <el-col :span="12">
+        <div class="card">
+          <div class="card-header">
+            <h3>Pareto Frontier</h3>
+            <div class="chart-selectors">
+              <el-select v-model="paretoX" size="small" style="width: 140px" @change="loadParetoChart">
+                <el-option v-for="m in metricOptions" :key="m.key" :label="m.label" :value="m.key" />
+              </el-select>
+              <span style="color: #909399; font-size: 12px">vs</span>
+              <el-select v-model="paretoY" size="small" style="width: 140px" @change="loadParetoChart">
+                <el-option v-for="m in metricOptions" :key="m.key" :label="m.label" :value="m.key" />
+              </el-select>
+            </div>
+          </div>
+          <div ref="paretoChartRef" class="chart-container"></div>
+        </div>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="16" class="chart-controls" v-if="selectedPlan">
+      <el-col :span="12">
+        <div class="card">
+          <div class="card-header">
+            <h3>Parameter Sensitivity</h3>
+            <el-select v-model="sensitivityMetric" size="small" style="width: 180px" @change="loadSensitivityChart">
+              <el-option v-for="m in metricOptions" :key="m.key" :label="`${m.label} (${m.unit})`" :value="m.key" />
+            </el-select>
+          </div>
+          <div ref="sensitivityChartRef" class="chart-container"></div>
+        </div>
+      </el-col>
+      <el-col :span="12">
+        <div class="card">
+          <div class="card-header">
+            <h3>Results Table</h3>
+            <el-tag size="small" type="info">{{ filteredResults.length }} points</el-tag>
+          </div>
+          <el-table :data="filteredResults" size="small" stripe max-height="320">
+            <el-table-column prop="run_index" label="#" width="50" fixed />
+            <el-table-column v-for="p in availableParams" :key="p" :label="p" :prop="`params.${p}`" width="90">
+              <template #default="{ row }">{{ row.params?.[p] }}</template>
+            </el-table-column>
+            <el-table-column prop="metrics.tavg_nm" label="Tavg" width="80">
+              <template #default="{ row }">{{ row.metrics?.tavg_nm?.toFixed(3) }}</template>
+            </el-table-column>
+            <el-table-column prop="metrics.efficiency_pct" label="Eff%" width="70">
+              <template #default="{ row }">{{ row.metrics?.efficiency_pct?.toFixed(1) }}</template>
+            </el-table-column>
+            <el-table-column prop="metrics.ripple_pct" label="Rip%" width="70">
+              <template #default="{ row }">{{ row.metrics?.ripple_pct?.toFixed(2) }}</template>
+            </el-table-column>
+            <el-table-column prop="status" label="Status" width="70">
+              <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-col>
+    </el-row>
+
+    <el-empty v-if="!selectedProject && !loading" description="Select a project to view analytics dashboard" :image-size="80" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import * as echarts from 'echarts'
+import { projectApi, planApi, analyticsApi } from '@/api'
+
+const loading = ref(false)
+const projects = ref<any[]>([])
+const selectedProject = ref<number | null>(null)
+const selectedPlan = ref<number | null>(null)
+const planList = ref<any[]>([])
+const overview = ref<any>(null)
+const planResults = ref<any[]>([])
+
+// Chart refs
+const trendChartRef = ref<HTMLElement>()
+const paretoChartRef = ref<HTMLElement>()
+const sensitivityChartRef = ref<HTMLElement>()
+let trendChart: echarts.ECharts | null = null
+let paretoChart: echarts.ECharts | null = null
+let sensitivityChart: echarts.ECharts | null = null
+
+// Chart controls
+const trendParam = ref('Airgap')
+const trendMetric = ref('tavg_nm')
+const paretoX = ref('total_losses_w')
+const paretoY = ref('efficiency_pct')
+const sensitivityMetric = ref('tavg_nm')
+
+const metricOptions = ref<any[]>([])
+const availableParams = computed(() => {
+  const params = new Set<string>()
+  for (const r of planResults.value) {
+    if (r.params) {
+      for (const k of Object.keys(r.params)) params.add(k)
+    }
+  }
+  return Array.from(params)
+})
+
+const filteredResults = computed(() => planResults.value.filter(r => r.status === 'OK'))
+
+async function loadProjects() {
+  try {
+    const res = await projectApi.list({ limit: 100 })
+    projects.value = res.data.items
+  } catch (e: any) {
+    ElMessage.error('Failed to load projects: ' + e.message)
+  }
+}
+
+async function loadMetrics() {
+  try {
+    const res = await analyticsApi.listMetrics()
+    metricOptions.value = res.data.metrics
+  } catch (e: any) {
+    console.error('Failed to load metrics:', e)
+  }
+}
+
+async function onProjectChange(projectId: number | null) {
+  selectedPlan.value = null
+  planList.value = []
+  overview.value = null
+  planResults.value = []
+  if (!projectId) return
+  loading.value = true
+  try {
+    const [overviewRes, plansRes] = await Promise.all([
+      analyticsApi.projectOverview(projectId),
+      planApi.list({ project_id: projectId, limit: 100 })
+    ])
+    overview.value = overviewRes.data
+    planList.value = plansRes.data.items
+  } catch (e: any) {
+    ElMessage.error('Failed to load project overview: ' + e.message)
+  } finally {
+    loading.value = false
+  }
+}
+
+async function loadPlanResults() {
+  if (!selectedPlan.value) return
+  try {
+    const res = await planApi.getResults(selectedPlan.value)
+    planResults.value = res.data.items
+    // Set default trend param from available params
+    if (availableParams.value.length > 0 && !availableParams.value.includes(trendParam.value)) {
+      trendParam.value = availableParams.value[0]
+    }
+  } catch (e: any) {
+    ElMessage.error('Failed to load plan results: ' + e.message)
+  }
+}
+
+async function loadAllCharts() {
+  if (!selectedPlan.value) return
+  loading.value = true
+  await loadPlanResults()
+  await nextTick()
+  initCharts()
+  loadTrendChart()
+  loadParetoChart()
+  loadSensitivityChart()
+  loading.value = false
+}
+
+function initCharts() {
+  if (trendChartRef.value && !trendChart) {
+    trendChart = echarts.init(trendChartRef.value)
+  }
+  if (paretoChartRef.value && !paretoChart) {
+    paretoChart = echarts.init(paretoChartRef.value)
+  }
+  if (sensitivityChartRef.value && !sensitivityChart) {
+    sensitivityChart = echarts.init(sensitivityChartRef.value)
+  }
+}
+
+async function loadTrendChart() {
+  if (!selectedPlan.value || !trendChart) return
+  try {
+    const res = await analyticsApi.planTrend(selectedPlan.value, trendParam.value, trendMetric.value)
+    const data = res.data
+    const metricDef = metricOptions.value.find(m => m.key === trendMetric.value)
+    trendChart.setOption({
+      tooltip: { trigger: 'axis', formatter: (params: any) => {
+        const p = params[0]
+        return `${trendParam.value}: ${p.value[0]}<br/>${data.y_label}: ${p.value[1]} ${data.y_unit}`
+      }},
+      grid: { left: 60, right: 20, top: 30, bottom: 40 },
+      xAxis: { type: 'value', name: trendParam.value, nameLocation: 'middle', nameGap: 25 },
+      yAxis: { type: 'value', name: data.y_label, nameLocation: 'middle', nameGap: 50 },
+      series: [{
+        type: 'scatter',
+        data: data.points,
+        symbolSize: 10,
+        itemStyle: { color: '#3b82f6' },
+        markLine: {
+          silent: true,
+          data: [{ type: 'average', name: 'Avg' }],
+          lineStyle: { color: '#94a3b8', type: 'dashed' }
+        }
+      }]
+    })
+  } catch (e: any) {
+    console.error('Trend chart error:', e)
+  }
+}
+
+async function loadParetoChart() {
+  if (!selectedPlan.value || !paretoChart) return
+  try {
+    const res = await analyticsApi.planPareto(selectedPlan.value, paretoX.value, paretoY.value)
+    const data = res.data
+    const allPts = data.all_points.map((p: any) => [p.x, p.y])
+    const paretoPts = data.pareto_points.map((p: any) => [p.x, p.y])
+    paretoChart.setOption({
+      tooltip: { trigger: 'item', formatter: (p: any) => `${data.x_label}: ${p.value[0]} ${data.x_unit}<br/>${data.y_label}: ${p.value[1]} ${data.y_unit}` },
+      legend: { data: ['All Points', 'Pareto Frontier'], top: 0 },
+      grid: { left: 60, right: 20, top: 40, bottom: 40 },
+      xAxis: { type: 'value', name: data.x_label, nameLocation: 'middle', nameGap: 25 },
+      yAxis: { type: 'value', name: data.y_label, nameLocation: 'middle', nameGap: 50 },
+      series: [
+        { name: 'All Points', type: 'scatter', data: allPts, symbolSize: 8, itemStyle: { color: '#cbd5e1' } },
+        { name: 'Pareto Frontier', type: 'line', data: paretoPts, symbolSize: 12, lineStyle: { color: '#ef4444', width: 2 }, itemStyle: { color: '#ef4444' } }
+      ]
+    })
+  } catch (e: any) {
+    console.error('Pareto chart error:', e)
+  }
+}
+
+async function loadSensitivityChart() {
+  if (!selectedPlan.value || !sensitivityChart) return
+  try {
+    const res = await analyticsApi.planSensitivity(selectedPlan.value, sensitivityMetric.value)
+    const data = res.data
+    const items = data.items || []
+    sensitivityChart.setOption({
+      tooltip: { trigger: 'axis', formatter: (params: any) => {
+        const p = params[0]
+        const item = items[p.dataIndex]
+        return `${item.param}<br/>Correlation: ${item.correlation}<br/>Direction: ${item.direction}`
+      }},
+      grid: { left: 100, right: 30, top: 30, bottom: 30 },
+      xAxis: { type: 'value', name: '|Correlation|', max: 1, nameLocation: 'middle', nameGap: 25 },
+      yAxis: { type: 'category', data: items.map((i: any) => i.param), inverse: true },
+      series: [{
+        type: 'bar',
+        data: items.map((i: any) => ({
+          value: i.abs_correlation,
+          itemStyle: { color: i.direction === 'positive' ? '#22c55e' : '#ef4444' }
+        })),
+        barWidth: '50%',
+        label: { show: true, position: 'right', formatter: (p: any) => items[p.dataIndex].correlation }
+      }]
+    })
+  } catch (e: any) {
+    console.error('Sensitivity chart error:', e)
+  }
+}
+
+function handleResize() {
+  trendChart?.resize()
+  paretoChart?.resize()
+  sensitivityChart?.resize()
+}
+
+onMounted(() => {
+  loadProjects()
+  loadMetrics()
+  window.addEventListener('resize', handleResize)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', handleResize)
+  trendChart?.dispose()
+  paretoChart?.dispose()
+  sensitivityChart?.dispose()
+})
+</script>
+
+<style scoped>
+.page-container {
+  padding: 20px 24px;
+}
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20px;
+}
+.page-title {
+  font-size: 20px;
+  font-weight: 600;
+  color: #1e293b;
+}
+.header-controls {
+  display: flex;
+  gap: 12px;
+}
+.stats-row {
+  margin-bottom: 16px;
+}
+.stat-card {
+  background: #fff;
+  border-radius: 8px;
+  padding: 16px 20px;
+  border: 1px solid #e2e8f0;
+}
+.stat-label {
+  font-size: 12px;
+  color: #64748b;
+  margin-bottom: 6px;
+}
+.stat-value {
+  font-size: 24px;
+  font-weight: 700;
+  color: #1e293b;
+}
+.stat-sub {
+  font-size: 14px;
+  font-weight: 400;
+  color: #94a3b8;
+  margin-left: 2px;
+}
+.stat-success .stat-value { color: #16a34a; }
+.stat-primary .stat-value { color: #2563eb; }
+.chart-controls {
+  margin-bottom: 16px;
+}
+.card {
+  background: #fff;
+  border-radius: 8px;
+  padding: 16px;
+  border: 1px solid #e2e8f0;
+  height: 100%;
+}
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.card-header h3 {
+  margin: 0;
+  font-size: 14px;
+  font-weight: 600;
+  color: #334155;
+}
+.chart-selectors {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+.chart-container {
+  width: 100%;
+  height: 320px;
+}
+</style>

+ 373 - 19
web/frontend/src/views/ExperienceList.vue

@@ -2,67 +2,263 @@
   <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">
+      <div class="header-controls">
+        <el-button type="primary" size="small" @click="showSimilarDialog = true">
+          <el-icon><Search /></el-icon> Find Similar
+        </el-button>
+        <el-button size="small" @click="loadAll">
+          <el-icon><Refresh /></el-icon> Refresh
+        </el-button>
+      </div>
+    </div>
+
+    <!-- Stats Cards -->
+    <el-row :gutter="16" class="stats-row" v-if="stats">
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">Total Cases</div>
+          <div class="stat-value">{{ stats.total }}</div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card stat-primary">
+          <div class="stat-label">SSSR / DRSS</div>
+          <div class="stat-value">{{ stats.topology_distribution?.SSSR || 0 }}<span class="stat-sub"> / {{ stats.topology_distribution?.DRSS || 0 }}</span></div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card stat-success">
+          <div class="stat-label">Avg Rating</div>
+          <div class="stat-value">{{ stats.avg_rating || 0 }}<span class="stat-sub">/5</span></div>
+        </div>
+      </el-col>
+      <el-col :span="6">
+        <div class="stat-card">
+          <div class="stat-label">Tracked Params</div>
+          <div class="stat-value">{{ Object.keys(stats.param_coverage || {}).length }}</div>
+        </div>
+      </el-col>
+    </el-row>
+
+    <!-- Filters -->
+    <div class="card filter-bar">
+      <el-input v-model="searchKeyword" placeholder="Search conclusion or tags..." style="width: 280px" clearable @input="loadExperience" :prefix-icon="Search" />
+      <el-select v-model="topologyFilter" placeholder="Topology" style="width: 130px" clearable @change="loadExperience">
         <el-option label="SSSR" value="SSSR" />
         <el-option label="DRSS" value="DRSS" />
       </el-select>
+      <el-select v-model="tagFilter" placeholder="Tag" style="width: 150px" clearable @change="loadExperience">
+        <el-option v-for="t in allTags" :key="t" :label="t" :value="t" />
+      </el-select>
+      <el-select v-model="minRating" placeholder="Min Rating" style="width: 130px" clearable @change="loadExperience">
+        <el-option label="1 star" :value="1" />
+        <el-option label="2 stars" :value="2" />
+        <el-option label="3 stars" :value="3" />
+        <el-option label="4 stars" :value="4" />
+        <el-option label="5 stars" :value="5" />
+      </el-select>
     </div>
 
+    <!-- Table -->
     <div class="card">
-      <el-table :data="cases" v-loading="loading" stripe>
+      <el-table :data="filteredCases" v-loading="loading" stripe @row-click="openDetail" highlight-current-row>
         <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>
+            <el-tag size="small" :type="row.topology === 'SSSR' ? 'primary' : 'success'">{{ 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">
+        <el-table-column prop="source_plan_id" label="Source Plan" width="160" show-overflow-tooltip />
+        <el-table-column label="Key Params" min-width="180">
           <template #default="{ row }">
-            <span v-for="(v, k) in row.params" :key="k" style="margin-right: 12px; font-size: 12px">
+            <span v-for="(v, k) in row.params" :key="k" class="param-chip">
               {{ k }}={{ v }}
             </span>
           </template>
         </el-table-column>
-        <el-table-column label="Key Metrics" min-width="250">
+        <el-table-column label="Metrics" min-width="220">
           <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>
+            <div class="metrics-cell">
+              <span class="metric-item"><strong>T:</strong> {{ row.metrics?.tavg_nm?.toFixed(3) }}Nm</span>
+              <span class="metric-item"><strong>Eff:</strong> {{ row.metrics?.efficiency_pct?.toFixed(1) }}%</span>
+              <span class="metric-item"><strong>Rip:</strong> {{ row.metrics?.ripple_pct?.toFixed(2) }}%</span>
+            </div>
+          </template>
+        </el-table-column>
+        <el-table-column prop="conclusion" label="Conclusion" min-width="180" show-overflow-tooltip />
+        <el-table-column label="Tags" width="120">
+          <template #default="{ row }">
+            <el-tag v-for="t in row.tags" :key="t" size="small" style="margin-right: 4px" type="info">{{ t }}</el-tag>
           </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">
+        <el-table-column prop="rating" label="Rating" width="90">
           <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">
+        <el-table-column prop="created_at" label="Created" width="150">
           <template #default="{ row }">{{ formatDate(row.created_at) }}</template>
         </el-table-column>
       </el-table>
+      <div class="pagination-info">Showing {{ filteredCases.length }} of {{ totalCases }} cases</div>
     </div>
+
+    <!-- Detail Dialog -->
+    <el-dialog v-model="detailVisible" :title="`Experience Case #${selectedCase?.id}`" width="700px">
+      <div v-if="selectedCase" class="detail-content">
+        <el-descriptions :column="2" border size="small">
+          <el-descriptions-item label="Topology">{{ selectedCase.topology }}</el-descriptions-item>
+          <el-descriptions-item label="Source Plan">{{ selectedCase.source_plan_id || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="Model Path" :span="2">{{ selectedCase.model_path || '-' }}</el-descriptions-item>
+          <el-descriptions-item label="Rating">
+            <el-rate v-model="selectedCase.rating" disabled size="small" />
+          </el-descriptions-item>
+          <el-descriptions-item label="Created">{{ formatDate(selectedCase.created_at) }}</el-descriptions-item>
+        </el-descriptions>
+
+        <h4 class="detail-section">Parameters</h4>
+        <el-table :data="paramRows" size="small" border>
+          <el-table-column prop="key" label="Parameter" width="180" />
+          <el-table-column prop="value" label="Value" />
+        </el-table>
+
+        <h4 class="detail-section">Metrics</h4>
+        <el-table :data="metricRows" size="small" border>
+          <el-table-column prop="key" label="Metric" width="180" />
+          <el-table-column prop="value" label="Value" />
+        </el-table>
+
+        <h4 class="detail-section">Conclusion</h4>
+        <p class="conclusion-text">{{ selectedCase.conclusion || 'No conclusion recorded.' }}</p>
+
+        <h4 class="detail-section" v-if="selectedCase.tags?.length">Tags</h4>
+        <el-tag v-for="t in selectedCase.tags" :key="t" style="margin-right: 6px">{{ t }}</el-tag>
+      </div>
+    </el-dialog>
+
+    <!-- Similar Case Search Dialog -->
+    <el-dialog v-model="showSimilarDialog" title="Find Similar Cases" width="600px">
+      <div class="similar-form">
+        <p class="form-hint">Enter parameter values to find similar experience cases. Leave empty to ignore a parameter.</p>
+        <el-form label-width="140px" size="small">
+          <el-form-item v-for="p in similarParams" :key="p.key" :label="p.key">
+            <el-input v-model.number="p.value" type="number" placeholder="value" clearable style="width: 200px" />
+          </el-form-item>
+          <el-form-item label="Topology">
+            <el-select v-model="similarTopology" placeholder="All" clearable style="width: 200px">
+              <el-option label="SSSR" value="SSSR" />
+              <el-option label="DRSS" value="DRSS" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="Max Results">
+            <el-input-number v-model="similarTopK" :min="1" :max="20" style="width: 200px" />
+          </el-form-item>
+        </el-form>
+        <el-button type="primary" @click="searchSimilar" :loading="similarLoading">Search</el-button>
+      </div>
+
+      <div v-if="similarResults.length > 0" class="similar-results">
+        <el-divider>Results ({{ similarResults.length }})</el-divider>
+        <el-table :data="similarResults" size="small" stripe @row-click="(row) => { selectedCase = row; detailVisible = true }">
+          <el-table-column prop="id" label="ID" width="60" />
+          <el-table-column prop="topology" label="Topo" width="70" />
+          <el-table-column label="Similarity" width="100">
+            <template #default="{ row }">
+              <el-progress :percentage="Math.round(row.similarity_score * 100)" :stroke-width="8" :color="row.similarity_score > 0.7 ? '#67c23a' : row.similarity_score > 0.5 ? '#e6a23c' : '#f56c6c'" />
+            </template>
+          </el-table-column>
+          <el-table-column label="Shared Params" min-width="150">
+            <template #default="{ row }">
+              <el-tag v-for="p in row.shared_params" :key="p" size="small" style="margin-right: 4px" type="info">{{ p }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="conclusion" label="Conclusion" show-overflow-tooltip />
+        </el-table>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted, reactive } from 'vue'
 import { ElMessage } from 'element-plus'
-import { experienceApi } from '@/api'
+import { Search, Refresh } from '@element-plus/icons-vue'
+import { experienceApi, analyticsApi } from '@/api'
 
 const loading = ref(false)
 const cases = ref<any[]>([])
+const totalCases = ref(0)
+const stats = ref<any>(null)
 const topologyFilter = ref('')
+const tagFilter = ref('')
+const searchKeyword = ref('')
+const minRating = ref<number | null>(null)
+const allTags = ref<string[]>([])
+
+// Detail dialog
+const detailVisible = ref(false)
+const selectedCase = ref<any>(null)
+
+// Similar search
+const showSimilarDialog = ref(false)
+const similarLoading = ref(false)
+const similarResults = ref<any[]>([])
+const similarTopology = ref('')
+const similarTopK = ref(5)
+const similarParams = reactive([
+  { key: 'Airgap', value: null },
+  { key: 'RMSCurrent', value: null },
+  { key: 'MagnetThickness', value: null },
+  { key: 'PolePairs', value: null },
+  { key: 'OuterRadius', value: null },
+  { key: 'InnerRadius', value: null },
+  { key: 'Speed', value: null },
+  { key: 'TurnsPerCoil', value: null }
+])
+
+const filteredCases = computed(() => {
+  let result = cases.value
+  if (searchKeyword.value) {
+    const kw = searchKeyword.value.toLowerCase()
+    result = result.filter(c =>
+      (c.conclusion || '').toLowerCase().includes(kw) ||
+      (c.tags || []).some((t: string) => t.toLowerCase().includes(kw))
+    )
+  }
+  if (tagFilter.value) {
+    result = result.filter(c => (c.tags || []).includes(tagFilter.value))
+  }
+  if (minRating.value !== null) {
+    result = result.filter(c => (c.rating || 0) >= minRating.value!)
+  }
+  return result
+})
+
+const paramRows = computed(() => {
+  if (!selectedCase.value?.params) return []
+  return Object.entries(selectedCase.value.params).map(([key, value]) => ({ key, value }))
+})
+
+const metricRows = computed(() => {
+  if (!selectedCase.value?.metrics) return []
+  return Object.entries(selectedCase.value.metrics).map(([key, value]) => ({ key, value }))
+})
 
 async function loadExperience() {
   loading.value = true
   try {
-    const params: any = { limit: 100 }
+    const params: any = { limit: 200 }
     if (topologyFilter.value) params.topology = topologyFilter.value
+    if (tagFilter.value) params.tag = tagFilter.value
     const res = await experienceApi.list(params)
     cases.value = res.data.items
+    totalCases.value = res.data.total
+    // Collect all tags
+    const tags = new Set<string>()
+    for (const c of cases.value) {
+      for (const t of (c.tags || [])) tags.add(t)
+    }
+    allTags.value = Array.from(tags)
   } catch (e: any) {
     ElMessage.error('Failed to load experience: ' + e.message)
   } finally {
@@ -70,9 +266,167 @@ async function loadExperience() {
   }
 }
 
+async function loadStats() {
+  try {
+    const res = await analyticsApi.experienceStats(topologyFilter.value || undefined)
+    stats.value = res.data
+  } catch (e: any) {
+    console.error('Failed to load stats:', e)
+  }
+}
+
+async function loadAll() {
+  await Promise.all([loadExperience(), loadStats()])
+}
+
+function openDetail(row: any) {
+  selectedCase.value = row
+  detailVisible.value = true
+}
+
+async function searchSimilar() {
+  const params: Record<string, number> = {}
+  for (const p of similarParams) {
+    if (p.value !== null && p.value !== undefined && p.value !== '') {
+      params[p.key] = p.value
+    }
+  }
+  if (Object.keys(params).length === 0) {
+    ElMessage.warning('Please enter at least one parameter value')
+    return
+  }
+  similarLoading.value = true
+  try {
+    const res = await analyticsApi.experienceSimilar(params, similarTopology.value || undefined, similarTopK.value)
+    similarResults.value = res.data.items
+    if (similarResults.value.length === 0) {
+      ElMessage.info('No similar cases found. Try adjusting tolerance or parameters.')
+    }
+  } catch (e: any) {
+    ElMessage.error('Search failed: ' + e.message)
+  } finally {
+    similarLoading.value = false
+  }
+}
+
 function formatDate(d: string) {
   return new Date(d).toLocaleString()
 }
 
-onMounted(loadExperience)
+onMounted(loadAll)
 </script>
+
+<style scoped>
+.page-container {
+  padding: 20px 24px;
+}
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+}
+.page-title {
+  font-size: 20px;
+  font-weight: 600;
+  color: #1e293b;
+}
+.header-controls {
+  display: flex;
+  gap: 8px;
+}
+.stats-row {
+  margin-bottom: 16px;
+}
+.stat-card {
+  background: #fff;
+  border-radius: 8px;
+  padding: 14px 18px;
+  border: 1px solid #e2e8f0;
+}
+.stat-label {
+  font-size: 12px;
+  color: #64748b;
+  margin-bottom: 4px;
+}
+.stat-value {
+  font-size: 22px;
+  font-weight: 700;
+  color: #1e293b;
+}
+.stat-sub {
+  font-size: 13px;
+  font-weight: 400;
+  color: #94a3b8;
+}
+.stat-success .stat-value { color: #16a34a; }
+.stat-primary .stat-value { color: #2563eb; }
+.filter-bar {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+  margin-bottom: 16px;
+  padding: 12px 16px;
+}
+.card {
+  background: #fff;
+  border-radius: 8px;
+  padding: 16px;
+  border: 1px solid #e2e8f0;
+  margin-bottom: 16px;
+}
+.param-chip {
+  display: inline-block;
+  font-size: 11px;
+  background: #f1f5f9;
+  color: #475569;
+  padding: 2px 6px;
+  border-radius: 4px;
+  margin-right: 6px;
+  margin-bottom: 2px;
+}
+.metrics-cell {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.metric-item {
+  font-size: 12px;
+  color: #475569;
+}
+.pagination-info {
+  margin-top: 12px;
+  font-size: 12px;
+  color: #94a3b8;
+  text-align: right;
+}
+.detail-content {
+  max-height: 60vh;
+  overflow-y: auto;
+}
+.detail-section {
+  margin: 16px 0 8px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #334155;
+}
+.conclusion-text {
+  background: #f8fafc;
+  padding: 12px;
+  border-radius: 6px;
+  font-size: 13px;
+  line-height: 1.6;
+  color: #475569;
+}
+.similar-form {
+  margin-bottom: 16px;
+}
+.form-hint {
+  font-size: 12px;
+  color: #94a3b8;
+  margin-bottom: 12px;
+}
+.similar-results {
+  margin-top: 8px;
+}
+</style>