瀏覽代碼

绘制异常检测接口

liujiejie 2 月之前
父節點
當前提交
811e560c7a

+ 157 - 0
.cursor/skills/pref-web-dev/SKILL.md

@@ -0,0 +1,157 @@
+---
+name: pref-web-dev
+description: >-
+  风机数据管理平台(performance-test / pref-web)开发与联调指南。涵盖 Vue 2
+  Options API、多代理 devServer、健康分析 healthApi 联调、动态路由与组织树风场
+  选择。在用户修改本仓库前端/后端、新增 API 联调、健康/性能/驾驶舱模块或询问
+  项目规范时使用。
+---
+
+# pref-web 项目开发 Skill
+
+## 权威文档(先读后改)
+
+1. `docs/AI_SPECS.md` — 技术栈、目录、API、安全底线
+2. `.cursor/rules/project-rules.mdc` — 全项目总则
+3. `.cursor/rules/frontend-rules.mdc` — Vue 2 前端
+4. `.cursor/rules/backend-rules.mdc` — `server/`、`downLoadServer/`
+
+Rules 是精简硬约束;Specs 是详细说明。改代码前读 Specs 相关章节,不必把 Specs 全文塞进 Rule。
+
+## 技术栈速查
+
+| 层 | 技术 |
+|----|------|
+| 前端 | Vue 2.6、Vue Router 3、Vuex 3、Element UI、SCSS、ECharts、Vue CLI 5 |
+| 主 API | `@/utils/request.js` → `baseURL: /api` |
+| 独立服务 | 各模块自建 `axios` 实例 + `vue.config.js` 代理前缀 |
+| 后端 | `server/`(mysql2)、`downLoadServer/`(图表/报告导出) |
+
+**禁止**:迁移 TypeScript / Vue 3 / Composition API;引入新 UI 库或新请求库。
+
+## 环境与启动
+
+```bash
+npm run serve:dev   # .env.dev
+npm run serve:dt    # .env.dt(大唐)
+npm run serve:hd    # .env.hd(华电)
+npm run serve:jl    # .env.jl
+```
+
+修改 `vue.config.js` 代理后**必须重启** dev server。
+
+## 多代理约定
+
+主业务走 `/api`;其他服务用**独立前缀**,**禁止**用 `/health`(与路由 `/home/health` 冲突)。
+
+| 前缀 | 环境变量 | 用途 |
+|------|----------|------|
+| `/api` | `VUE_APP_APIPROXY` | 主业务 energy-manage-service |
+| `/healthApi` | `VUE_APP_HEALTH_APIPROXY` | 健康分析 energy-manage-analyse-service |
+| `/WZLapi` | `VUE_APP_WZLAPIPROXY` | 振动/激光 |
+| `/AnalysisMulti` | `VUE_APP_AnalysisMultiAPIPROXY` | 健康评估算法 |
+| `/downLoadChart` | `VUE_APP_downLoadChartAPIPROXY` | 图表下载 |
+
+**不要**自定义 `historyApiFallback.rewrites` 覆盖默认 SPA 回退,否则 `/home/*` 会返回 Tomcat 404 而非 `index.html`。
+
+新增独立服务时:
+1. `.env.*` 加 `VUE_APP_XXX_APIPROXY`
+2. `vue.config.js` `devServer.proxy` 加前缀(勿与路由路径冲突)
+3. `src/utils/xxxRequest.js` 新建 axios 实例
+4. `src/api/xxx.js` 封装接口
+
+## 前端 API 联调流程
+
+```
+1. 确认 swagger 或接口文档 → 路径、方法、query/body、响应 ResultResp<T>
+2. src/api/<module>.js 具名导出 export function getXxx(params)
+3. 选择 request 实例(主 /api 或专用 healthRequest 等)
+4. POST + query 参数时显式传 params: { fieldCode, datatime }
+5. 页面 methods 调用,判断 res.code === 200 后取 res.data
+6. 复杂响应抽到 views/<module>/utils/*Mapper.js 映射 UI 结构
+```
+
+### 响应约定
+
+```javascript
+// 标准包装
+{ code: 200, data: T, msg: string, status: boolean }
+```
+
+拦截器已处理非 200;页面 catch 后清空或保留上次数据,**不要** router.push 到 404 页。
+
+### 接口 404 / HTML 错误
+
+- 仅 `Message.error` 提示,保持当前页面
+- `healthRequest` 会识别 Tomcat HTML 响应并 reject
+- 代理路径错误时检查 Network 是否走 `/healthApi/...` 而非 `/api/...`
+
+## 健康模块(health)
+
+| 文件 | 职责 |
+|------|------|
+| `src/views/health/index.vue` | 仪表盘页 |
+| `src/views/health/components/health/HealthHeader.vue` | 风场树(portal 到 header) |
+| `src/api/healthAnalyse.js` | 健康分析接口 |
+| `src/utils/healthRequest.js` | `/healthApi` 专用请求 |
+| `src/views/health/utils/healthDashboardMapper.js` | API → UI 数据映射 |
+
+### 风场 fieldCode
+
+- `fieldCode` = 组织树节点的 `codeNumber`
+- 默认选中:`tree[0].children[0]`(第一个根节点的第一个子节点)
+- `HealthHeader` 的 `change` 事件携带 `{ ...node, fieldCode: codeNumber }`
+- 组织树接口走主 `/api`(`getSysOrganizationAuthTreeByRoleId`),健康数据走 `/healthApi`
+
+### 健康分析接口(swagger 2.X版本)
+
+```
+POST /energy-manage-analyse-service/healthscores/getHealthOverview
+  params: fieldCode(required), datatime(optional)
+POST /energy-manage-analyse-service/healthscores/getHealthscoresWindList
+POST /energy-manage-analyse-service/healthscores/getLastDaysTrend
+  params: day, engineId, fieldId
+```
+
+完整设计见 `docs/API_DESIGN_REPORT_energy-manage-analyse.md`。
+
+## 路由与菜单
+
+- 静态路由:`src/router/index.js`(`/home` 壳 + 驾驶舱)
+- 动态路由:登录后 `store/auth.js` → `getAuthRouterFn` 按权限注入
+- 菜单跳转:`MenuDt.vue` → `router.push(`${path}?id=${id}`)`
+- 健康页典型路径:`/home/health/index?id=264`
+
+catch-all `path: '*'` 在 `auth.js` 末尾注册;接口代理路径(`/api`、`/healthApi` 等)不应进入 404 页面组件。
+
+## 编码风格要点
+
+- Vue 2 **Options API**;`.vue` 结构 `template` / `script` / `style lang="scss"`
+- API 放 `src/api/*.js`;双引号、保留分号
+- 样式跟现有 SCSS 变量与 `html[data-theme="..."]`
+- 驾驶舱全局样式:`src/views/admin/cockpitManage/cockpit-dashboard.scss`(勿改 scoped)
+- 健康仪表盘样式:`src/views/health/components/health/health-dashboard.scss`
+- 图表复用 `src/assets/js/constants/echarts-config/` 与已有 mixin
+
+## 安全红线
+
+- 不输出/硬编码 `.env*`、token、数据库密码、MinIO 凭据
+- 不擅自改 PM2、nginx、部署脚本、生产代理
+- 数据库结构变更:先给迁移 SQL + 回滚方案,等确认后再动
+- 接口改动保持向后兼容
+
+## 变更检查清单
+
+```
+- [ ] 是否复用已有组件/工具/API?
+- [ ] 新接口是否放对 request 实例与代理前缀?
+- [ ] 是否保持 Options API 与局部最小 diff?
+- [ ] 错误处理是否只提示、不跳转 404 页?
+- [ ] vue.config 代理改动是否提醒重启 serve?
+- [ ] 是否检查相关文件 lint?
+```
+
+## 延伸阅读
+
+- 代理与健康 API 细节:[reference.md](reference.md)
+- Swagger 分析报告:`docs/API_DESIGN_REPORT_energy-manage-analyse.md`

+ 85 - 0
.cursor/skills/pref-web-dev/reference.md

@@ -0,0 +1,85 @@
+# pref-web 参考手册
+
+## devServer 代理完整列表(vue.config.js)
+
+```javascript
+"/api"           → VUE_APP_APIPROXY
+"/healthApi"     → VUE_APP_HEALTH_APIPROXY
+"/WZLapi"        → VUE_APP_WZLAPIPROXY
+"/ETLapi"        → VUE_APP_ETLAPIPROXY
+"/tiles"         → VUE_APP_MAP
+"/AnalysisMulti" → VUE_APP_AnalysisMultiAPIPROXY
+"/transDataWeb"  → VUE_APP_WZLAPIPROXY
+"/sAlgorithm"    → VUE_APP_sAlgorithmAPIPROXY
+"/databaseApi"   → VUE_APP_databaseApiAPIPROXY
+"/downLoadChart" → VUE_APP_downLoadChartAPIPROXY
+```
+
+pathRewrite 均去掉前缀后转发到 target。
+
+## healthRequest 模板
+
+```javascript
+// src/utils/healthRequest.js
+const HEALTH_BASE = process.env.NODE_ENV === "development"
+  ? "/healthApi"
+  : window?._BASE_CONFIG?.HEALTH_API || "/healthApi";
+
+const service = axios.create({ baseURL: HEALTH_BASE, withCredentials: true });
+
+// 拦截器:token 从 sessionStorage.vuex.auth.userInfo.token
+// 404/HTML 错误:Message 提示 + reject,不 router.push
+```
+
+```javascript
+// src/api/healthAnalyse.js
+export function getHealthOverview(params) {
+  return healthRequest({
+    url: "/energy-manage-analyse-service/healthscores/getHealthOverview",
+    method: "post",
+    params, // query 参数,POST 也需显式 params
+  });
+}
+```
+
+## HealthHeader 默认风场逻辑
+
+```javascript
+getDefaultTreeNode(treeData) {
+  const root = treeData?.[0];
+  return root?.children?.[0] || null;
+}
+// 加载树后:companyCode = defaultNode.codeNumber → emit change
+// 勿用 selecttree type="1"(会选中根节点而非第一个子节点)
+```
+
+## healthDashboardMapper 映射关系
+
+| UI 字段 | API 来源 |
+|---------|----------|
+| totalScore | healthscoresWindVO.overallScore |
+| scoreDistribution / scoreSummary | excellentCount, goodCount, fairCount, poorCount |
+| subsystemData | structureScore, systemScore, componentScore |
+| healthyRankList | healthOverviewListVOList 按 overallScore 降序 Top5 |
+| riskRankList | 同上升序 Top5 |
+| unitCards | 列表项 → HealthUnitCard(含 engineId, fieldId) |
+
+## 下载 swagger 文档
+
+```bash
+curl "http://<host>:16880/energy-manage-analyse-service/v2/api-docs?group=2.X%E7%89%88%E6%9C%AC" \
+  -o swagger.json
+```
+
+## 生产环境注意
+
+- `public/config/config.js` 中 `_BASE_CONFIG.API = "/api"`;健康服务需运维配置 `/healthApi` 反向代理
+- 勿在未授权时改 `public/runtime-config.js`、nginx、PM2
+
+## Rules vs Specs vs Skill
+
+| 文件 | 作用 | 加载方式 |
+|------|------|----------|
+| `.cursor/rules/*.mdc` | 每次对话自动注入的硬约束 | alwaysApply |
+| `docs/AI_SPECS.md` | 人类/AI 详细说明 | 按需阅读 |
+| `.cursor/skills/pref-web-dev/` | 开发工作流与踩坑 | 匹配 description 或 @ 引用 |

+ 3 - 2
.env.dt

@@ -1,8 +1,8 @@
 ###
  # @Author: your name
  # @Date: 2026-03-06 11:19:04
- # @LastEditTime: 2026-03-06 11:19:28
- # @LastEditors: bogon
+ # @LastEditTime: 2026-06-04 15:32:34
+ # @LastEditors: milo-MacBook-Pro.local
  # @Description: In User Settings Edit
  # @FilePath: /performance-test/.env.dt
 ### 
@@ -33,6 +33,7 @@ VUE_APP_UPLOAD="http://192.168.50.235/energy-manage-service/api/check/upload"
 VUE_APP_MAPVIEW=/tiles/{z}/{x}/{y}.png
 VUE_APP_MAP=http://192.168.50.235
 VUE_APP_APIPROXY='http://192.168.50.235:16200'
+VUE_APP_HEALTH_APIPROXY='http://192.168.5.4:16880'
 VUE_APP_WZLAPIPROXY='http://192.168.50.241:9002'
 # VUE_APP_ETLAPIPROXY='http://192.168.50.241:9002'
 VUE_APP_ETLAPIPROXY='http://192.168.50.241:9000/transDataWebProd/'

+ 272 - 0
docs/API_DESIGN_REPORT_energy-manage-analyse.md

@@ -0,0 +1,272 @@
+# 中能智能分析业务后台 API 设计报告
+
+> 来源:`swagger.json`(Swagger 2.0)  
+> 接口文档:`http://192.168.5.4:16880/energy-manage-analyse-service/v2/api-docs?group=2.X版本`  
+> 生成时间:2026-06-04
+
+---
+
+## 文档概览
+
+| 项目 | 值 |
+|------|-----|
+| 标题 | 中能智能分析业务后台相关接口文档 |
+| 版本 | 1.0 |
+| 描述 | 包含模块:异常检测和健康诊断接口 |
+| 联系方 | 研发中心 |
+| Host | `192.168.5.4:16880` |
+| 基础路径前缀 | `/energy-manage-analyse-service` |
+| 协议风格 | Swagger 2.0,统一 `ResultResp<T>` 包装响应 |
+
+---
+
+## 1. 所有模块(Tags)
+
+共 **3** 个业务模块:
+
+| # | 模块名 | 接口数 | 职责 |
+|---|--------|--------|------|
+| 1 | 湍流尾流接口 | 1 | 风场湍流、尾流影响分析与可视化数据 |
+| 2 | 风机健康查询接口 | 3 | 风场/风机健康评分、概览、趋势 |
+| 3 | 风机异常检测查询接口 | 12 | 五类检测模块 + 传感器异常统计、热力图、趋势等 |
+
+---
+
+## 2. 所有接口
+
+共 **16** 个接口,**全部为 POST**。
+
+### 2.1 湍流尾流接口(1)
+
+| 方法 | 路径 | 摘要 | operationId | 请求参数 | 响应 data |
+|------|------|------|-------------|----------|-----------|
+| POST | `/wakewindfarm/getWakeWindFarm` | 风场湍流尾流的数据 | `getWakeWindFarmUsingPOST` | `datatime`(query), `fieldCode`(query,必填) | `WakeWindFarmVO` |
+
+### 2.2 风机健康查询接口(3)
+
+| 方法 | 路径 | 摘要 | operationId | 请求参数 | 响应 data |
+|------|------|------|-------------|----------|-----------|
+| POST | `/healthscores/getHealthOverview` | 风场下所有风机健康查询概览页面 | `getHealthOverviewUsingPOST` | `datatime`(query), `fieldCode`(query,必填) | `HealthOverviewVO` |
+| POST | `/healthscores/getHealthscoresWindList` | 首页风场健康台数统计数据 | `getHealthscoresWindListUsingPOST` | `datatime`(query), `fieldCode`(query) | `List<HealthscoresWindVO>` |
+| POST | `/healthscores/getLastDaysTrend` | 风机按天数查询趋势图 | `getLastDaysTrendUsingPOST` | `day`(query,必填), `engineId`(query,必填), `fieldId`(query,必填) | `HealthscoresTendencyVO` |
+
+### 2.3 风机异常检测查询接口(12)
+
+| 方法 | 路径 | 摘要 | operationId | 请求体 | 响应 data |
+|------|------|------|-------------|--------|-----------|
+| POST | `/anomaly/getAnomalyOverview` | 查询异常风机概览 | `getAnomalyOverviewUsingPOST` | `异常检测风场参数` | `风机异常概览` |
+| POST | `/anomaly/getAnomalyModel` | 风机卡片 | `getAnomalyModelUsingPOST` | `异常检测风场参数` | `List<风机卡片返回数据>` |
+| POST | `/anomaly/getAnomalyModelCount` | 查询模块异常台数(雷达图) | `getAnomalyModelCountUsingPOST` | `异常检测风场参数` | `模块统计雷达图` |
+| POST | `/anomaly/getAnomalySensorCount` | 查询传感器异常台数(雷达图) | `getAnomalySensorCountUsingPOST` | `异常检测风场参数` | `传感器统计雷达图` |
+| POST | `/anomaly/getBarChartStats` | 查询柱状图 | `getBarChartStatsUsingPOST` | `异常检测风场参数` | `异常检测概览页面柱状图` |
+| POST | `/anomaly/getAnomalyModelMap` | 查询热力图 | `getAnomalyModelMapUsingPOST` | `异常检测风场参数` | `热力图` |
+| POST | `/anomaly/getAnomalyWindpwr` | 风速功率模块 | `getAnomalyWindpwrUsingPOST` | `异常检测风场下风机基础参数` | `风速功率模块` |
+| POST | `/anomaly/geAnomalyYaw` | 偏航模块 | `geAnomalyYawUsingPOST` | `异常检测风场下风机基础参数` | `偏航模块` |
+| POST | `/anomaly/getAnomalyPitch` | 变桨模块 | `getAnomalyPitchUsingPOST` | `异常检测风场下风机基础参数` | `变桨模块` |
+| POST | `/anomaly/getAnomalyCtrlParam` | 运行状态模块 | `getAnomalyCtrlParamUsingPOST` | `异常检测风场下风机基础参数` | `运行模块` |
+| POST | `/anomaly/getAerodynamics` | 气动性能 | `getAerodynamicsUsingPOST` | `异常检测风场下风机基础参数` | `气动性能模块` |
+| POST | `/anomaly/getPitchAnomalyTrend` | 模块异常趋势曲线(支持年度分表+跨年) | `getPitchAnomalyTrendUsingPOST` | `异常检测风场下风机基础参数` | `List<异常检测曲线图异常占比>` |
+
+> 完整 URL = `http://{host}/energy-manage-analyse-service` + 上表路径
+
+---
+
+## 3. 所有 DTO(Definitions)
+
+共 **50** 个模型,按职责分组如下。
+
+### 3.1 统一响应包装(15)
+
+所有业务接口 HTTP 200 均返回 `ResultResp<T>`:
+
+```text
+ResultResp<T> {
+  code: integer
+  data: T
+  msg: string
+  status: boolean
+}
+```
+
+具体类型:`ResultResp«HealthOverviewVO»`、`ResultResp«List«风机卡片返回数据»»` 等共 15 种泛型包装。
+
+### 3.2 请求 DTO(2)
+
+| DTO | 字段 | 说明 |
+|-----|------|------|
+| **异常检测风场参数** | `datatime`, `fieldCode` | 风场级查询(概览、卡片、雷达图、柱状图、热力图) |
+| **异常检测风场下风机基础参数** | `datatime`, `engineId`, `fieldId`, `timeRange` | 单机/模块级查询;`timeRange` 为曲线天数 7/30 |
+
+> Swagger 参数名 `anomalyDTO` / `anomalyModelDTO` 实际引用上述两个 definition。
+
+### 3.3 健康诊断 VO(4)
+
+| DTO | 用途 |
+|-----|------|
+| `HealthOverviewVO` | 健康概览页:风机列表 + 风场汇总 |
+| `HealthOverviewListVO` | 单台风机各子系统/部件评分 |
+| `HealthscoresWindVO` | 风场健康台数统计(优/良/中/差) |
+| `HealthscoresTendencyVO` | 按天趋势的多维度评分序列 |
+
+### 3.4 湍流尾流 VO(2)
+
+| DTO | 用途 |
+|-----|------|
+| `WakeWindFarmVO` | 风场尾流:图表路径、风机列表 |
+| `WakeTurbineVO` | 单风机:湍流强度、速度亏损、是否受尾流影响 |
+
+### 3.5 异常检测 — 聚合/展示 VO(10)
+
+| DTO | 用途 |
+|-----|------|
+| `风机异常概览` | 检测器/传感器/总异常数 |
+| `风机卡片返回数据` | 单台风机五模块 + 传感器明细(字段最多) |
+| `模块统计雷达图` | 五模块异常台数 |
+| `传感器统计雷达图` | 七类传感器异常台数 |
+| `异常检测概览页面柱状图` | 偏航/变桨/风速功率/运行 四类计数 |
+| `异常检测曲线图异常占比` | 按时间的五模块异常占比趋势 |
+| `热力图` | `anomalyChartMap`、`anomalySensorMap` |
+| `检测器热力图` | 风机名、是否异常、异常比例 |
+| `风速功率模块` / `偏航模块` / `变桨模块` / `运行模块` / `气动性能模块` | 各模块下挂对应 `Anomaly*PO` 检测器 |
+
+### 3.6 异常检测 — 持久化 PO(13)
+
+各 PO 结构一致,记录检测器/传感器异常统计与文件路径:
+
+`AnomalyCabletwistPO`、`AnomalyStaticyawPO`、`AnomalyMinpitchPO`、`AnomalyPitchcoordPO`、`AnomalyPitchregulationPO`、`AnomalyPowercurvePO`、`AnomalyScatterPO`、`AnomalySimulinkPO`、`AnomalyOperationPO`、`AnomalyPowerqualityPO`、`AnomalyCpPO`、`AnomalyCpTsrPO`、`AnomalyTsrPO`
+
+公共字段:`id`, `fieldId`, `engineId`, `anomalyModelName`, `detectorIsAnomaly`, `detectorAnomalyCount`, `detectorNormallyCount`, `sensorIsAnomaly`, `sensorAnomalyCount`, `sensorNormallyCount`, `sensorAnomalyType`, `filePath`, `graphPath`, `sourceDatetime`, `createTime`
+
+---
+
+## 4. API 设计报告
+
+### 4.1 领域模型
+
+```mermaid
+graph TB
+    subgraph 风场维度
+        F[fieldCode / fieldId]
+    end
+    subgraph 风机维度
+        E[engineId]
+    end
+    subgraph 健康诊断
+        H1[HealthOverview]
+        H2[HealthscoresWind]
+        H3[HealthscoresTendency]
+    end
+    subgraph 异常检测
+        A1[概览/卡片/雷达/柱状/热力]
+        A2[五模块详情]
+        A3[趋势曲线]
+    end
+    subgraph 湍流尾流
+        W[WakeWindFarm]
+    end
+    F --> H1 & H2 & A1 & W
+    F --> E
+    E --> H3 & A2 & A3
+```
+
+### 4.2 设计特点
+
+1. **模块划分清晰**:健康、异常、尾流三条业务线,Swagger tag 与 URL 前缀一致(`/healthscores`、`/anomaly`、`/wakewindfarm`)。
+2. **统一响应体**:`ResultResp` + `code/msg/status/data`,便于前端统一拦截。
+3. **异常检测五模块模型**:
+   - Model1:风速功率(功率曲线、散点、simulink)
+   - Model2:偏航(扭缆、静态偏航)
+   - Model3:变桨(最小桨距、协调、调节)
+   - Model4:运行状态(机械运行、电气功率、降载)
+   - Model5:气动性能(Cp、Cp-TSR、TSR)
+4. **传感器维度独立**:风速、功率、转速、转矩、变桨及逻辑组合(风速-功率、转速-扭矩)与检测器维度并行统计。
+5. **图表资源路径型响应**:尾流、部分异常结果通过 `*Path` 字符串返回 MinIO/文件服务路径,前端需二次加载图片或 JSON。
+
+### 4.3 请求设计模式
+
+| 模式 | 使用场景 | 示例 |
+|------|----------|------|
+| Query 参数 | 健康、尾流简单查询 | `fieldCode`, `datatime`, `day` |
+| JSON Body | 异常检测全部接口 | `异常检测风场参数` / `异常检测风场下风机基础参数` |
+
+**不一致点**:健康/尾流用 query,异常用 body;同一业务(风场+日期)存在 `fieldCode` 与 `fieldId` 两种命名,对接时需做字段映射。
+
+### 4.4 接口依赖关系(前端页面建议)
+
+```text
+异常检测概览页
+├── getAnomalyOverview      → 顶部统计
+├── getBarChartStats        → 柱状图
+├── getAnomalyModelCount    → 模块雷达图
+├── getAnomalySensorCount   → 传感器雷达图
+├── getAnomalyModelMap      → 热力图
+└── getAnomalyModel         → 风机卡片列表
+
+风机详情 / 模块下钻
+├── getAnomalyWindpwr / geAnomalyYaw / getAnomalyPitch
+├── getAnomalyCtrlParam / getAerodynamics
+└── getPitchAnomalyTrend    → 趋势(需 engineId + timeRange)
+
+健康首页
+├── getHealthscoresWindList → 风场列表统计
+├── getHealthOverview       → 概览
+└── getLastDaysTrend        → 单机趋势
+
+湍流尾流页
+└── getWakeWindFarm
+```
+
+### 4.5 数据质量与规范建议
+
+| 项 | 现状 | 建议 |
+|----|------|------|
+| HTTP 方法 | 查询类接口全部 POST | 只读接口可逐步改为 GET,利于缓存 |
+| 命名 | `geAnomalyYaw` 拼写错误 | 保留旧路径,新增别名或文档标注 |
+| 字段命名 | `fieldCode` vs `fieldId` | 文档明确二者等价关系 |
+| 作废字段 | `model1WindpwrSimulink`、`model3PitchMinpitch` 标注作废 | 前端勿再展示 |
+| 类型 | `热力图` 中 map 为 `object` | 补充具体 key 结构或示例 JSON |
+| 安全 | 文档未描述鉴权 | 对接时确认 Token/网关头 |
+
+### 4.6 与前端项目集成要点
+
+1. 在 `src/api/` 新增 `energyAnalyse.js`(或按模块拆分),baseURL 指向 `energy-manage-analyse-service`。
+2. 复用 `src/utils/request.js`,判断 `code === 200` 且 `status === true` 后取 `data`。
+3. 风机卡片 `风机卡片返回数据` 字段众多,建议封装为表格列配置或按模块折叠展示。
+4. 趋势接口 `getPitchAnomalyTrend` 注明支持年度分表,跨年查询需传完整 `datatime`/`timeRange`。
+5. 图表类 `*Path` 字段需拼接文件服务域名或与现有 MinIO 下载逻辑复用。
+
+### 4.7 统计摘要
+
+| 维度 | 数量 |
+|------|------|
+| 业务模块 | 3 |
+| API 接口 | 16 |
+| DTO 模型 | 50 |
+| 请求 DTO | 2 |
+| 响应包装类型 | 15 |
+| Anomaly PO | 13 |
+| 检测模块 | 5 |
+| 传感器类型 | 7 |
+
+---
+
+## 附录:完整接口路径清单
+
+```
+POST /energy-manage-analyse-service/wakewindfarm/getWakeWindFarm
+POST /energy-manage-analyse-service/healthscores/getHealthOverview
+POST /energy-manage-analyse-service/healthscores/getHealthscoresWindList
+POST /energy-manage-analyse-service/healthscores/getLastDaysTrend
+POST /energy-manage-analyse-service/anomaly/getAnomalyOverview
+POST /energy-manage-analyse-service/anomaly/getAnomalyModel
+POST /energy-manage-analyse-service/anomaly/getAnomalyModelCount
+POST /energy-manage-analyse-service/anomaly/getAnomalySensorCount
+POST /energy-manage-analyse-service/anomaly/getBarChartStats
+POST /energy-manage-analyse-service/anomaly/getAnomalyModelMap
+POST /energy-manage-analyse-service/anomaly/getAnomalyWindpwr
+POST /energy-manage-analyse-service/anomaly/geAnomalyYaw
+POST /energy-manage-analyse-service/anomaly/getAnomalyPitch
+POST /energy-manage-analyse-service/anomaly/getAnomalyCtrlParam
+POST /energy-manage-analyse-service/anomaly/getAerodynamics
+POST /energy-manage-analyse-service/anomaly/getPitchAnomalyTrend
+```

+ 75 - 0
src/api/anomalyAnalyse.js

@@ -0,0 +1,75 @@
+import healthRequest from "@/utils/healthRequest";
+
+const PREFIX = "/energy-manage-analyse-service/anomaly";
+
+/**
+ * 查询异常风机概览
+ * @param {{ fieldCode: string, datatime?: string }} data
+ */
+export function getAnomalyOverview(data) {
+  return healthRequest({
+    url: `${PREFIX}/getAnomalyOverview`,
+    method: "post",
+    data,
+  });
+}
+
+/**
+ * 风机卡片列表
+ * @param {{ fieldCode: string, datatime?: string }} data
+ */
+export function getAnomalyModel(data) {
+  return healthRequest({
+    url: `${PREFIX}/getAnomalyModel`,
+    method: "post",
+    data,
+  });
+}
+
+/**
+ * 模块异常台数(功能诊断雷达图)
+ * @param {{ fieldCode: string, datatime?: string }} data
+ */
+export function getAnomalyModelCount(data) {
+  return healthRequest({
+    url: `${PREFIX}/getAnomalyModelCount`,
+    method: "post",
+    data,
+  });
+}
+
+/**
+ * 传感器异常台数(数据感知雷达图)
+ * @param {{ fieldCode: string, datatime?: string }} data
+ */
+export function getAnomalySensorCount(data) {
+  return healthRequest({
+    url: `${PREFIX}/getAnomalySensorCount`,
+    method: "post",
+    data,
+  });
+}
+
+/**
+ * 热力图(检测器 + 传感器)
+ * @param {{ fieldCode: string, datatime?: string }} data
+ */
+export function getAnomalyModelMap(data) {
+  return healthRequest({
+    url: `${PREFIX}/getAnomalyModelMap`,
+    method: "post",
+    data,
+  });
+}
+
+/**
+ * 概览页柱状图统计
+ * @param {{ fieldCode: string, datatime?: string }} data
+ */
+export function getBarChartStats(data) {
+  return healthRequest({
+    url: `${PREFIX}/getBarChartStats`,
+    method: "post",
+    data,
+  });
+}

+ 39 - 0
src/api/healthAnalyse.js

@@ -0,0 +1,39 @@
+import healthRequest from "@/utils/healthRequest";
+
+const PREFIX = "/energy-manage-analyse-service/healthscores";
+
+/**
+ * 风场下所有风机健康查询概览
+ * @param {{ fieldCode: string, datatime?: string }} params
+ */
+export function getHealthOverview(params) {
+  return healthRequest({
+    url: `${PREFIX}/getHealthOverview`,
+    method: "post",
+    params,
+  });
+}
+
+/**
+ * 首页风场健康台数统计
+ * @param {{ fieldCode?: string, datatime?: string }} params
+ */
+export function getHealthscoresWindList(params) {
+  return healthRequest({
+    url: `${PREFIX}/getHealthscoresWindList`,
+    method: "post",
+    params,
+  });
+}
+
+/**
+ * 风机按天数查询趋势图
+ * @param {{ day: number, engineId: string, fieldId: string }} params
+ */
+export function getLastDaysTrend(params) {
+  return healthRequest({
+    url: `${PREFIX}/getLastDaysTrend`,
+    method: "post",
+    params,
+  });
+}

+ 28 - 2
src/store/auth.js

@@ -65,12 +65,38 @@ export default {
         // 重新添加home路由
         router.addRoute(homeRoute);
       }
-      // 添加404页面路由
+      // 添加404页面路由(仅未匹配的业务页面;接口代理路径不进入该页)
+      const apiPathPrefixes = [
+        "/api",
+        "/healthApi",
+        "/health",
+        "/WZLapi",
+        "/ETLapi",
+        "/AnalysisMulti",
+        "/transDataWeb",
+        "/sAlgorithm",
+        "/databaseApi",
+        "/downLoadChart",
+      ];
       router.addRoute({
-        path: "/:pathMatch(.*)*",
+        path: "*",
         name: "NotFound",
         component: () => import("@/views/error/404.vue"),
         meta: { hidden: true },
+        beforeEnter(to, from, next) {
+          const isApiLikePath = apiPathPrefixes.some((prefix) =>
+            to.path.startsWith(prefix),
+          );
+          if (isApiLikePath) {
+            if (from.matched.length) {
+              next(false);
+            } else {
+              next("/home");
+            }
+            return;
+          }
+          next();
+        },
       });
       // 确保导航到动态添加的路由
       router.push("/home");

+ 128 - 0
src/utils/healthRequest.js

@@ -0,0 +1,128 @@
+import axios from "axios";
+import { Message } from "element-ui";
+import router from "../router/index";
+
+const HEALTH_BASE =
+  process.env.NODE_ENV === "development"
+    ? "/healthApi"
+    : window?._BASE_CONFIG?.HEALTH_API || "/healthApi";
+
+const service = axios.create({
+  baseURL: HEALTH_BASE,
+  withCredentials: true,
+  timeout: 10000,
+});
+
+service.interceptors.request.use(
+  (config) => {
+    const token = JSON.parse(sessionStorage.getItem("vuex"))?.auth?.userInfo
+      ?.token;
+    if (token) {
+      config.headers.token = token;
+      config.headers.showIp = "106.120.102.238";
+    } else {
+      router.push("/login");
+    }
+    return config;
+  },
+  (error) => Promise.reject(error),
+);
+
+const isHtmlResponse = (data) => {
+  if (typeof data === "string") {
+    return /<!doctype html|<html/i.test(data);
+  }
+  return false;
+};
+
+service.interceptors.response.use(
+  async (response) => {
+    const { data, status, config } = response;
+    if (isHtmlResponse(data)) {
+      const msg =
+        status === 404
+          ? `健康分析接口不存在(404): ${config?.url || ""}`
+          : `健康分析服务响应异常(${status})`;
+      Message({ message: msg, type: "error", duration: 5 * 1000 });
+      return Promise.reject(new Error(msg));
+    }
+    if (data?.code) {
+      if (data.code !== 200) {
+        Message({
+          message: data.msg || "Error",
+          type: "error",
+          duration: 5 * 1000,
+        });
+        return Promise.reject(new Error(data.msg || "Error"));
+      }
+      return data;
+    }
+    if (data?.type === "application/octet-stream") {
+      return response;
+    }
+    if (data?.type === "application/json") {
+      const resData = JSON.parse(await data.text());
+      if (resData.code !== 200) {
+        Message({
+          message: resData.msg || "Error",
+          type: "error",
+          duration: 5 * 1000,
+        });
+        return Promise.reject(new Error(resData.msg || "Error"));
+      }
+      return resData;
+    }
+    return data;
+  },
+  (error) => {
+    const { response, config } = error;
+    if (response) {
+      const status = response.status;
+      const requestUrl = config?.url || "";
+      if (status === 404) {
+        Message({
+          message: `健康分析接口不存在(404)${requestUrl ? `: ${requestUrl}` : ""}`,
+          type: "error",
+          duration: 5 * 1000,
+        });
+      } else {
+        Message({
+          message:
+            response.data?.message ||
+            response.data?.msg ||
+            `请求失败(${status})`,
+          type: "error",
+          duration: 5 * 1000,
+        });
+      }
+    } else {
+      Message({
+        message: error.message,
+        type: "error",
+        duration: 5 * 1000,
+      });
+    }
+    return Promise.reject(error);
+  },
+);
+
+const healthRequest = async (requestObj) => {
+  const {
+    url,
+    method,
+    data = {},
+    timeout,
+    params,
+    responseType,
+  } = requestObj;
+  return service({
+    url,
+    method: method || "post",
+    data,
+    timeout: timeout || 300000,
+    params: params || (method && method.toLowerCase() === "get" ? data : {}),
+    responseType: responseType || "json",
+  });
+};
+
+export default healthRequest;

+ 8 - 0
src/views/anomalyDetection/anomalyDetectionDetail.vue

@@ -0,0 +1,8 @@
+<!--
+ * @Author: your name
+ * @Date: 2026-06-09 15:11:07
+ * @LastEditTime: 2026-06-09 15:11:08
+ * @LastEditors: milo-MacBook-Pro.local
+ * @Description: In User Settings Edit
+ * @FilePath: /performance-test/src/views/anomalyDetection/anomalyDetectionDeail.vue
+-->

+ 144 - 0
src/views/anomalyDetection/components/AnomalyCarouselPanel.vue

@@ -0,0 +1,144 @@
+<template>
+  <section class="anomaly-card anomaly-carousel-card">
+    <div class="anomaly-carousel-head">
+      <div class="anomaly-title" style="margin-bottom: 0">
+        <i class="el-icon-data-analysis"></i>
+        功能诊断-各模块
+      </div>
+      <div class="anomaly-carousel-controls">
+        <button
+          class="anomaly-carousel-btn"
+          type="button"
+          @click="handlePrev"
+        >
+          <i class="el-icon-arrow-left"></i>
+        </button>
+        <div class="anomaly-carousel-dots" role="tablist">
+          <span
+            v-for="(module, index) in modules"
+            :key="module.title"
+            class="anomaly-carousel-dot"
+            role="tab"
+            :style="dotStyle(index)"
+            @click="handleDotClick(index)"
+          ></span>
+        </div>
+        <button
+          class="anomaly-carousel-btn"
+          type="button"
+          @click="handleNext"
+        >
+          <i class="el-icon-arrow-right"></i>
+        </button>
+      </div>
+    </div>
+    <div ref="chartRef" class="anomaly-carousel-chart"></div>
+  </section>
+</template>
+
+<script>
+import * as echarts from "echarts";
+import { buildCarouselOption } from "../utils/anomalyChartOptions";
+
+export default {
+  name: "AnomalyCarouselPanel",
+  props: {
+    modules: {
+      type: Array,
+      default: () => [],
+    },
+  },
+  data() {
+    return {
+      chart: null,
+      currentIndex: 0,
+      carouselTimer: null,
+    };
+  },
+  mounted() {
+    this.initChart();
+    this.startCarousel();
+    window.addEventListener("resize", this.handleResize);
+  },
+  beforeDestroy() {
+    window.removeEventListener("resize", this.handleResize);
+    this.clearCarousel();
+    if (this.chart) {
+      this.chart.dispose();
+      this.chart = null;
+    }
+  },
+  watch: {
+    modules: {
+      deep: true,
+      handler() {
+        if (this.currentIndex >= this.modules.length) {
+          this.currentIndex = 0;
+        }
+        this.renderChart();
+      },
+    },
+  },
+  methods: {
+    initChart() {
+      if (!this.$refs.chartRef) return;
+      this.chart = echarts.init(this.$refs.chartRef);
+      this.renderChart();
+    },
+    renderChart() {
+      if (!this.chart || !this.modules.length) return;
+      const module = this.modules[this.currentIndex];
+      if (!module) return;
+      this.chart.setOption(buildCarouselOption(module), true);
+    },
+    dotStyle(index) {
+      const module = this.modules[index];
+      const active = index === this.currentIndex;
+      return {
+        backgroundColor: active
+          ? module?.color || "#5ecdee"
+          : "rgba(95, 189, 227, 0.35)",
+        transform: active ? "scale(1.25)" : "scale(1)",
+      };
+    },
+    handlePrev() {
+      if (!this.modules.length) return;
+      this.clearCarousel();
+      this.currentIndex =
+        (this.currentIndex - 1 + this.modules.length) % this.modules.length;
+      this.renderChart();
+      this.startCarousel();
+    },
+    handleNext() {
+      if (!this.modules.length) return;
+      this.clearCarousel();
+      this.currentIndex = (this.currentIndex + 1) % this.modules.length;
+      this.renderChart();
+      this.startCarousel();
+    },
+    handleDotClick(index) {
+      this.clearCarousel();
+      this.currentIndex = index;
+      this.renderChart();
+      this.startCarousel();
+    },
+    startCarousel() {
+      this.clearCarousel();
+      if (this.modules.length <= 1) return;
+      this.carouselTimer = setInterval(() => {
+        this.currentIndex = (this.currentIndex + 1) % this.modules.length;
+        this.renderChart();
+      }, 5000);
+    },
+    clearCarousel() {
+      if (this.carouselTimer) {
+        clearInterval(this.carouselTimer);
+        this.carouselTimer = null;
+      }
+    },
+    handleResize() {
+      this.chart && this.chart.resize();
+    },
+  },
+};
+</script>

+ 80 - 0
src/views/anomalyDetection/components/AnomalyHeatmapPanel.vue

@@ -0,0 +1,80 @@
+<template>
+  <section class="anomaly-card anomaly-heatmap-card">
+    <div class="anomaly-title" style="justify-content: space-between; margin-bottom: 0">
+      <span>
+        <i class="el-icon-s-grid"></i>
+        {{ title }}
+      </span>
+      <span style="color: #6a9bb5; font-size: 10px">异常占比</span>
+    </div>
+    <div ref="chartRef" class="anomaly-heatmap-chart"></div>
+  </section>
+</template>
+
+<script>
+import * as echarts from "echarts";
+import { buildHeatmapOption } from "../utils/anomalyChartOptions";
+
+export default {
+  name: "AnomalyHeatmapPanel",
+  props: {
+    title: {
+      type: String,
+      default: "",
+    },
+    heatmap: {
+      type: Object,
+      default: () => ({
+        yLabels: [],
+        xLabels: [],
+        matrix: [],
+        highColor: "#5ecdee",
+      }),
+    },
+  },
+  data() {
+    return {
+      chart: null,
+    };
+  },
+  mounted() {
+    this.$nextTick(() => {
+      this.initChart();
+    });
+    window.addEventListener("resize", this.handleResize);
+  },
+  beforeDestroy() {
+    window.removeEventListener("resize", this.handleResize);
+    if (this.chart) {
+      this.chart.dispose();
+      this.chart = null;
+    }
+  },
+  watch: {
+    heatmap: {
+      deep: true,
+      handler() {
+        this.renderChart();
+      },
+    },
+  },
+  methods: {
+    initChart() {
+      if (!this.$refs.chartRef) return;
+      this.chart = echarts.init(this.$refs.chartRef);
+      this.renderChart();
+    },
+    renderChart() {
+      if (!this.chart) return;
+      try {
+        this.chart.setOption(buildHeatmapOption(this.heatmap), true);
+      } catch (error) {
+        console.error("渲染热力图失败:", error);
+      }
+    },
+    handleResize() {
+      this.chart && this.chart.resize();
+    },
+  },
+};
+</script>

+ 186 - 0
src/views/anomalyDetection/components/AnomalyOverviewPanel.vue

@@ -0,0 +1,186 @@
+<template>
+  <section class="anomaly-card anomaly-overview-card">
+    <span class="anomaly-radar-panel-title">
+      <i class="el-icon-aim"></i>
+      风场总异常概览
+    </span>
+
+    <div class="anomaly-overview-stats">
+      <div class="anomaly-overview-ring-wrap">
+        <div class="anomaly-overview-ring">
+          <div class="anomaly-num">{{ overview.total }}</div>
+        </div>
+        <div class="anomaly-overview-ring-label">异常机组数</div>
+      </div>
+
+      <div class="anomaly-overview-divider"></div>
+
+      <div class="anomaly-overview-side">
+        <div class="anomaly-stat-card sensor">
+          <div class="anomaly-stat-icon">
+            <span>{{ overview.sensorCount }}</span>
+          </div>
+          <div>
+            <div class="anomaly-stat-label">数据感知异常</div>
+            <div class="anomaly-stat-ratio">{{ overview.sensorRatioText }}</div>
+          </div>
+        </div>
+        <div class="anomaly-stat-card detect">
+          <div class="anomaly-stat-icon">
+            <span>{{ overview.detectorCount }}</span>
+          </div>
+          <div>
+            <div class="anomaly-stat-label">功能诊断异常</div>
+            <div class="anomaly-stat-ratio">{{ overview.detectorRatioText }}</div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="anomaly-radar-wrap">
+      <div class="anomaly-radar-panel">
+        <div class="anomaly-radar-panel-head">
+          <span class="anomaly-radar-panel-title">
+            <i class="el-icon-aim"></i>
+            数据感知
+          </span>
+          <span class="anomaly-radar-panel-unit">异常台数</span>
+        </div>
+        <div ref="sensorRadarRef" class="anomaly-radar-chart"></div>
+      </div>
+      <div class="anomaly-radar-panel">
+        <div class="anomaly-radar-panel-head">
+          <span class="anomaly-radar-panel-title">
+            <i class="el-icon-aim"></i>
+            功能诊断
+          </span>
+          <span class="anomaly-radar-panel-unit">异常台数</span>
+        </div>
+        <div ref="detectorRadarRef" class="anomaly-radar-chart"></div>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script>
+import * as echarts from "echarts";
+import { buildRadarOption } from "../utils/anomalyChartOptions";
+
+export default {
+  name: "AnomalyOverviewPanel",
+  props: {
+    overview: {
+      type: Object,
+      default: () => ({}),
+    },
+    sensorRadar: {
+      type: Object,
+      default: () => ({ indicators: [], values: [] }),
+    },
+    detectorRadar: {
+      type: Object,
+      default: () => ({ indicators: [], values: [] }),
+    },
+  },
+  data() {
+    return {
+      sensorChart: null,
+      detectorChart: null,
+    };
+  },
+  mounted() {
+    this.$nextTick(() => {
+      this.initCharts();
+    });
+    window.addEventListener("resize", this.handleResize);
+  },
+  beforeDestroy() {
+    window.removeEventListener("resize", this.handleResize);
+    this.disposeCharts();
+  },
+  watch: {
+    sensorRadar: {
+      deep: true,
+      handler() {
+        this.renderSensorRadar();
+      },
+    },
+    detectorRadar: {
+      deep: true,
+      handler() {
+        this.renderDetectorRadar();
+      },
+    },
+  },
+  methods: {
+    initCharts() {
+      if (this.$refs.sensorRadarRef) {
+        this.sensorChart = echarts.init(this.$refs.sensorRadarRef);
+      }
+      if (this.$refs.detectorRadarRef) {
+        this.detectorChart = echarts.init(this.$refs.detectorRadarRef);
+      }
+      this.renderSensorRadar();
+      this.renderDetectorRadar();
+    },
+    renderSensorRadar() {
+      if (!this.sensorChart) return;
+      try {
+        this.sensorChart.setOption(
+          buildRadarOption({
+            indicators: this.sensorRadar.indicators || [],
+            values: this.sensorRadar.values || [],
+            seriesName: "数据感知",
+            color: {
+              line: "#6effb0",
+              shadow: "rgba(77, 230, 152, 0.45)",
+              areaInner: "rgba(77, 230, 152, 0.38)",
+              areaMid: "rgba(94, 193, 237, 0.18)",
+              areaOuter: "rgba(94, 193, 237, 0.03)",
+            },
+          }),
+          true,
+        );
+      } catch (error) {
+        console.error("渲染数据感知雷达图失败:", error);
+      }
+    },
+    renderDetectorRadar() {
+      if (!this.detectorChart) return;
+      try {
+        this.detectorChart.setOption(
+          buildRadarOption({
+            indicators: this.detectorRadar.indicators || [],
+            values: this.detectorRadar.values || [],
+            seriesName: "功能诊断",
+            color: {
+              line: "#5ecdee",
+              shadow: "rgba(94, 193, 237, 0.55)",
+              areaInner: "rgba(94, 193, 237, 0.45)",
+              areaMid: "rgba(77, 141, 255, 0.22)",
+              areaOuter: "rgba(77, 141, 255, 0.04)",
+            },
+          }),
+          true,
+        );
+      } catch (error) {
+        console.error("渲染功能诊断雷达图失败:", error);
+      }
+    },
+    handleResize() {
+      this.sensorChart && this.sensorChart.resize();
+      this.detectorChart && this.detectorChart.resize();
+    },
+    disposeCharts() {
+      if (this.sensorChart) {
+        this.sensorChart.dispose();
+        this.sensorChart = null;
+      }
+      if (this.detectorChart) {
+        this.detectorChart.dispose();
+        this.detectorChart = null;
+      }
+    },
+  },
+};
+</script>

+ 54 - 0
src/views/anomalyDetection/components/AnomalyWindCard.vue

@@ -0,0 +1,54 @@
+<template>
+  <div class="anomaly-card anomaly-wind-card">
+    <div class="anomaly-wind-turbine" aria-hidden="true">
+      <span class="anomaly-wind-turbine__pole"></span>
+      <span class="anomaly-wind-turbine__blade"></span>
+    </div>
+    <div class="anomaly-wind-id">{{ card.name }}</div>
+
+    <div class="anomaly-wind-metrics">
+      <div class="anomaly-wind-metrics-top">
+        <div class="anomaly-wind-metric">
+          <span class="anomaly-num">{{ card.summary.windPower }}%</span>
+          <span class="anomaly-wind-label">风速-功率异常</span>
+        </div>
+        <div class="anomaly-wind-metric">
+          <span class="anomaly-num">{{ card.summary.yaw }}%</span>
+          <span class="anomaly-wind-label">偏航系统异常</span>
+        </div>
+      </div>
+      <div class="anomaly-wind-metrics-bottom">
+        <div class="anomaly-wind-metric">
+          <span class="anomaly-num">{{ card.summary.pitch }}%</span>
+          <span class="anomaly-wind-label">变桨系统异常</span>
+        </div>
+        <div class="anomaly-wind-metric">
+          <span class="anomaly-num">{{ card.summary.run }}%</span>
+          <span class="anomaly-wind-label">风机运行状态综合异常</span>
+        </div>
+        <div class="anomaly-wind-metric">
+          <span class="anomaly-num">{{ card.summary.aero }}%</span>
+          <span class="anomaly-wind-label">气动性能异常</span>
+        </div>
+      </div>
+    </div>
+
+    <div class="anomaly-wind-divider"></div>
+    <div class="anomaly-wind-sensors">
+      数据感知异常类型:{{ card.sensorTypes }}
+    </div>
+    <div class="anomaly-wind-detail">详情 →</div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "AnomalyWindCard",
+  props: {
+    card: {
+      type: Object,
+      required: true,
+    },
+  },
+};
+</script>

+ 151 - 0
src/views/anomalyDetection/index.vue

@@ -0,0 +1,151 @@
+<template>
+  <div
+    v-loading="loading"
+    class="anomaly-dashboard-page"
+    element-loading-background="rgba(5, 14, 35, 0.75)"
+  >
+    <HealthHeader
+      v-model="companyCode"
+      :datatime.sync="datatime"
+      @change="handleQueryChange"
+    />
+
+    <div class="anomaly-page__body">
+      <div class="anomaly-top-grid">
+        <AnomalyOverviewPanel
+          :overview="dashboard.overview"
+          :sensor-radar="dashboard.sensorRadar"
+          :detector-radar="dashboard.detectorRadar"
+        />
+
+        <AnomalyHeatmapPanel
+          title="数据感知异常类型分布"
+          :heatmap="dashboard.sensorHeatmap"
+        />
+
+        <AnomalyHeatmapPanel
+          title="功能诊断异常类型分布"
+          :heatmap="dashboard.detectorHeatmap"
+        />
+      </div>
+
+      <AnomalyCarouselPanel :modules="dashboard.carouselModules" />
+
+      <div class="anomaly-wind-list-title">风机列表</div>
+      <div class="anomaly-wind-grid">
+        <template v-if="dashboard.windCards.length">
+          <AnomalyWindCard
+            v-for="card in dashboard.windCards"
+            :key="card.engineId || card.name"
+            :card="card"
+          />
+        </template>
+        <div v-else class="anomaly-card anomaly-wind-empty-card">
+          <span class="anomaly-wind-empty-card__text">暂无数据</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import HealthHeader from "@/views/health/components/health/HealthHeader.vue";
+import AnomalyOverviewPanel from "./components/AnomalyOverviewPanel.vue";
+import AnomalyHeatmapPanel from "./components/AnomalyHeatmapPanel.vue";
+import AnomalyCarouselPanel from "./components/AnomalyCarouselPanel.vue";
+import AnomalyWindCard from "./components/AnomalyWindCard.vue";
+import {
+  getAnomalyModel,
+  getAnomalyModelCount,
+  getAnomalyModelMap,
+  getAnomalyOverview,
+  getAnomalySensorCount,
+} from "@/api/anomalyAnalyse";
+import {
+  getEmptyDashboard,
+  mapAnomalyDashboard,
+} from "./utils/anomalyDashboardMapper";
+
+export default {
+  name: "AnomalyDetection",
+  components: {
+    HealthHeader,
+    AnomalyOverviewPanel,
+    AnomalyHeatmapPanel,
+    AnomalyCarouselPanel,
+    AnomalyWindCard,
+  },
+  data() {
+    return {
+      companyCode: "",
+      selectedField: null,
+      datatime: "",
+      loading: false,
+      dashboard: getEmptyDashboard(),
+    };
+  },
+  mounted() {
+    this.$nextTick(() => {
+      window.dispatchEvent(new Event("resize"));
+    });
+  },
+  methods: {
+    handleQueryChange(data) {
+      const fieldCode = data?.fieldCode || data?.codeNumber;
+      if (!fieldCode) return;
+      this.selectedField = data;
+      this.companyCode = fieldCode;
+      if (data.datatime !== undefined) {
+        this.datatime = data.datatime;
+      }
+      this.fetchDashboard(fieldCode);
+    },
+    buildRequestData(fieldCode) {
+      const payload = { fieldCode };
+      if (this.datatime) {
+        payload.datatime = this.datatime;
+      }
+      return payload;
+    },
+    async fetchDashboard(fieldCode) {
+      if (!fieldCode) return;
+      this.loading = true;
+      const payload = this.buildRequestData(fieldCode);
+      try {
+        const [
+          overviewRes,
+          modelCountRes,
+          sensorCountRes,
+          heatmapRes,
+          modelRes,
+        ] = await Promise.all([
+          getAnomalyOverview(payload),
+          getAnomalyModelCount(payload),
+          getAnomalySensorCount(payload),
+          getAnomalyModelMap(payload),
+          getAnomalyModel(payload),
+        ]);
+        this.dashboard = mapAnomalyDashboard({
+          overviewRes,
+          modelCountRes,
+          sensorCountRes,
+          heatmapRes,
+          modelRes,
+        });
+      } catch (error) {
+        console.error("加载异常检测数据失败:", error);
+        this.dashboard = getEmptyDashboard();
+      } finally {
+        this.loading = false;
+        this.$nextTick(() => {
+          window.dispatchEvent(new Event("resize"));
+        });
+      }
+    },
+  },
+};
+</script>
+
+<style lang="scss">
+@import "./styles/anomaly-dashboard.scss";
+</style>

+ 475 - 0
src/views/anomalyDetection/styles/anomaly-dashboard.scss

@@ -0,0 +1,475 @@
+.anomaly-dashboard-page {
+  height: 100%;
+  min-height: 0;
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  padding: 10px;
+  box-sizing: border-box;
+  background-color: #090a10;
+  color: #d8efff;
+  font-family: "Microsoft YaHei", system-ui, sans-serif;
+  overflow-x: hidden;
+
+  *,
+  *::before,
+  *::after {
+    box-sizing: border-box;
+  }
+
+  .anomaly-card {
+    background: #101628;
+    border: 1px solid rgba(73, 183, 228, 0.28);
+    border-radius: 8px;
+    box-shadow: inset 0 0 14px rgba(65, 176, 225, 0.06);
+  }
+
+  .anomaly-title {
+    color: #b7dff5;
+    font-size: 14px;
+    font-weight: 600;
+    margin-bottom: 10px;
+    display: flex;
+    align-items: center;
+    gap: 6px;
+  }
+
+  .anomaly-num {
+    color: #4facca;
+    font-weight: 700;
+  }
+
+  .anomaly-page__body {
+    flex: 1;
+    min-height: 0;
+    overflow-y: auto;
+  }
+
+  .anomaly-top-grid {
+    display: grid;
+    grid-template-columns: 1fr;
+    gap: 12px;
+    min-height: 520px;
+  }
+
+  @media (min-width: 1024px) {
+    .anomaly-top-grid {
+      grid-template-columns: repeat(3, minmax(0, 1fr));
+    }
+  }
+
+  .anomaly-overview-card {
+    padding: 16px;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+  }
+
+  .anomaly-overview-stats {
+    display: grid;
+    grid-template-columns: 1fr 1px 1fr;
+    align-items: stretch;
+    gap: 16px;
+    margin-top: 8px;
+  }
+
+  .anomaly-overview-ring-wrap {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 12px 0;
+  }
+
+  .anomaly-overview-ring {
+    width: 80px;
+    height: 80px;
+    border-radius: 50%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border: 1px solid rgba(95, 189, 227, 0.45);
+    background: linear-gradient(
+      145deg,
+      rgba(29, 145, 211, 0.35),
+      rgba(22, 30, 56, 0.35)
+    );
+    box-shadow: 0 0 18px rgba(52, 182, 242, 0.22);
+
+    .anomaly-num {
+      font-size: 32px;
+      line-height: 1;
+      color: #d8efff;
+    }
+  }
+
+  .anomaly-overview-ring-label {
+    margin-top: 16px;
+    font-size: 13px;
+    color: #d6eefc;
+    font-weight: 500;
+  }
+
+  .anomaly-overview-divider {
+    background: linear-gradient(
+      to bottom,
+      transparent,
+      rgba(95, 189, 227, 0.35),
+      transparent
+    );
+  }
+
+  .anomaly-overview-side {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    gap: 12px;
+    padding: 4px 0;
+  }
+
+  .anomaly-stat-card {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 12px;
+    border-radius: 12px;
+
+    &.sensor {
+      background: rgba(77, 141, 255, 0.12);
+      border: 1px solid rgba(77, 141, 255, 0.22);
+    }
+
+    &.detect {
+      background: rgba(77, 230, 152, 0.1);
+      border: 1px solid rgba(77, 230, 152, 0.22);
+    }
+  }
+
+  .anomaly-stat-icon {
+    width: 48px;
+    height: 48px;
+    border-radius: 12px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    flex-shrink: 0;
+    font-size: 20px;
+    font-weight: 700;
+  }
+
+  .anomaly-stat-card.sensor .anomaly-stat-icon {
+    background: linear-gradient(
+      145deg,
+      rgba(77, 141, 255, 0.55),
+      rgba(77, 141, 255, 0.15)
+    );
+    color: #6ba3ff;
+    box-shadow: 0 4px 12px rgba(77, 141, 255, 0.2);
+  }
+
+  .anomaly-stat-card.detect .anomaly-stat-icon {
+    background: linear-gradient(
+      145deg,
+      rgba(77, 230, 152, 0.5),
+      rgba(77, 230, 152, 0.15)
+    );
+    color: #6effb0;
+    box-shadow: 0 4px 12px rgba(77, 230, 152, 0.18);
+  }
+
+  .anomaly-stat-label {
+    color: #d8efff;
+    font-size: 12px;
+    font-weight: 500;
+    line-height: 1.3;
+  }
+
+  .anomaly-stat-ratio {
+    color: #8db6d1;
+    font-size: 10px;
+    margin-top: 4px;
+  }
+
+  .anomaly-radar-wrap {
+    margin-top: 14px;
+    padding-top: 14px;
+    border-top: 1px solid rgba(95, 189, 227, 0.22);
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    gap: 8px;
+  }
+
+  .anomaly-radar-panel {
+    min-width: 0;
+    background: #111627;
+    border: 1px solid rgba(95, 189, 227, 0.18);
+    border-radius: 10px;
+    padding: 10px 8px 4px;
+    overflow: hidden;
+    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
+  }
+
+  .anomaly-radar-panel-head {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 2px;
+    padding: 0 6px 4px;
+  }
+
+  .anomaly-radar-panel-title {
+    color: #b7dff5;
+    font-size: 14px;
+    font-weight: 600;
+    display: flex;
+    align-items: center;
+    gap: 4px;
+  }
+
+  .anomaly-radar-panel-unit {
+    color: #6a9bb5;
+    font-size: 10px;
+    white-space: nowrap;
+  }
+
+  .anomaly-radar-chart {
+    width: 100%;
+    height: 220px;
+  }
+
+  .anomaly-heatmap-card {
+    padding: 12px;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    min-height: 520px;
+  }
+
+  .anomaly-heatmap-chart {
+    flex: 1;
+    min-height: 0;
+    width: 100%;
+  }
+
+  .anomaly-carousel-card {
+    padding: 12px;
+    margin-top: 12px;
+  }
+
+  .anomaly-carousel-head {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 12px;
+  }
+
+  .anomaly-carousel-controls {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    position: relative;
+    z-index: 5;
+    flex-shrink: 0;
+  }
+
+  .anomaly-carousel-btn {
+    width: 28px;
+    height: 28px;
+    border-radius: 50%;
+    border: 1px solid rgba(95, 189, 227, 0.35);
+    background: rgba(22, 30, 56, 0.88);
+    color: #b7dff5;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    cursor: pointer;
+    transition: background 0.2s ease;
+    padding: 0;
+
+    &:hover {
+      background: rgba(29, 145, 211, 0.35);
+    }
+  }
+
+  .anomaly-carousel-dots {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    padding: 6px 10px;
+    margin: 0 4px;
+  }
+
+  .anomaly-carousel-dot {
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+    background: rgba(95, 189, 227, 0.35);
+    cursor: pointer;
+    flex-shrink: 0;
+    position: relative;
+    z-index: 6;
+    transition: all 0.2s ease;
+  }
+
+  .anomaly-carousel-chart {
+    height: 340px;
+    width: 100%;
+    position: relative;
+    z-index: 1;
+  }
+
+  .anomaly-wind-list-title {
+    margin: 12px 0 8px;
+    font-size: 14px;
+    color: #9cc7df;
+    font-weight: 600;
+  }
+
+  .anomaly-wind-grid {
+    display: grid;
+    grid-template-columns: repeat(1, minmax(0, 1fr));
+    gap: 12px;
+    padding-bottom: 12px;
+  }
+
+  @media (min-width: 768px) {
+    .anomaly-wind-grid {
+      grid-template-columns: repeat(2, minmax(0, 1fr));
+    }
+  }
+
+  @media (min-width: 1280px) {
+    .anomaly-wind-grid {
+      grid-template-columns: repeat(4, minmax(0, 1fr));
+    }
+  }
+
+  .anomaly-wind-empty-card {
+    grid-column: 1 / -1;
+    min-height: 160px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 24px 12px;
+  }
+
+  .anomaly-wind-empty-card__text {
+    color: #8db6d1;
+    font-size: 14px;
+    letter-spacing: 0.5px;
+  }
+
+  .anomaly-wind-card {
+    position: relative;
+    min-height: 200px;
+    padding: 12px;
+  }
+
+  .anomaly-wind-turbine {
+    position: absolute;
+    right: 10px;
+    top: 4px;
+    width: 72px;
+    height: 88px;
+    display: flex;
+    align-items: flex-end;
+    justify-content: center;
+    filter: drop-shadow(0 0 8px rgba(83, 217, 255, 0.28));
+    opacity: 0.9;
+  }
+
+  .anomaly-wind-turbine__pole {
+    position: absolute;
+    bottom: 0;
+    width: 4px;
+    height: 52px;
+    border-radius: 2px;
+    background: linear-gradient(180deg, #7ee8ff, #2f8eb0);
+  }
+
+  .anomaly-wind-turbine__blade {
+    position: absolute;
+    bottom: 48px;
+    width: 34px;
+    height: 34px;
+    border: 3px solid #53d9ff;
+    border-radius: 50%;
+    background:
+      linear-gradient(0deg, transparent 46%, #53d9ff 46%, #53d9ff 54%, transparent 54%),
+      linear-gradient(60deg, transparent 46%, #53d9ff 46%, #53d9ff 54%, transparent 54%),
+      linear-gradient(120deg, transparent 46%, #53d9ff 46%, #53d9ff 54%, transparent 54%);
+  }
+
+  .anomaly-wind-id {
+    position: absolute;
+    left: 12px;
+    top: 10px;
+    color: #d8efff;
+    font-size: 12px;
+    font-weight: 700;
+    line-height: 1;
+    padding: 4px 8px;
+    border-radius: 10px;
+    border: 1px solid rgba(95, 189, 227, 0.45);
+    background: rgba(22, 30, 56, 0.88);
+  }
+
+  .anomaly-wind-metrics {
+    padding-top: 30px;
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+  }
+
+  .anomaly-wind-metrics-top {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 10px 12px;
+    padding-right: 84px;
+  }
+
+  .anomaly-wind-metrics-bottom {
+    display: grid;
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+    gap: 8px 6px;
+    width: 100%;
+  }
+
+  .anomaly-wind-metric {
+    text-align: center;
+
+    .anomaly-num {
+      display: block;
+      font-size: 18px;
+      line-height: 1.6;
+    }
+  }
+
+  .anomaly-wind-label {
+    display: block;
+    margin-top: 4px;
+    color: #b8d3e6;
+    font-size: 11px;
+    line-height: 1.2;
+  }
+
+  .anomaly-wind-divider {
+    height: 1px;
+    margin-top: 10px;
+    background: rgba(126, 172, 199, 0.45);
+  }
+
+  .anomaly-wind-sensors {
+    margin-top: 16px;
+    font-size: 11px;
+    color: #9abed3;
+    line-height: 1.5;
+  }
+
+  .anomaly-wind-detail {
+    text-align: right;
+    color: #67e8f9;
+    margin-top: 4px;
+    font-size: 12px;
+  }
+}

+ 436 - 0
src/views/anomalyDetection/utils/anomalyChartOptions.js

@@ -0,0 +1,436 @@
+import * as echarts from "echarts";
+
+function formatHeatPercent(value) {
+  const pct = Math.max(0, Math.min(100, Number(value) || 0));
+  return `${Math.round(pct)}%`;
+}
+
+function matrixToHeat(matrix) {
+  const data = [];
+  matrix.forEach((row, y) => {
+    row.forEach((value, x) => {
+      data.push([x, y, Math.max(0, Math.min(100, Number(value) || 0))]);
+    });
+  });
+  return data;
+}
+
+export function buildRadarOption({
+  indicators,
+  values,
+  seriesName,
+  color,
+}) {
+  const safeIndicators = (indicators || []).map((ind) => ({
+    ...ind,
+    max: Math.max(Number(ind.max) || 0, 1),
+  }));
+  const safeValues = safeIndicators.map(
+    (_, idx) => Number(values?.[idx]) || 0,
+  );
+
+  if (!safeIndicators.length) {
+    return {
+      backgroundColor: "transparent",
+      graphic: {
+        type: "text",
+        left: "center",
+        top: "middle",
+        style: {
+          text: "暂无数据",
+          fill: "#8db6d1",
+          fontSize: 14,
+        },
+      },
+    };
+  }
+
+  const areaGradient = new echarts.graphic.RadialGradient(0.5, 0.5, 0.85, [
+    { offset: 0, color: color.areaInner || color.area },
+    { offset: 0.65, color: color.areaMid || color.area },
+    { offset: 1, color: color.areaOuter || "rgba(0,0,0,0)" },
+  ]);
+  const radarIndicators = safeIndicators.map((ind, idx) => ({
+    ...ind,
+    name: `${ind.name}\n{count|${safeValues[idx]}台}`,
+  }));
+
+  return {
+    backgroundColor: "transparent",
+    tooltip: {
+      trigger: "item",
+      backgroundColor: "rgba(16, 21, 39, 0.96)",
+      borderColor: color.line,
+      borderWidth: 1,
+      padding: [10, 14],
+      textStyle: { color: "#d8efff", fontSize: 11 },
+      formatter: () => {
+        const lines = safeIndicators
+          .map((ind, idx) => {
+            const label = ind.name.replace(/\n/g, "");
+            return `<span style="color:${color.line}">●</span> ${label}:<b>${safeValues[idx]}</b> 台`;
+          })
+          .join("<br/>");
+        return `<div style="font-weight:600;margin-bottom:8px;color:#d8efff">${seriesName}</div>${lines}`;
+      },
+    },
+    radar: {
+      indicator: radarIndicators,
+      shape: "polygon",
+      center: ["50%", "54%"],
+      radius: "48%",
+      startAngle: 90,
+      axisName: {
+        color: "#b7dff5",
+        fontSize: safeIndicators.length > 5 ? 7 : 8,
+        lineHeight: 12,
+        fontWeight: 500,
+        rich: {
+          count: {
+            color: color.line,
+            fontWeight: 700,
+            fontSize: safeIndicators.length > 5 ? 8 : 9,
+            lineHeight: 13,
+          },
+        },
+      },
+      axisNameGap: 8,
+      splitNumber: 4,
+      splitLine: {
+        lineStyle: {
+          color: "rgba(101, 167, 201, 0.22)",
+          type: "dashed",
+        },
+      },
+      splitArea: {
+        areaStyle: {
+          color: [
+            "rgba(16, 21, 39, 0.55)",
+            "rgba(20, 28, 52, 0.4)",
+            "rgba(22, 30, 56, 0.25)",
+            "rgba(28, 38, 68, 0.1)",
+          ],
+        },
+      },
+      axisLine: {
+        lineStyle: { color: "rgba(101, 167, 201, 0.28)" },
+      },
+    },
+    series: [
+      {
+        type: "radar",
+        name: seriesName,
+        symbol: "circle",
+        symbolSize: 5,
+        data: [
+          {
+            value: safeValues,
+            name: seriesName,
+            areaStyle: { color: areaGradient, opacity: 0.92 },
+            lineStyle: {
+              color: color.line,
+              width: 2.5,
+              shadowColor: color.shadow || "transparent",
+              shadowBlur: 10,
+            },
+            itemStyle: {
+              color: color.line,
+              borderColor: "rgba(255,255,255,0.85)",
+              borderWidth: 1.5,
+            },
+            label: { show: false },
+            emphasis: {
+              scale: true,
+              lineStyle: { width: 3 },
+              areaStyle: { opacity: 1 },
+              itemStyle: {
+                shadowBlur: 14,
+                shadowColor: color.shadow || color.line,
+              },
+            },
+          },
+        ],
+      },
+    ],
+  };
+}
+
+export function buildHeatmapOption({ yLabels, xLabels, matrix, highColor }) {
+  const safeYLabels = yLabels || [];
+  const safeXLabels = xLabels || [];
+  const safeMatrix = matrix || [];
+
+  if (!safeYLabels.length || !safeXLabels.length) {
+    return {
+      backgroundColor: "transparent",
+      graphic: {
+        type: "text",
+        left: "center",
+        top: "middle",
+        style: {
+          text: "暂无数据",
+          fill: "#8db6d1",
+          fontSize: 14,
+        },
+      },
+    };
+  }
+
+  const visibleCount = 5;
+  const heatXZoomEnd =
+    safeXLabels.length <= visibleCount
+      ? 100
+      : (visibleCount / safeXLabels.length) * 100;
+
+  return {
+    backgroundColor: "transparent",
+    grid: { left: 98, right: 24, top: 32, bottom: 52 },
+    tooltip: {
+      position: "top",
+      backgroundColor: "rgba(16, 21, 39, 0.96)",
+      borderColor: "rgba(95, 189, 227, 0.35)",
+      textStyle: { color: "#d8efff", fontSize: 11 },
+      formatter: (params) =>
+        `${safeYLabels[params.value[1]]}<br/>${safeXLabels[params.value[0]]}:${formatHeatPercent(params.value[2])}`,
+    },
+    visualMap: {
+      min: 0,
+      max: 100,
+      calculable: false,
+      show: false,
+      inRange: { color: ["#101527", highColor] },
+    },
+    xAxis: {
+      type: "category",
+      data: safeXLabels,
+      position: "top",
+      axisLabel: {
+        color: "#8fbfe0",
+        fontSize: 10,
+        margin: 8,
+      },
+      axisLine: { lineStyle: { color: "rgba(101,167,201,0.42)" } },
+      axisTick: { show: false },
+    },
+    yAxis: {
+      type: "category",
+      data: safeYLabels,
+      inverse: true,
+      axisLabel: { color: "#8fbfe0", fontSize: 10 },
+      axisLine: { lineStyle: { color: "rgba(101,167,201,0.42)" } },
+      axisTick: { show: false },
+    },
+    dataZoom: [
+      {
+        type: "inside",
+        xAxisIndex: 0,
+        start: 0,
+        end: heatXZoomEnd,
+        zoomOnMouseWheel: true,
+        moveOnMouseMove: true,
+      },
+      {
+        type: "inside",
+        yAxisIndex: 0,
+        start: 0,
+        end: 100,
+      },
+      {
+        type: "slider",
+        xAxisIndex: 0,
+        start: 0,
+        end: heatXZoomEnd,
+        left: "center",
+        width: "88%",
+        height: 14,
+        bottom: 10,
+        borderColor: "rgba(101,167,201,0.35)",
+        backgroundColor: "rgba(16, 21, 39, 0.85)",
+        fillerColor: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+          { offset: 0, color: "rgba(50,115,138,0.75)" },
+          { offset: 1, color: "rgba(43,103,129,0.75)" },
+        ]),
+        moveHandleSize: 0,
+        showDetail: false,
+      },
+    ],
+    series: [
+      {
+        type: "heatmap",
+        data: matrixToHeat(safeMatrix),
+        label: {
+          show: true,
+          color: "#d8efff",
+          fontSize: 9,
+          formatter: (params) => formatHeatPercent(params.value[2]),
+        },
+        itemStyle: {
+          borderColor: "#0c1122",
+          borderWidth: 1,
+          borderRadius: 0,
+        },
+      },
+    ],
+  };
+}
+
+function adjustColorForChart(color, amount) {
+  const hex = color.replace("#", "");
+  const r = Math.max(
+    0,
+    Math.min(255, parseInt(hex.substr(0, 2), 16) + amount),
+  );
+  const g = Math.max(
+    0,
+    Math.min(255, parseInt(hex.substr(2, 2), 16) + amount),
+  );
+  const b = Math.max(
+    0,
+    Math.min(255, parseInt(hex.substr(4, 2), 16) + amount),
+  );
+  return `rgb(${r}, ${g}, ${b})`;
+}
+
+export function buildCarouselOption(module) {
+  if (!module?.turbines?.length) {
+    return {
+      backgroundColor: "transparent",
+      title: {
+        text: module?.title || "",
+        left: "center",
+        top: 0,
+        textStyle: {
+          color: module?.color || "#5ecdee",
+          fontSize: 14,
+          fontWeight: "bold",
+        },
+      },
+      graphic: {
+        type: "text",
+        left: "center",
+        top: "middle",
+        style: {
+          text: "暂无数据",
+          fill: "#8db6d1",
+          fontSize: 14,
+        },
+      },
+    };
+  }
+
+  const detectorColors =
+    module.detectorColors ||
+    module.detectors.map(
+      (_, i) =>
+        ["#5ecdee", "#4bc1e6", "#f2c84e", "#7eb8d9", "#49A1BB"][i % 5],
+    );
+
+  const series = module.detectors.map((detector, i) => {
+    const barColor = detectorColors[i];
+    return {
+      name: detector.name,
+      type: "bar",
+      data: module.turbines.map((turbine) => turbine.values[i] || 0),
+      barGap: "10%",
+      barMaxWidth: 28,
+      itemStyle: {
+        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+          { offset: 0, color: barColor },
+          { offset: 1, color: adjustColorForChart(barColor, -45) },
+        ]),
+        borderRadius: [3, 3, 0, 0],
+      },
+      label: {
+        show: true,
+        position: "top",
+        color: "#b7dcf3",
+        fontSize: 10,
+        formatter: "{c}%",
+      },
+    };
+  });
+
+  const turbineNames = module.turbines.map((item) => item.name);
+
+  return {
+    backgroundColor: "transparent",
+    title: {
+      text: module.title,
+      left: "center",
+      top: 0,
+      textStyle: {
+        color: module.color,
+        fontSize: 14,
+        fontWeight: "bold",
+      },
+    },
+    tooltip: {
+      trigger: "axis",
+      axisPointer: { type: "shadow" },
+      backgroundColor: "rgba(16, 21, 39, 0.96)",
+      borderColor: "rgba(95, 189, 227, 0.35)",
+      textStyle: { color: "#d8efff" },
+      formatter(params) {
+        let html = `<div style="font-weight:bold;margin-bottom:6px;color:${module.color}">${params[0].name}</div>`;
+        params.forEach((p) => {
+          html += `<div style="display:flex;align-items:center;gap:8px;margin:4px 0">
+            <span style="display:inline-block;width:10px;height:10px;border-radius:2px;background:${p.color}"></span>
+            <span>${p.seriesName}: ${p.value}%</span>
+          </div>`;
+        });
+        return html;
+      },
+    },
+    legend: {
+      data: module.detectors.map((item) => item.name),
+      bottom: 0,
+      textStyle: { color: "#8fbfe0", fontSize: 10 },
+      itemWidth: 12,
+      itemHeight: 12,
+    },
+    grid: { left: "6%", right: "4%", top: "18%", bottom: "28%" },
+    xAxis: {
+      type: "category",
+      data: turbineNames,
+      axisLabel: {
+        color: "#8fbfe0",
+        fontSize: 10,
+        interval: 0,
+        rotate: turbineNames.length > 8 ? 28 : 0,
+      },
+      axisLine: { lineStyle: { color: "rgba(101,167,201,0.45)" } },
+    },
+    dataZoom: [
+      {
+        type: "inside",
+        xAxisIndex: 0,
+        start: 0,
+        end: turbineNames.length > 8 ? 65 : 100,
+      },
+      {
+        type: "slider",
+        xAxisIndex: 0,
+        height: 14,
+        bottom: 36,
+        borderColor: "rgba(101,167,201,0.35)",
+        backgroundColor: "rgba(16, 21, 39, 0.85)",
+        fillerColor: "rgba(50,115,138,0.55)",
+        showDetail: false,
+      },
+    ],
+    yAxis: {
+      type: "value",
+      name: "异常百分比(%)",
+      nameTextStyle: { color: "#8fbfe0", fontSize: 11 },
+      min: 0,
+      max: 100,
+      axisLabel: {
+        color: "#8fbfe0",
+        fontSize: 11,
+        formatter: "{value}%",
+      },
+      splitLine: { lineStyle: { color: "rgba(84,145,180,0.22)" } },
+    },
+    series,
+  };
+}

+ 275 - 0
src/views/anomalyDetection/utils/anomalyDashboardMapper.js

@@ -0,0 +1,275 @@
+const SENSOR_TYPE_FIELDS = [
+  { field: "sensorAnomalyPower", label: "功率值越界" },
+  { field: "sensorAnomalyWind", label: "风速值越界" },
+  { field: "sensorAnomalyPitch", label: "变桨值越界" },
+  { field: "sensorAnomalyTorque", label: "扭矩值越界" },
+  { field: "sensorAnomalySpeed", label: "转速值越界" },
+  { field: "sensorAnomalyWindPwr", label: "风速-功率逻辑悖论" },
+  { field: "sensorAnomalySpdTrq", label: "转速-功率逻辑悖论" },
+];
+
+const SENSOR_RADAR_FIELDS = [
+  { key: "sensorAnomalyWindPwrCount", label: "风速-功率\n逻辑异常" },
+  { key: "sensorAnomalySpdTrqCount", label: "转速-扭矩\n逻辑异常" },
+  { key: "sensorAnomalyTorqueCount", label: "转矩传感器\n异常" },
+  { key: "sensorAnomalyApeedCount", label: "转速传感器\n异常" },
+  { key: "sensorAnomalyPitchCount", label: "变桨传感器\n异常" },
+  { key: "sensorAnomalyWindCount", label: "风速传感器\n异常" },
+  { key: "sensorAnomalyPowerCount", label: "功率传感器\n异常" },
+];
+
+const MODEL_RADAR_FIELDS = [
+  { key: "model1Count", label: "风速-功率\n异常" },
+  { key: "model2Count", label: "偏航系统\n异常" },
+  { key: "model3Count", label: "变桨系统\n异常" },
+  { key: "model4Count", label: "运行状态\n综合异常" },
+  { key: "model5Count", label: "气动性能\n异常" },
+];
+
+export const CAROUSEL_MODULES = [
+  {
+    title: "风速-功率异常",
+    color: "#5ecdee",
+    detectorColors: ["#5ecdee", "#f2c84e"],
+    detectors: [
+      { name: "风功率曲线", ratioKey: "model1WindpwrPowercurveRatio" },
+      { name: "风功率散点", ratioKey: "model1WindpwrScatterRatio" },
+    ],
+  },
+  {
+    title: "偏航系统异常",
+    color: "#4bc1e6",
+    detectorColors: ["#7eb8d9", "#f2c84e"],
+    detectors: [
+      { name: "静态偏航角", ratioKey: "model2YawStaticyawRatio" },
+      { name: "扭缆角度", ratioKey: "model2YawCabletwistRatio" },
+    ],
+  },
+  {
+    title: "变桨系统异常",
+    color: "#49A1BB",
+    detectorColors: ["#49A1BB", "#5ecdee", "#f2c84e"],
+    detectors: [
+      { name: "桨距角调节", ratioKey: "model3PitchPitchregulationRatio" },
+      { name: "变桨-转速-功率协调", ratioKey: "model3PitchPitchcoordRatio" },
+    ],
+  },
+  {
+    title: "风机运行状态综合异常",
+    color: "#3D879D",
+    detectorColors: ["#3D879D", "#f2c84e"],
+    detectors: [
+      { name: "功率质量", ratioKey: "model4CtrlparamPowerqualityRatio" },
+      { name: "运行状态", ratioKey: "model4CtrlparamOperationstateRatio" },
+    ],
+  },
+  {
+    title: "气动性能异常",
+    color: "#f2c84e",
+    detectorColors: ["#f2c84e", "#49A1BB", "#7eb8d9"],
+    detectors: [
+      { name: "Cp检测器", ratioKey: "model5AerodynamicsCpRatio" },
+      { name: "TSR-Cp联合分布检测器", ratioKey: "model5AerodynamicsCpTsrRatio" },
+      { name: "TSR-风速分布检测器", ratioKey: "model5AerodynamicsTsrRatio" },
+    ],
+  },
+];
+
+function toPercent(value) {
+  const num = Number(value);
+  if (!Number.isFinite(num)) return 0;
+  return Math.max(0, Math.min(100, Math.round(num)));
+}
+
+function formatRatioText(count, total) {
+  if (!total) return "占比 0%";
+  const pct = ((Number(count) || 0) / total) * 100;
+  return `占比 ${pct.toFixed(1)}%`;
+}
+
+function buildSensorTypeText(item) {
+  const labels = SENSOR_TYPE_FIELDS.filter(
+    (entry) => Number(item[entry.field]) === 1,
+  ).map((entry) => entry.label);
+  return labels.length ? labels.join("、") : "暂无异常";
+}
+
+function formatEngineName(engineId, engineNameMap) {
+  if (engineNameMap && engineNameMap[engineId]) {
+    return engineNameMap[engineId];
+  }
+  if (!engineId) return "--";
+  const text = String(engineId);
+  if (/号$/.test(text)) return text;
+  if (/^\d+$/.test(text)) return `${text}号`;
+  return text;
+}
+
+function collectEngineNames(mapData) {
+  const names = [];
+  const seen = new Set();
+  Object.values(mapData || {}).forEach((list) => {
+    (list || []).forEach((item) => {
+      const name = item.engineName || item.engineId;
+      if (name && !seen.has(name)) {
+        seen.add(name);
+        names.push(name);
+      }
+    });
+  });
+  return names;
+}
+
+function mapHeatmap(mapData, highColor) {
+  const entries = Object.entries(mapData || {});
+  const yLabels = entries.map(([label]) => label);
+  const xLabels = collectEngineNames(mapData);
+  const matrix = entries.map(([, list]) =>
+    xLabels.map((engineName) => {
+      const hit = (list || []).find((item) => item.engineName === engineName);
+      return toPercent(hit?.ratio);
+    }),
+  );
+  return {
+    yLabels,
+    xLabels,
+    matrix,
+    highColor,
+  };
+}
+
+function buildEngineNameMap(mapData) {
+  const map = {};
+  Object.values(mapData || {}).forEach((list) => {
+    (list || []).forEach((item) => {
+      if (item.engineName) {
+        map[item.engineName] = item.engineName;
+      }
+    });
+  });
+  return map;
+}
+
+function buildEmptyRadar(fields) {
+  return {
+    indicators: fields.map((item) => ({ name: item.label, max: 1 })),
+    values: fields.map(() => 0),
+    max: 1,
+  };
+}
+
+export function getEmptyDashboard() {
+  return {
+    overview: {
+      total: 0,
+      sensorCount: 0,
+      detectorCount: 0,
+      sensorRatioText: "占比 0%",
+      detectorRatioText: "占比 0%",
+    },
+    sensorRadar: buildEmptyRadar(SENSOR_RADAR_FIELDS),
+    detectorRadar: buildEmptyRadar(MODEL_RADAR_FIELDS),
+    sensorHeatmap: { yLabels: [], xLabels: [], matrix: [], highColor: "#4bc1e6" },
+    detectorHeatmap: {
+      yLabels: [],
+      xLabels: [],
+      matrix: [],
+      highColor: "#5ecdee",
+    },
+    carouselModules: CAROUSEL_MODULES.map((module) => ({
+      ...module,
+      turbines: [],
+    })),
+    windCards: [],
+  };
+}
+
+export function mapAnomalyDashboard({
+  overviewRes,
+  modelCountRes,
+  sensorCountRes,
+  heatmapRes,
+  modelRes,
+}) {
+  const overviewData = overviewRes?.data || {};
+  const modelCount = modelCountRes?.data || {};
+  const sensorCount = sensorCountRes?.data || {};
+  const heatmapData = heatmapRes?.data || {};
+  const modelList = modelRes?.data || [];
+
+  const total = Number(overviewData.totalAnomalyCount) || 0;
+  const sensorCountVal = Number(overviewData.sensorAnomalyCount) || 0;
+  const detectorCountVal = Number(overviewData.detectorAnomalyCount) || 0;
+
+  const radarMax = Math.max(modelList.length, total, 1);
+
+  const sensorRadar = {
+    indicators: SENSOR_RADAR_FIELDS.map((item) => ({
+      name: item.label,
+      max: radarMax,
+    })),
+    values: SENSOR_RADAR_FIELDS.map(
+      (item) => Number(sensorCount[item.key]) || 0,
+    ),
+    max: radarMax,
+  };
+
+  const detectorRadar = {
+    indicators: MODEL_RADAR_FIELDS.map((item) => ({
+      name: item.label,
+      max: radarMax,
+    })),
+    values: MODEL_RADAR_FIELDS.map(
+      (item) => Number(modelCount[item.key]) || 0,
+    ),
+    max: radarMax,
+  };
+
+  const engineNameMap = buildEngineNameMap(heatmapData.anomalyChartMap);
+
+  const windCards = modelList.map((item) => ({
+    engineId: item.engineId,
+    name: formatEngineName(item.engineId, engineNameMap),
+    summary: {
+      windPower: toPercent(item.model1WindpwrAnomalyRate),
+      yaw: toPercent(item.model2YawAnomalyRate),
+      pitch: toPercent(item.model3PitchAnomalyRate),
+      run: toPercent(item.model4CtrlparamAnomalyRate),
+      aero: toPercent(item.model5AerodynamicsAnomalyRatio),
+    },
+    sensorTypes: buildSensorTypeText(item),
+    raw: item,
+  }));
+
+  const carouselModules = CAROUSEL_MODULES.map((module) => ({
+    ...module,
+    turbines: windCards.map((card) => ({
+      name: card.name,
+      values: module.detectors.map((detector) =>
+        toPercent(card.raw?.[detector.ratioKey]),
+      ),
+    })),
+  }));
+
+  return {
+    overview: {
+      total,
+      sensorCount: sensorCountVal,
+      detectorCount: detectorCountVal,
+      sensorRatioText: formatRatioText(sensorCountVal, total),
+      detectorRatioText: formatRatioText(detectorCountVal, total),
+    },
+    sensorRadar,
+    detectorRadar,
+    sensorHeatmap: mapHeatmap(
+      heatmapData.anomalySensorMap,
+      "#4bc1e6",
+    ),
+    detectorHeatmap: mapHeatmap(
+      heatmapData.anomalyChartMap,
+      "#5ecdee",
+    ),
+    carouselModules,
+    windCards,
+  };
+}

+ 156 - 26
src/views/health/components/health/HealthHeader.vue

@@ -1,17 +1,34 @@
 <template>
-  <div class="health-header__farm" ref="farmRoot">
-    <i class="el-icon-location health-header__farm-icon"></i>
-    <selecttree
-      class="health-header__selecttree"
-      size="small"
-      popper-class="health-header-tree-popper"
-      placeholder="请选择所属公司"
-      :list="parentOpt"
-      type="1"
-      v-model="companyCode"
-      @change="handleSelectChange"
-      :defaultParentProps="defaultParentProps"
-    />
+  <div class="health-header__tools" ref="farmRoot">
+    <div class="health-header__item">
+      <i class="el-icon-location health-header__farm-icon"></i>
+      <selecttree
+        class="health-header__selecttree"
+        size="small"
+        popper-class="health-header-tree-popper"
+        placeholder="请选择所属公司"
+        :list="parentOpt"
+        v-model="companyCode"
+        @change="handleSelectChange"
+        :defaultParentProps="defaultParentProps"
+      />
+    </div>
+
+    <div class="health-header__item health-header__item--date">
+      <span class="health-header__label">时间:</span>
+      <el-date-picker
+        v-model="localDatatime"
+        type="date"
+        size="small"
+        placeholder="选择日期"
+        format="yyyy/MM/dd"
+        value-format="yyyy-MM-dd"
+        class="health-header__date"
+        clearable
+        popper-class="health-header-date-popper"
+        @change="handleDateChange"
+      />
+    </div>
   </div>
 </template>
 
@@ -19,6 +36,15 @@
 import selecttree from "@/components/selecttree.vue";
 import { getSysOrganizationAuthTreeByRoleId } from "@/api/ledger.js";
 
+function getYesterdayString() {
+  const date = new Date();
+  date.setDate(date.getDate() - 1);
+  const y = date.getFullYear();
+  const m = String(date.getMonth() + 1).padStart(2, "0");
+  const d = String(date.getDate()).padStart(2, "0");
+  return `${y}-${m}-${d}`;
+}
+
 export default {
   name: "HealthHeader",
   components: {
@@ -29,27 +55,41 @@ export default {
       type: String,
       default: "",
     },
+    datatime: {
+      type: String,
+      default: "",
+    },
   },
   data() {
     return {
       companyCode: this.value,
+      localDatatime: this.datatime || getYesterdayString(),
       parentOpt: [],
       defaultParentProps: {
         children: "children",
         label: "companyName",
         value: "codeNumber",
       },
+      selectedField: null,
     };
   },
   watch: {
     value(val) {
       this.companyCode = val;
     },
+    datatime(val) {
+      if (val !== this.localDatatime) {
+        this.localDatatime = val;
+      }
+    },
     companyCode(val) {
       this.$emit("input", val);
     },
   },
   created() {
+    if (!this.datatime && this.localDatatime) {
+      this.$emit("update:datatime", this.localDatatime);
+    }
     this.GETtree();
   },
   mounted() {
@@ -72,14 +112,10 @@ export default {
     },
     unmountFromHeader() {
       const el = this.$refs.farmRoot;
-      if (
-        this._portalPlaceholder &&
-        this._portalPlaceholder.parentNode &&
-        el
-      ) {
+      if (this._portalPlaceholder && this._portalPlaceholder.parentNode && el) {
         this._portalPlaceholder.parentNode.insertBefore(
           el,
-          this._portalPlaceholder
+          this._portalPlaceholder,
         );
         this._portalPlaceholder.parentNode.removeChild(this._portalPlaceholder);
       } else if (el && el.parentNode) {
@@ -87,16 +123,27 @@ export default {
       }
       this._portalPlaceholder = null;
     },
+    getDefaultTreeNode(treeData) {
+      const root = treeData && treeData[0];
+      if (!root || !root.children || !root.children.length) {
+        return null;
+      }
+      return root.children[0];
+    },
     async GETtree() {
       try {
         const res = await getSysOrganizationAuthTreeByRoleId();
         const processedData = this.processTreeData(res.data || []);
+        const defaultNode = this.getDefaultTreeNode(processedData);
+
+        if (defaultNode && defaultNode.codeNumber) {
+          this.companyCode = defaultNode.codeNumber;
+        }
         this.parentOpt = processedData;
 
-        const defaultdata = (res.data || [])[0];
-        if (defaultdata) {
+        if (defaultNode) {
           this.$nextTick(() => {
-            this.handleSelectChange(defaultdata);
+            this.handleSelectChange(defaultNode);
           });
         }
       } catch (error) {
@@ -119,20 +166,44 @@ export default {
       });
       return processedData;
     },
+    emitQueryChange() {
+      const fieldCode =
+        this.selectedField?.fieldCode || this.selectedField?.codeNumber;
+      if (!fieldCode) return;
+      this.$emit("change", {
+        ...this.selectedField,
+        fieldCode,
+        datatime: this.localDatatime || "",
+      });
+    },
     handleSelectChange(data) {
       if (!data || !data.codeNumber) {
         return;
       }
-      this.companyCode = data.codeNumber;
-      this.$emit("input", data.codeNumber);
-      this.$emit("change", data);
+      const fieldCode = data.codeNumber;
+      this.companyCode = fieldCode;
+      this.selectedField = { ...data, fieldCode };
+      this.$emit("input", fieldCode);
+      this.emitQueryChange();
+    },
+    handleDateChange(val) {
+      this.localDatatime = val || "";
+      this.$emit("update:datatime", this.localDatatime);
+      this.$emit("date-change", this.localDatatime);
+      this.emitQueryChange();
     },
   },
 };
 </script>
 
 <style lang="scss" scoped>
-.health-header__farm {
+.health-header__tools {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.health-header__item {
   display: flex;
   align-items: center;
   gap: 8px;
@@ -148,12 +219,23 @@ export default {
   }
 }
 
+.health-header__item--date {
+  padding-right: 8px;
+}
+
 .health-header__farm-icon {
   color: #22d3ee;
   font-size: 16px;
   flex-shrink: 0;
 }
 
+.health-header__label {
+  color: #94a3b8;
+  font-size: 13px;
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+
 .health-header__selecttree {
   width: 220px;
 
@@ -170,6 +252,29 @@ export default {
     color: #64748b !important;
   }
 }
+
+.health-header__date {
+  width: 130px;
+
+  ::v-deep .el-input__prefix {
+    display: none !important;
+  }
+
+  ::v-deep .el-input__inner {
+    background: transparent !important;
+    border: none !important;
+    color: #e2e8f0 !important;
+    padding-left: 0 !important;
+    padding-right: 28px !important;
+    height: 28px;
+    line-height: 28px;
+  }
+
+  ::v-deep .el-input__suffix,
+  ::v-deep .el-input__icon {
+    color: #22d3ee !important;
+  }
+}
 </style>
 
 <style lang="scss">
@@ -220,4 +325,29 @@ export default {
     color: #ffffff !important;
   }
 }
+
+.health-header-date-popper {
+  background: #0d1930 !important;
+  border: 1px solid rgba(0, 242, 255, 0.25) !important;
+  color: #b7dff5 !important;
+
+  .el-date-picker__header-label,
+  .el-date-table th,
+  .el-date-table td span {
+    color: #b7dff5 !important;
+  }
+
+  .el-date-table td.available:hover span {
+    color: #00f2ff !important;
+  }
+
+  .el-date-table td.current:not(.disabled) span {
+    background: #0891b2 !important;
+    color: #ffffff !important;
+  }
+
+  .popper__arrow::after {
+    border-bottom-color: #0d1930 !important;
+  }
+}
 </style>

+ 2 - 2
src/views/health/components/health/HealthRankingPanel.vue

@@ -16,7 +16,7 @@
         >
           <div
             v-for="(item, index) in healthyList"
-            :key="item.name"
+            :key="item.engineId || `healthy-${item.name}-${index}`"
             class="health-ranking-panel__row health-ranking-panel__row--good"
           >
             <span
@@ -56,7 +56,7 @@
         >
           <div
             v-for="(item, index) in riskList"
-            :key="item.name"
+            :key="item.engineId || `risk-${item.name}-${index}`"
             class="health-ranking-panel__row"
             :class="[
               item.rowClass === 'strong'

+ 178 - 58
src/views/health/components/health/HealthUnitDetailDrawer.vue

@@ -6,7 +6,6 @@
     :title="drawerTitle"
     custom-class="health-unit-detail-drawer"
     append-to-body
-    @opened="handleOpened"
     @closed="handleClosed"
   >
     <div class="unit-detail">
@@ -29,7 +28,13 @@
             <el-radio-button label="365">近1年</el-radio-button>
           </el-radio-group>
         </div>
-        <div ref="overallTrendRef" class="unit-detail__chart"></div>
+        <div
+          v-loading="trendLoading.overall"
+          element-loading-background="rgba(5, 14, 35, 0.6)"
+          class="unit-detail__chart-wrap"
+        >
+          <div ref="overallTrendRef" class="unit-detail__chart"></div>
+        </div>
       </section>
 
       <section
@@ -57,7 +62,9 @@
             ></i>
             {{ statusInfo(item.status).label }}
           </span>
-          <span v-else class="unit-detail__health-status unit-detail__health-status--empty"
+          <span
+            v-else
+            class="unit-detail__health-status unit-detail__health-status--empty"
             >/</span
           >
         </div>
@@ -77,7 +84,16 @@
             <el-radio-button label="365">近1年</el-radio-button>
           </el-radio-group>
         </div>
-        <div ref="structuralTrendRef" class="unit-detail__chart unit-detail__chart--tall"></div>
+        <div
+          v-loading="trendLoading.structural"
+          element-loading-background="rgba(5, 14, 35, 0.6)"
+          class="unit-detail__chart-wrap"
+        >
+          <div
+            ref="structuralTrendRef"
+            class="unit-detail__chart unit-detail__chart--tall"
+          ></div>
+        </div>
       </section>
 
       <section class="unit-detail__section">
@@ -94,7 +110,16 @@
             <el-radio-button label="365">近1年</el-radio-button>
           </el-radio-group>
         </div>
-        <div ref="systemTrendRef" class="unit-detail__chart unit-detail__chart--tall"></div>
+        <div
+          v-loading="trendLoading.system"
+          element-loading-background="rgba(5, 14, 35, 0.6)"
+          class="unit-detail__chart-wrap"
+        >
+          <div
+            ref="systemTrendRef"
+            class="unit-detail__chart unit-detail__chart--tall"
+          ></div>
+        </div>
       </section>
 
       <section class="unit-detail__section">
@@ -111,7 +136,16 @@
             <el-radio-button label="365">近1年</el-radio-button>
           </el-radio-group>
         </div>
-        <div ref="componentTrendRef" class="unit-detail__chart unit-detail__chart--tall"></div>
+        <div
+          v-loading="trendLoading.component"
+          element-loading-background="rgba(5, 14, 35, 0.6)"
+          class="unit-detail__chart-wrap"
+        >
+          <div
+            ref="componentTrendRef"
+            class="unit-detail__chart unit-detail__chart--tall"
+          ></div>
+        </div>
       </section>
     </div>
 
@@ -123,11 +157,13 @@
 
 <script>
 import * as echarts from "echarts";
+import { getLastDaysTrend } from "@/api/healthAnalyse";
 import {
-  buildTrendData,
-  getUnitDetailData,
-  HEALTH_STATUS,
-} from "./unitDetailData";
+  getEmptyTrendChart,
+  mapTendencyToChartData,
+  normalizeTendencyList,
+} from "../../utils/healthTrendMapper";
+import { getUnitDetailData, HEALTH_STATUS } from "./unitDetailData";
 
 export default {
   name: "HealthUnitDetailDrawer",
@@ -150,6 +186,13 @@ export default {
         system: "7",
         component: "7",
       },
+      trendLoading: {
+        overall: false,
+        structural: false,
+        system: false,
+        component: false,
+      },
+      trendCache: {},
       charts: {},
     };
   },
@@ -166,19 +209,34 @@ export default {
       return "机组详情";
     },
     unitName() {
-      return (this.unit && this.unit.name) || "XXX";
+      const overview = this.overviewItem;
+      return (
+        this.unit.fullName ||
+        this.unit.name ||
+        overview.engineName ||
+        overview.engineId ||
+        "未知风机"
+      );
+    },
+    /** healthOverviewListVOList 单项,抽屉静态数据唯一来源 */
+    overviewItem() {
+      return this.unit.overview || this.unit;
     },
   },
   watch: {
+    visible(val) {
+      if (val) {
+        this.$nextTick(() => {
+          this.handleOpened();
+        });
+      }
+    },
     unit: {
       deep: true,
       handler(val) {
         this.detail = getUnitDetailData(val);
         if (this.visible) {
-          this.$nextTick(() => {
-            this.renderGauge();
-            this.renderAllTrends();
-          });
+          this.refreshDrawerContent();
         }
       },
     },
@@ -191,23 +249,51 @@ export default {
     statusInfo(status) {
       return HEALTH_STATUS[status] || { label: "--", color: "#64748b" };
     },
-    handleOpened() {
+    async handleOpened() {
       this.timeRange = {
         overall: "7",
         structural: "7",
         system: "7",
         component: "7",
       };
+      this.trendCache = {};
       this.detail = getUnitDetailData(this.unit);
-      this.$nextTick(() => {
-        this.initCharts();
-        window.addEventListener("resize", this.handleResize);
-      });
+      window.addEventListener("resize", this.handleResize);
+      await this.refreshDrawerContent();
     },
     handleClosed() {
       window.removeEventListener("resize", this.handleResize);
       this.disposeCharts();
     },
+    async waitForChartRefs(maxRetry = 20) {
+      for (let i = 0; i < maxRetry; i += 1) {
+        await this.$nextTick();
+        if (this.$refs.gaugeRef && this.$refs.overallTrendRef) {
+          return true;
+        }
+      }
+      return false;
+    },
+    getTrendQueryParams() {
+      const overview = this.overviewItem;
+      return {
+        engineId: overview.engineId,
+        fieldId: overview.fieldId,
+      };
+    },
+    async prefetchTrendDays() {
+      const days = [...new Set(Object.values(this.timeRange))];
+      await Promise.all(days.map((day) => this.fetchTrendData(day)));
+    },
+    async refreshDrawerContent() {
+      await this.prefetchTrendDays();
+      const ready = await this.waitForChartRefs();
+      if (!ready) {
+        console.warn("健康详情抽屉图表容器未就绪");
+        return;
+      }
+      this.initCharts();
+    },
     initCharts() {
       this.initChart("gauge", this.$refs.gaugeRef);
       this.initChart("overall", this.$refs.overallTrendRef);
@@ -339,16 +425,21 @@ export default {
           symbolSize: 6,
           showSymbol: pointCount <= 15,
           lineStyle: { width: 2, color: item.color },
-          itemStyle: { color: item.color, borderColor: "#0d1930", borderWidth: 1 },
-          label: showPointLabel && index === 0
-            ? {
-                show: true,
-                position: "top",
-                distance: 6,
-                color: "#94a3b8",
-                fontSize: 10,
-              }
-            : { show: false },
+          itemStyle: {
+            color: item.color,
+            borderColor: "#0d1930",
+            borderWidth: 1,
+          },
+          label:
+            showPointLabel && index === 0
+              ? {
+                  show: true,
+                  position: "top",
+                  distance: 6,
+                  color: "#94a3b8",
+                  fontSize: 10,
+                }
+              : { show: false },
           areaStyle: showLegend
             ? undefined
             : {
@@ -361,41 +452,66 @@ export default {
         })),
       };
     },
+    async fetchTrendData(day) {
+      const cacheKey = String(day);
+      if (this.trendCache[cacheKey]) {
+        return this.trendCache[cacheKey];
+      }
+      const { engineId, fieldId } = this.getTrendQueryParams();
+      if (!engineId || !fieldId) {
+        console.warn("getLastDaysTrend 缺少 engineId 或 fieldId", {
+          engineId,
+          fieldId,
+          overview: this.overviewItem,
+        });
+        this.trendCache[cacheKey] = [];
+        return [];
+      }
+      const res = await getLastDaysTrend({
+        day: Number(day),
+        engineId,
+        fieldId,
+      });
+      const list = normalizeTendencyList(res.data);
+      this.trendCache[cacheKey] = list;
+      return list;
+    },
+    async renderTrendChart(chartKey, rangeKey, showLegend) {
+      const day = this.timeRange[rangeKey];
+      this.$set(this.trendLoading, chartKey, true);
+      try {
+        const list = await this.fetchTrendData(day);
+        const trendData = list.length
+          ? mapTendencyToChartData(list, chartKey)
+          : getEmptyTrendChart();
+        const chart = this.charts[chartKey];
+        if (chart) {
+          chart.setOption(this.buildLineOption(trendData, showLegend), true);
+        }
+      } catch (error) {
+        console.error(`加载${chartKey}趋势失败:`, error);
+        const chart = this.charts[chartKey];
+        if (chart) {
+          chart.setOption(
+            this.buildLineOption(getEmptyTrendChart(), showLegend),
+            true,
+          );
+        }
+      } finally {
+        this.$set(this.trendLoading, chartKey, false);
+      }
+    },
     renderOverallTrend() {
-      const chart = this.charts.overall;
-      if (!chart) return;
-      const trendData = buildTrendData(
-        this.detail.trendMeta.overall,
-        this.timeRange.overall
-      );
-      chart.setOption(this.buildLineOption(trendData, false), true);
+      this.renderTrendChart("overall", "overall", false);
     },
     renderStructuralTrend() {
-      const chart = this.charts.structural;
-      if (!chart) return;
-      const trendData = buildTrendData(
-        this.detail.trendMeta.structural,
-        this.timeRange.structural
-      );
-      chart.setOption(this.buildLineOption(trendData, true), true);
+      this.renderTrendChart("structural", "structural", true);
     },
     renderSystemTrend() {
-      const chart = this.charts.system;
-      if (!chart) return;
-      const trendData = buildTrendData(
-        this.detail.trendMeta.system,
-        this.timeRange.system
-      );
-      chart.setOption(this.buildLineOption(trendData, true), true);
+      this.renderTrendChart("system", "system", true);
     },
     renderComponentTrend() {
-      const chart = this.charts.component;
-      if (!chart) return;
-      const trendData = buildTrendData(
-        this.detail.trendMeta.component,
-        this.timeRange.component
-      );
-      chart.setOption(this.buildLineOption(trendData, true), true);
+      this.renderTrendChart("component", "component", true);
     },
     renderAllTrends() {
       this.renderOverallTrend();
@@ -482,6 +598,10 @@ export default {
   height: 180px;
 }
 
+.unit-detail__chart-wrap {
+  min-height: 180px;
+}
+
 .unit-detail__chart {
   width: 100%;
   height: 180px;

+ 50 - 109
src/views/health/components/health/unitDetailData.js

@@ -5,146 +5,87 @@ export const HEALTH_STATUS = {
   poor: { label: "差", color: "#FF4A4A" },
 };
 
-const TREND_COLORS = ["#22d3ee", "#1E7CF8", "#14F39A", "#F7C43B", "#FF4A4A", "#C58CFF"];
+const LEVEL_STATUS_MAP = {
+  优: "excellent",
+  良: "good",
+  中: "fair",
+  差: "poor",
+};
 
-function getDateLabels(count) {
-  const labels = [];
-  const now = new Date();
-  for (let i = count - 1; i >= 0; i -= 1) {
-    const date = new Date(now);
-    date.setDate(date.getDate() - i);
-    const month = String(date.getMonth() + 1).padStart(2, "0");
-    const day = String(date.getDate()).padStart(2, "0");
-    labels.push(`${month}/${day}`);
-  }
-  return labels;
+function formatScore(score) {
+  if (score == null || Number.isNaN(Number(score))) return null;
+  return Math.round(Number(score));
 }
 
-function getMonthLabels(count = 12) {
-  const labels = [];
-  const now = new Date();
-  for (let i = count - 1; i >= 0; i -= 1) {
-    const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
-    labels.push(`${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}`);
+function scoreToStatus(score) {
+  const value = formatScore(score);
+  if (value == null) {
+    return { score: null, status: null };
   }
-  return labels;
-}
-
-function getIndexLabels(count) {
-  return Array.from({ length: count }, (_, i) => String(i + 1));
-}
-
-function clampScore(value) {
-  return Math.max(0, Math.min(100, Math.round(value)));
-}
-
-function buildSeriesValues(count, base, offset = 0) {
-  return Array.from({ length: count }, (_, i) =>
-    clampScore(base + Math.sin((i + offset) * 0.75) * 14 + ((i + offset) % 5) * 3)
-  );
-}
-
-export function getTrendDays(range) {
-  if (range === "30") return 30;
-  if (range === "365") return 12;
-  return 7;
-}
-
-function getTrendLabels(range) {
-  if (range === "365") return getMonthLabels(12);
-  if (range === "30") return getDateLabels(30);
-  return getIndexLabels(7);
+  if (value >= 90) return { score: value, status: "excellent" };
+  if (value >= 75) return { score: value, status: "good" };
+  if (value >= 60) return { score: value, status: "fair" };
+  return { score: value, status: "poor" };
 }
 
-function buildSingleTrendByRange(range, baseScore) {
-  if (range === "7") {
-    return {
-      labels: getIndexLabels(7),
-      values: [23, 13, 12, 23, 55, 23, clampScore(baseScore || 80)],
-    };
-  }
-
-  const count = getTrendDays(range);
-  const labels = getTrendLabels(range);
-  const values = buildSeriesValues(count, baseScore, 1);
-  return { labels, values };
+function levelToStatus(level) {
+  return LEVEL_STATUS_MAP[level] || null;
 }
 
-function buildMultiTrendByRange(names, bases, range) {
-  const count = getTrendDays(range);
-  const labels = getTrendLabels(range);
-
+function buildHealthItem(name, score, options = {}) {
+  const statusData = scoreToStatus(score);
   return {
-    labels,
-    series: names.map((name, index) => ({
-      name,
-      color: TREND_COLORS[index % TREND_COLORS.length],
-      data: buildSeriesValues(count, bases[index] || 70, index + 1),
-    })),
+    name,
+    score: statusData.score,
+    status: statusData.status,
+    ...options,
   };
 }
 
+/**
+ * 抽屉非趋势图数据:仅来源于 healthOverviewListVOList 单项(unit.overview)
+ * 趋势图由 getLastDaysTrend 单独请求
+ */
 export function getUnitDetailData(unit = {}) {
-  const score = unit.score || 85;
+  const overview = unit.overview || unit;
+  const gaugeScore = formatScore(overview.overallScore) || 0;
+  const overallStatus =
+    levelToStatus(overview.overallLevel) || scoreToStatus(gaugeScore).status;
 
   return {
-    gaugeScore: score,
+    gaugeScore,
+    overallLevel: overview.overallLevel || null,
+    overallStatus,
+    engineId: overview.engineId,
+    fieldId: overview.fieldId,
+    engineName: overview.engineName || overview.engineId,
+    machineTypeCode: overview.machineTypeCode,
     healthSections: [
       {
         title: "结构健康",
         items: [
-          { name: "叶轮", score: 92, status: "excellent" },
-          { name: "塔筒", score: 76, status: "good" },
+          buildHealthItem("叶轮", overview.rotorScore),
+          buildHealthItem("塔筒", overview.towerScore),
         ],
       },
       {
         title: "系统健康",
         items: [
-          { name: "偏航系统", score: 40, status: "poor" },
-          { name: "变桨系统", score: 64, status: "fair" },
-          { name: "液压系统", score: 71, status: "good" },
-          { name: "主控系统", score: 71, status: "good" },
+          buildHealthItem("偏航系统", overview.yawSystemScore),
+          buildHealthItem("变桨系统", overview.pitchSystemScore),
+          buildHealthItem("液压系统", overview.hydraulicSystemScore),
+          buildHealthItem("主控系统", overview.controlSystemScore),
         ],
       },
       {
         title: "部件健康",
         items: [
-          { name: "发电机", score: 76, status: "good" },
-          { name: "齿轮箱", score: 71, status: "good" },
-          { name: "主轴", score: null, status: null, help: true },
-          { name: "变流器", score: 95, status: "excellent" },
+          buildHealthItem("发电机", overview.generatorScore),
+          buildHealthItem("齿轮箱", overview.gearboxScore),
+          buildHealthItem("主轴", overview.mainShaftScore, { help: true }),
+          buildHealthItem("变流器", overview.converterScore),
         ],
       },
     ],
-    trendMeta: {
-      overall: { type: "single", base: score },
-      structural: {
-        type: "multi",
-        names: ["叶轮", "塔筒"],
-        bases: [92, 76],
-      },
-      system: {
-        type: "multi",
-        names: ["偏航系统", "变桨系统", "液压系统", "主控系统"],
-        bases: [40, 64, 71, 71],
-      },
-      component: {
-        type: "multi",
-        names: ["发电机", "齿轮箱", "主轴", "变流器"],
-        bases: [76, 71, 68, 95],
-      },
-    },
   };
 }
-
-export function buildTrendData(meta, range) {
-  if (meta.type === "single") {
-    const trend = buildSingleTrendByRange(range, meta.base);
-    return {
-      labels: trend.labels,
-      series: [{ name: "综合健康", color: "#22d3ee", data: trend.values }],
-    };
-  }
-
-  return buildMultiTrendByRange(meta.names, meta.bases, range);
-}

+ 66 - 21
src/views/health/index.vue

@@ -1,6 +1,14 @@
 <template>
-  <div class="health-dashboard-page">
-    <HealthHeader v-model="companyCode" @change="handleFarmChange" />
+  <div
+    v-loading="loading"
+    class="health-dashboard-page"
+    element-loading-background="rgba(5, 14, 35, 0.75)"
+  >
+    <HealthHeader
+      v-model="companyCode"
+      :datatime.sync="datatime"
+      @change="handleQueryChange"
+    />
 
     <div class="health-page__body">
       <main class="dashboard-main">
@@ -22,7 +30,7 @@
         <section class="health-unit-grid">
           <HealthUnitCard
             v-for="unit in unitCards"
-            :key="unit.name"
+            :key="unit.engineId || unit.name"
             :unit="unit"
           />
         </section>
@@ -37,14 +45,11 @@ import HealthScorePanel from "./components/health/HealthScorePanel.vue";
 import HealthSubsystemPanel from "./components/health/HealthSubsystemPanel.vue";
 import HealthRankingPanel from "./components/health/HealthRankingPanel.vue";
 import HealthUnitCard from "./components/health/HealthUnitCard.vue";
+import { getHealthOverview } from "@/api/healthAnalyse";
 import {
-  healthyRankList,
-  riskRankList,
-  scoreDistribution,
-  scoreSummary,
-  subsystemData,
-  unitCards,
-} from "./components/health/dashboardData";
+  getEmptyDashboard,
+  mapHealthOverviewToDashboard,
+} from "./utils/healthDashboardMapper";
 
 export default {
   name: "HealthDashboard",
@@ -56,15 +61,19 @@ export default {
     HealthUnitCard,
   },
   data() {
+    const empty = getEmptyDashboard();
     return {
       companyCode: "",
-      totalScore: 92,
-      scoreDistribution,
-      scoreSummary,
-      subsystemData,
-      healthyRankList,
-      riskRankList,
-      unitCards,
+      selectedField: null,
+      datatime: "",
+      loading: false,
+      totalScore: empty.totalScore,
+      scoreDistribution: empty.scoreDistribution,
+      scoreSummary: empty.scoreSummary,
+      subsystemData: empty.subsystemData,
+      healthyRankList: empty.healthyRankList,
+      riskRankList: empty.riskRankList,
+      unitCards: empty.unitCards,
     };
   },
   mounted() {
@@ -73,10 +82,46 @@ export default {
     });
   },
   methods: {
-    handleFarmChange(data) {
-      if (!data || !data.codeNumber) return;
-      // TODO: 切换风场/组织后刷新仪表盘数据
-      console.log("当前选中:", data.companyName || data.fieldName, data);
+    handleQueryChange(data) {
+      const fieldCode = data?.fieldCode || data?.codeNumber;
+      if (!fieldCode) return;
+      this.selectedField = data;
+      this.companyCode = fieldCode;
+      if (data.datatime !== undefined) {
+        this.datatime = data.datatime;
+      }
+      this.fetchDashboard(fieldCode);
+    },
+    async fetchDashboard(fieldCode) {
+      if (!fieldCode) return;
+      this.loading = true;
+      try {
+        const params = { fieldCode };
+        if (this.datatime) {
+          params.datatime = this.datatime;
+        }
+        const res = await getHealthOverview(params);
+        const mapped = mapHealthOverviewToDashboard(res.data || {});
+        this.applyDashboard(mapped);
+      } catch (error) {
+        console.error("加载健康概览失败:", error);
+        const empty = getEmptyDashboard();
+        this.applyDashboard(empty);
+      } finally {
+        this.loading = false;
+        this.$nextTick(() => {
+          window.dispatchEvent(new Event("resize"));
+        });
+      }
+    },
+    applyDashboard(mapped) {
+      this.totalScore = mapped.totalScore;
+      this.scoreDistribution = mapped.scoreDistribution;
+      this.scoreSummary = mapped.scoreSummary;
+      this.subsystemData = mapped.subsystemData;
+      this.healthyRankList = mapped.healthyRankList;
+      this.riskRankList = mapped.riskRankList;
+      this.unitCards = mapped.unitCards;
     },
   },
 };

+ 226 - 0
src/views/health/utils/healthDashboardMapper.js

@@ -0,0 +1,226 @@
+const DISTRIBUTION_COLORS = {
+  优: "#14F39A",
+  良: "#1E7CF8",
+  中: "#F7C43B",
+  差: "#FF4A4A",
+};
+
+const SUMMARY_COLORS = {
+  优: "#00FF88",
+  良: "#0077FF",
+  中: "#FAAD14",
+  差: "#FF3131",
+};
+
+const SUBSYSTEM_COLORS = [
+  ["#22e8ff", "#1f7fff"],
+  ["#c58cff", "#7b3dff"],
+  ["#1df3a0", "#14a67a"],
+];
+
+function toDisplayNumber(val) {
+  if (val == null || Number.isNaN(Number(val))) return 0;
+  const num = Number(val);
+  if (num > 0 && num < 1) return Math.round(num * 100);
+  return Math.round(num);
+}
+
+function scoreToLevel(score) {
+  if (score >= 90) return "优";
+  if (score >= 75) return "良";
+  if (score >= 60) return "中";
+  return "差";
+}
+
+function mapLevelToStatus(overallLevel, overallScore) {
+  const level = overallLevel || scoreToLevel(overallScore);
+  const base = {
+    showScoreLabel: false,
+    showGlow: false,
+    interactive: true,
+    dimmed: false,
+  };
+  switch (level) {
+    case "优":
+      return {
+        ...base,
+        statusText: "运行优",
+        statusType: "excellent",
+        scoreTone: "excellent",
+      };
+    case "良":
+      return {
+        ...base,
+        statusText: "运行良",
+        statusType: "good",
+        scoreTone: "good",
+      };
+    case "中":
+      return {
+        ...base,
+        statusText: "运行中",
+        statusType: "warning",
+        scoreTone: "warning",
+      };
+    case "差":
+      return {
+        ...base,
+        statusText: "故障中",
+        statusType: "fault",
+        scoreTone: "danger",
+        showScoreLabel: true,
+        showGlow: true,
+      };
+    default:
+      return {
+        ...base,
+        statusText: "运行中",
+        statusType: "warning",
+        scoreTone: "warning",
+      };
+  }
+}
+
+function metricTone(value) {
+  if (value >= 85) return "emerald";
+  if (value >= 60) return "good";
+  if (value >= 40) return "warning";
+  return "danger";
+}
+
+function buildMetrics(item) {
+  const metrics = [
+    { label: "结构健康", value: toDisplayNumber(item.structureScore) },
+    { label: "系统健康", value: toDisplayNumber(item.systemScore) },
+    { label: "部件健康", value: toDisplayNumber(item.componentScore) },
+  ];
+  return metrics.map((m) => ({
+    ...m,
+    tone: metricTone(m.value),
+    glow: m.value < 60,
+  }));
+}
+
+function getOverallScore(item) {
+  const score = Number(item?.overallScore);
+  return Number.isFinite(score) ? score : -Infinity;
+}
+
+function formatRankScore(score) {
+  if (!Number.isFinite(score)) return 0;
+  return Math.round(score * 10) / 10;
+}
+
+function mapRankItem(item, options = {}) {
+  const rawScore = getOverallScore(item);
+  const score = formatRankScore(rawScore === -Infinity ? 0 : rawScore);
+  const name = item.engineName || item.engineId || "未知风机";
+  const row = { name, score, engineId: item.engineId, fieldId: item.fieldId };
+  if (options.risk) {
+    row.scoreClass = score < 50 ? "danger" : "warning";
+    row.rowClass = score < 50 ? "strong" : "muted";
+  }
+  return row;
+}
+
+/** healthOverviewListVOList 单项 → 风机卡片(含 overview 原对象供抽屉使用) */
+export function mapOverviewItemToUnitCard(item) {
+  const score = Math.round(Number(item.overallScore) || 0);
+  const status = mapLevelToStatus(item.overallLevel, score);
+  const name = item.engineName || item.engineId || "未知风机";
+  const modelCode = item.machineTypeCode ? `型号: ${item.machineTypeCode}` : "";
+
+  return {
+    name: name.length > 14 ? `${name.slice(0, 14)}...` : name,
+    fullName: name,
+    model: modelCode,
+    score,
+    overview: item,
+    metrics: buildMetrics(item),
+    ...status,
+  };
+}
+
+/** 健康榜 / 预警榜:均来自 healthOverviewListVOList,按 overallScore 排序 */
+function buildRankingLists(list) {
+  const source = Array.isArray(list) ? list : [];
+
+  const healthyRankList = [...source]
+    .sort((a, b) => getOverallScore(b) - getOverallScore(a))
+    .slice(0, 5)
+    .map((item) => mapRankItem(item));
+
+  const riskRankList = [...source]
+    .sort((a, b) => getOverallScore(a) - getOverallScore(b))
+    .slice(0, 5)
+    .map((item) => mapRankItem(item, { risk: true }));
+
+  return { healthyRankList, riskRankList };
+}
+
+/**
+ * 将 getHealthOverview 的 data 映射为仪表盘 UI 数据
+ * @param {Object} data HealthOverviewVO
+ */
+export function mapHealthOverviewToDashboard(data) {
+  const windVo = data?.healthscoresWindVO || {};
+  const list = data?.healthOverviewListVOList || [];
+
+  const excellent = toDisplayNumber(windVo.excellentCount);
+  const good = toDisplayNumber(windVo.goodCount);
+  const fair = toDisplayNumber(windVo.fairCount);
+  const poor = toDisplayNumber(windVo.poorCount);
+
+  const scoreDistribution = [
+    { value: excellent, name: "优", color: DISTRIBUTION_COLORS.优 },
+    { value: good, name: "良", color: DISTRIBUTION_COLORS.良 },
+    { value: fair, name: "中", color: DISTRIBUTION_COLORS.中 },
+    { value: poor, name: "差", color: DISTRIBUTION_COLORS.差 },
+  ];
+
+  const scoreSummary = [
+    { label: "优 (台)", value: excellent, color: SUMMARY_COLORS.优 },
+    { label: "良 (台)", value: good, color: SUMMARY_COLORS.良 },
+    { label: "中 (台)", value: fair, color: SUMMARY_COLORS.中 },
+    { label: "差 (台)", value: poor, color: SUMMARY_COLORS.差 },
+  ];
+
+  const subsystemData = [
+    {
+      name: "结构健康",
+      value: toDisplayNumber(windVo.structureScore),
+      colors: SUBSYSTEM_COLORS[0],
+    },
+    {
+      name: "系统健康",
+      value: toDisplayNumber(windVo.systemScore),
+      colors: SUBSYSTEM_COLORS[1],
+    },
+    {
+      name: "部件健康",
+      value: toDisplayNumber(windVo.componentScore),
+      colors: SUBSYSTEM_COLORS[2],
+    },
+  ];
+
+  const { healthyRankList, riskRankList } = buildRankingLists(list);
+
+  const unitCards = list.map((item) => mapOverviewItemToUnitCard(item));
+
+  return {
+    totalScore: Math.round(Number(windVo.overallScore) || 0),
+    scoreDistribution,
+    scoreSummary,
+    subsystemData,
+    healthyRankList,
+    riskRankList,
+    unitCards,
+  };
+}
+
+export function getEmptyDashboard() {
+  return mapHealthOverviewToDashboard({
+    healthscoresWindVO: {},
+    healthOverviewListVOList: [],
+  });
+}

+ 71 - 0
src/views/health/utils/healthTrendMapper.js

@@ -0,0 +1,71 @@
+const TREND_COLORS = [
+  "#22d3ee",
+  "#1E7CF8",
+  "#14F39A",
+  "#F7C43B",
+  "#C58CFF",
+  "#FF8C42",
+];
+
+const TREND_CHART_SERIES = {
+  overall: [{ key: "overallScore", name: "综合健康", color: "#22d3ee" }],
+  structural: [
+    { key: "rotorScore", name: "叶轮", color: "#22d3ee" },
+    { key: "towerScore", name: "塔筒", color: "#1E7CF8" },
+  ],
+  system: [
+    { key: "yawSystemScore", name: "偏航系统", color: "#22d3ee" },
+    { key: "pitchSystemScore", name: "变桨系统", color: "#1E7CF8" },
+    { key: "hydraulicSystemScore", name: "液压系统", color: "#14F39A" },
+    { key: "controlSystemScore", name: "主控系统", color: "#F7C43B" },
+  ],
+  component: [
+    { key: "generatorScore", name: "发电机", color: "#22d3ee" },
+    { key: "gearboxScore", name: "齿轮箱", color: "#1E7CF8" },
+    { key: "mainShaftScore", name: "主轴", color: "#14F39A" },
+    { key: "converterScore", name: "变流器", color: "#C58CFF" },
+  ],
+};
+
+function clampScore(value) {
+  if (value == null || Number.isNaN(Number(value))) return null;
+  return Math.max(0, Math.min(100, Math.round(Number(value))));
+}
+
+function formatDateLabel(sourceDatetime) {
+  if (!sourceDatetime) return "";
+  const text = String(sourceDatetime);
+  if (text.length >= 10) {
+    return text.slice(5, 10).replace("-", "/");
+  }
+  return text;
+}
+
+/** swagger 为单对象,实际接口可能返回数组 */
+export function normalizeTendencyList(data) {
+  if (Array.isArray(data)) return data;
+  if (data && typeof data === "object") return [data];
+  return [];
+}
+
+export function mapTendencyToChartData(list, chartKey) {
+  const configs = TREND_CHART_SERIES[chartKey] || [];
+  const sorted = [...list].sort((a, b) => {
+    const ta = new Date(a.sourceDatetime || 0).getTime();
+    const tb = new Date(b.sourceDatetime || 0).getTime();
+    return ta - tb;
+  });
+
+  const labels = sorted.map((item) => formatDateLabel(item.sourceDatetime));
+  const series = configs.map((cfg, index) => ({
+    name: cfg.name,
+    color: cfg.color || TREND_COLORS[index % TREND_COLORS.length],
+    data: sorted.map((item) => clampScore(item[cfg.key])),
+  }));
+
+  return { labels, series };
+}
+
+export function getEmptyTrendChart() {
+  return { labels: [], series: [] };
+}

文件差異過大導致無法顯示
+ 0 - 0
swagger.json


+ 8 - 0
vue.config.js

@@ -71,6 +71,14 @@ module.exports = {
           "^/api": "", // 去掉 /api 前缀
         },
       },
+      // 健康分析接口(勿用 /health,避免与路由 /home/health 及 history 回退冲突)
+      "/healthApi": {
+        target: process.env.VUE_APP_HEALTH_APIPROXY,
+        changeOrigin: true,
+        pathRewrite: {
+          "^/healthApi": "",
+        },
+      },
       // 未知量  //振动、激光测距仪
       "/WZLapi": {
         target: process.env.VUE_APP_WZLAPIPROXY,

部分文件因文件數量過多而無法顯示