Explorar o código

健康报告下载功能及卫星地图改造

liujiejie hai 9 horas
pai
achega
e6c253874d

+ 35 - 35
.env.dev

@@ -13,43 +13,43 @@ VUE_APP_Helath='dev'
 
 VUE_APP_TITLE='机组功率曲线异常检测数据分析系统'
 # 外网
-VUE_APP_MAPVIEW= "http://106.120.102.238:18000/tiles/{z}/{x}/{y}.png"
-VUE_APP_UPLOAD="http://106.120.102.238:16700/energy-manage-service/api/check/upload"
-# 生产外网
-# VUE_APP_APIPROXY='http://106.120.102.238:16700'
-# 开发外网
-VUE_APP_APIPROXY='http://106.120.102.238:16600'
-VUE_APP_MAP=http://106.120.102.238:18080
+# VUE_APP_MAPVIEW= "http://106.120.102.238:18000/tiles/{z}/{x}/{y}.png"
+# VUE_APP_UPLOAD="http://106.120.102.238:16700/energy-manage-service/api/check/upload"
+# # 生产外网
+# # VUE_APP_APIPROXY='http://106.120.102.238:16700'
+# # 开发外网
+# VUE_APP_APIPROXY='http://106.120.102.238:16600'
+# VUE_APP_MAP=http://106.120.102.238:18080
 
 
-VUE_APP_WZLAPIPROXY='http://106.120.102.238:18080/WindTransDev'
-VUE_APP_ETLAPIPROXY='http://106.120.102.238:18080/WindTransDev'
-VUE_APP_WZLAPIPROXY='http://106.120.102.238:58880/transDataWeb'
-VUE_APP_ETLAPIPROXY='http://106.120.102.238:58880/transDataWeb'
-VUE_APP_AnalysisMultiAPIPROXY='http://106.120.102.238:28999/AnalysisMulti'
-#自定义算法文佳 目前无法使用,可能是服务未启动
-VUE_APP_sAlgorithmAPIPROXY='http://106.120.102.238:58880'
-VUE_APP_databaseApiAPIPROXY='http://106.120.102.238:58880'
-#暂时不知下载报告内网dev 环境地址
-VUE_APP_downLoadChartAPIPROXY='http://106.120.102.238:58880'
+# VUE_APP_WZLAPIPROXY='http://106.120.102.238:18080/WindTransDev'
+# VUE_APP_ETLAPIPROXY='http://106.120.102.238:18080/WindTransDev'
+# VUE_APP_WZLAPIPROXY='http://106.120.102.238:58880/transDataWeb'
+# VUE_APP_ETLAPIPROXY='http://106.120.102.238:58880/transDataWeb'
+# VUE_APP_AnalysisMultiAPIPROXY='http://106.120.102.238:28999/AnalysisMulti'
+# #自定义算法文佳 目前无法使用,可能是服务未启动
+# VUE_APP_sAlgorithmAPIPROXY='http://106.120.102.238:58880'
+# VUE_APP_databaseApiAPIPROXY='http://106.120.102.238:58880'
+# #暂时不知下载报告内网dev 环境地址
+# VUE_APP_downLoadChartAPIPROXY='http://106.120.102.238:58880'
 # VUE_APP_downLoadChartAPIPROXY='http://0.0.0.0:3001'
 #内网
-# VUE_APP_UPLOAD="http://192.168.50.235/energy-manage-service/api/check/upload"
-# VUE_APP_MAPVIEW=/tiles/{z}/{x}/{y}.png
+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
-# # http://192.168.50.235:16200 开发内网地址
-# # VUE_APP_APIPROXY='http://192.168.50.235:16300' # 生产数据库
-# VUE_APP_APIPROXY='http://192.168.50.235:16300'
-# 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/'
-# VUE_APP_ENOS_APIPROXY='http://192.168.50.241:9000'
-# # #自定义算法文佳 目前无法使用,可能是服务未启动
-# VUE_APP_sAlgorithmAPIPROXY='http://192.168.50.235:8998/AnalysisMulti'
-# VUE_APP_databaseApiAPIPROXY='http://192.168.50.234:3002'
-# #暂时不知健康评估内网dev 环境地址
-# VUE_APP_AnalysisMultiAPIPROXY='http://192.168.50.235:8998/AnalysisMulti'
-# #暂时不知下载报告内网dev 环境地址
-# # VUE_APP_downLoadChartAPIPROXY='http://106.120.102.238:58880'
-# # VUE_APP_downLoadChartAPIPROXY='http://192.168.50.234:3001'
-# VUE_APP_downLoadChartAPIPROXY='http://0.0.0.0:3001'
+# http://192.168.50.235:16200 开发内网地址
+# VUE_APP_APIPROXY='http://192.168.50.235:16300' # 生产数据库
+VUE_APP_APIPROXY='http://192.168.50.235:16300'
+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/'
+VUE_APP_ENOS_APIPROXY='http://192.168.50.241:9000'
+# #自定义算法文佳 目前无法使用,可能是服务未启动
+VUE_APP_sAlgorithmAPIPROXY='http://192.168.50.235:8998/AnalysisMulti'
+VUE_APP_databaseApiAPIPROXY='http://192.168.50.234:3002'
+#暂时不知健康评估内网dev 环境地址
+VUE_APP_AnalysisMultiAPIPROXY='http://192.168.50.235:8998/AnalysisMulti'
+#暂时不知下载报告内网dev 环境地址
+# VUE_APP_downLoadChartAPIPROXY='http://106.120.102.238:58880'
+# VUE_APP_downLoadChartAPIPROXY='http://192.168.50.234:3001'
+VUE_APP_downLoadChartAPIPROXY='http://0.0.0.0:3001'

+ 11 - 4
.env.dt

@@ -19,7 +19,7 @@ VUE_APP_DEV_HOST=0.0.0.0
 VUE_APP_DEV_PORT=8080
 # #外网
 # VUE_APP_MAPVIEW=/tiles/{z}/{x}/{y}.png
-# # VUE_APP_MAPVIEW= "http://106.120.102.238:18000/tiles/{z}/{x}/{y}.png" # 地图废除
+# VUE_APP_MAPVIEW= "http://106.120.102.238:18000/tiles/{z}/{x}/{y}.png" # 地图废除
 # VUE_APP_UPLOAD="http://106.120.102.238:16700/energy-manage-service/api/check/upload"
 # VUE_APP_APIPROXY='http://106.120.102.238:16700'
 # VUE_APP_HEALTH_APIPROXY='http://106.120.102.238:16880'
@@ -31,18 +31,23 @@ VUE_APP_DEV_PORT=8080
 # #自定义算法文佳 目前无法使用,可能是服务未启动
 # VUE_APP_sAlgorithmAPIPROXY='http://106.120.102.238:58880'
 # VUE_APP_databaseApiAPIPROXY='http://106.120.102.238:58880'
+# VUE_APP_downLoadChartAPIPROXY='http://127.0.0.1:3001'
 # # #暂时不知下载报告内网dev 环境地址
 # VUE_APP_downLoadChartAPIPROXY='http://106.120.102.238:58880/downLoadChart'
 
 #内网
 VUE_APP_UPLOAD="http://10.172.12.211:16200/energy-manage-service/api/check/upload"
 VUE_APP_MAPVIEW=/tiles/{z}/{x}/{y}.png
-VUE_APP_MAP=http://192.168.50.235:8000
+# 卫星底图:nginx 若 alias 到 map/tiles/s2/,保持此路径
+# VUE_APP_MAPVIEW=/tiles_map/{z}/{x}/{y}.jpg
+# 行政区划叠加:nginx 需单独 alias 到 map/tiles/admin/
+# VUE_APP_MAPVIEW_ADMIN=/tiles_map/admin/{z}/{x}/{y}.png
+VUE_APP_MAP="http://192.168.50.235:8000"
 VUE_APP_APIPROXY='http://192.168.50.235:16200'
 VUE_APP_HEALTH_APIPROXY='http://192.168.50.235:16880'
 VUE_APP_DatangEnosData_APIPROXY='http://192.168.50.235:8899'
 # VUE_APP_APIPROXY='http://10.172.12.211:16200'
-# VUE_APP_HEALTH_APIPROXY='http://192.168.5.4:16880'
+# # VUE_APP_HEALTH_APIPROXY='http://192.168.5.4:16880'
 # VUE_APP_HEALTH_APIPROXY='http://10.172.12.211:16880'
 # VUE_APP_DatangEnosData_APIPROXY='http://10.172.12.212:8101'
 VUE_APP_WZLAPIPROXY='http://192.168.50.241:9002'
@@ -54,4 +59,6 @@ VUE_APP_databaseApiAPIPROXY='http://192.168.50.234:3002'
 #暂时不知健康评估内网dev 环境地址
 VUE_APP_AnalysisMultiAPIPROXY='http://192.168.50.235:8998/AnalysisMulti'
 #暂时不知下载报告内网dev 环境地址
-VUE_APP_downLoadChartAPIPROXY='http://192.168.50.235:8998/downLoadChart'
+# VUE_APP_downLoadChartAPIPROXY='http://10.172.12.211:16200/downLoadChart'
+# 本地 downLoadServer:target 不要带 /downLoadChart(vue.config 会 pathRewrite 去掉前缀)
+VUE_APP_downLoadChartAPIPROXY='http://127.0.0.1:3001'

BIN=BIN
public/files/风场健康评估数据下载模版.xlsx


BIN=BIN
public/files/风电机组健康评估_风场级自动报告模板_V1.0.docx


+ 13 - 0
src/api/anomalyAnalyse.js

@@ -15,6 +15,19 @@ export function getAnomalyOverview(data) {
 }
 
 /**
+ * 按区域统计异常台数
+ * @param {{ companyCode?: string, datatime?: string }} params
+ * companyCode 为空时默认查全部;datatime 为空时默认昨天
+ */
+export function getAnomalyRegionList(params) {
+  return healthRequest({
+    url: `${PREFIX}/getAnomalyRegionList`,
+    method: "post",
+    params,
+  });
+}
+
+/**
  * 风机卡片列表
  * @param {{ fieldCode: string, datatime?: string }} data
  */

+ 13 - 0
src/api/healthAnalyse.js

@@ -28,6 +28,19 @@ export function getHealthscoresWindList(params) {
 }
 
 /**
+ * 按区域统计健康台数(优/良/中/差)
+ * @param {{ companyCode?: string, datatime?: string }} params
+ * companyCode 为空时默认查全部;datatime 为空时默认昨天
+ */
+export function getHealthscoresRegionList(params) {
+  return healthRequest({
+    url: `${PREFIX}/getHealthscoresRegionList`,
+    method: "post",
+    params,
+  });
+}
+
+/**
  * 风机按天数查询趋势图
  * @param {{ day: number, engineId: string, fieldId: string, dateTime?: string }} params
  */

+ 134 - 0
src/api/reportDownload.js

@@ -0,0 +1,134 @@
+import axios from "axios";
+import { saveAs } from "file-saver";
+import { Message } from "element-ui";
+
+function getAuthHeaders() {
+  const headers = {};
+  try {
+    const token = JSON.parse(sessionStorage.getItem("vuex"))?.auth?.userInfo
+      ?.token;
+    if (token) {
+      headers.token = token;
+      headers.showIp = "106.120.102.238";
+    }
+  } catch (_error) {
+    // ignore
+  }
+  return headers;
+}
+
+function parseFileName(contentDisposition, fallback) {
+  if (!contentDisposition) return fallback;
+  const utfMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
+  if (utfMatch?.[1]) {
+    try {
+      return decodeURIComponent(utfMatch[1]);
+    } catch (_error) {
+      return utfMatch[1];
+    }
+  }
+  const plainMatch = contentDisposition.match(/filename="?([^";]+)"?/i);
+  return plainMatch?.[1] ? decodeURIComponent(plainMatch[1]) : fallback;
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function readBlobJson(blob) {
+  const text = await blob.text();
+  try {
+    return JSON.parse(text);
+  } catch (_error) {
+    return { message: text || "报告生成失败" };
+  }
+}
+
+function saveDocxBlob(data, fileName) {
+  saveAs(
+    new Blob([data], {
+      type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+    }),
+    fileName,
+  );
+}
+
+async function pollReportTask(taskId, type) {
+  const deadline = Date.now() + 1800000;
+  while (Date.now() < deadline) {
+    await sleep(2000);
+    const res = await axios.get(
+      `/downLoadChart/chartServer/reports/tasks/${taskId}`,
+      {
+        headers: getAuthHeaders(),
+        responseType: "blob",
+        timeout: 120000,
+        validateStatus: (status) =>
+          status === 200 || status === 202 || status === 404 || status === 500,
+      },
+    );
+    const contentType = res.headers["content-type"] || "";
+    if (res.status === 202) continue;
+    if (contentType.includes("application/json") || res.status !== 200) {
+      const json = await readBlobJson(res.data);
+      if (json.status === "running") continue;
+      throw new Error(json.message || "报告生成失败");
+    }
+    const fileName = parseFileName(
+      res.headers["content-disposition"],
+      type === "health" ? "健康评估报告.docx" : "异常检测报告.docx",
+    );
+    saveDocxBlob(res.data, fileName);
+    return;
+  }
+  throw new Error("报告生成超时,请稍后重试");
+}
+
+async function downloadReport(type, params) {
+  const res = await axios.post(
+    `/downLoadChart/chartServer/reports/${type}`,
+    params,
+    {
+      headers: {
+        ...getAuthHeaders(),
+        Accept: "application/json",
+      },
+      timeout: 60000,
+      validateStatus: (status) => status === 200 || status === 202,
+    },
+  );
+
+  const contentType = res.headers["content-type"] || "";
+  if (contentType.includes("application/json") || res.status === 202) {
+    const payload = res.data || {};
+    if (!payload.taskId) {
+      throw new Error(payload.message || "报告任务创建失败");
+    }
+    await pollReportTask(payload.taskId, type);
+    return;
+  }
+
+  const fileName = parseFileName(
+    res.headers["content-disposition"],
+    type === "health" ? "健康评估报告.docx" : "异常检测报告.docx",
+  );
+  saveDocxBlob(res.data, fileName);
+}
+
+export function downloadHealthReport(params) {
+  return downloadReport("health", params);
+}
+
+export function downloadAnomalyReport(params) {
+  return downloadReport("anomaly", params);
+}
+
+export async function downloadReportWithNotify(type, params) {
+  try {
+    await downloadReport(type, params);
+    Message.success("报告已生成并开始下载");
+  } catch (error) {
+    Message.error(error.message || "报告生成失败");
+    throw error;
+  }
+}

+ 11 - 3
src/components/map/index.vue

@@ -260,12 +260,20 @@ export default {
       layers: [
         new TileLayer({
           source: new XYZ({
-            // url: "http://10.96.137.5:9080/tiles/{z}/{x}/{y}.png", //大~#@唐
-            // url: "http://192.168.0.1/tiles/{z}/{x}/{y}.png", //华电
-            // url: "http://192.168.50.235/tiles/{z}/{x}/{y}.png", //中广核
             url: process.env.VUE_APP_MAPVIEW,
+            maxZoom: 12,
           }),
         }),
+        // ...(process.env.VUE_APP_MAPVIEW_ADMIN
+        //   ? [
+        //       new TileLayer({
+        //         source: new XYZ({
+        //           url: process.env.VUE_APP_MAPVIEW_ADMIN,
+        //           maxZoom: 12,
+        //         }),
+        //       }),
+        //     ]
+        //   : []),
         new VectorLayer({
           id: "marker",
           source: new Vector(),

+ 9 - 0
src/styles/legacy-theme.scss

@@ -736,6 +736,15 @@ html:not([data-theme="tech-blue"]) {
       color: var(--health-header-tool-muted, #909399);
     }
 
+    .health-header__download {
+      color: var(--health-header-tool-text, #303133) !important;
+
+      .el-icon-download,
+      .el-icon-document {
+        color: var(--health-header-tool-icon, var(--primary-color, #82780c));
+      }
+    }
+
     .health-header__selecttree .el-input__inner,
     .health-header__date .el-input__inner {
       background-color: transparent !important;

+ 24 - 3
src/views/admin/cockpitManage/Index.vue

@@ -59,15 +59,18 @@
           </div>
         </section>
 
-        <!-- 集团/根节点级:区域健康与异常排名 -->
+        <!-- 集团视角:区域健康与异常排名(选中根节点或其直属区域公司时保持显示) -->
         <section
-          v-if="ShowRi && isRootSelected"
+          v-if="ShowRi && showRegionRank"
+          v-hasPermi="['home:health:btn', 'home:anomalyDetection:btn']"
           class="cockpit-panel cockpit-panel--region-rank"
         >
           <CockpitRegionRankPanel
-            :root-node="selectedNode"
+            :root-node="rankRootNode"
+            :active-code="selectedNode.codeNumber"
             :parent-opt="parentOpt"
             :datatime="datatime"
+            @select-region="handleSelectRegion"
           />
         </section>
 
@@ -163,12 +166,25 @@ export default {
     };
   },
   computed: {
+    rankRootNode() {
+      return this.parentOpt[0] || {};
+    },
     isRootSelected() {
       if (!this.selectedNode || !this.selectedNode.codeNumber) return false;
       return this.parentOpt.some(
         (root) => root.codeNumber === this.selectedNode.codeNumber,
       );
     },
+    /** 根节点或其直属区域公司被选中时,保持健康/异常排名面板可见 */
+    showRegionRank() {
+      const root = this.rankRootNode;
+      const selected = this.selectedNode;
+      if (!root?.codeNumber || !selected?.codeNumber) return false;
+      if (String(root.codeNumber) === String(selected.codeNumber)) return true;
+      return (root.children || []).some(
+        (child) => String(child.codeNumber) === String(selected.codeNumber),
+      );
+    },
   },
   created() {
     this.GETtree();
@@ -256,6 +272,11 @@ export default {
       const avgLat = validCoords.reduce((s, c) => s + c[1], 0) / validCoords.length;
       return [avgLon, avgLat];
     },
+    handleSelectRegion(node) {
+      if (!node || !node.codeNumber) return;
+      this.companyCode = node.codeNumber;
+      this.parentChange(node);
+    },
     parentChange(data) {
       this.maplist = data;
       this.maplistArr = data;

+ 7 - 2
src/views/admin/cockpitManage/component/CockpitAbnormalPanel.vue

@@ -57,7 +57,11 @@ import { getAnomalyOverview } from "@/api/anomalyAnalyse";
 import { queryWindTurbineCount } from "@/api/cockpitEnos";
 import Tabroll from "./tabroll.vue";
 import { COCKPIT_TABLE_BODY_HEIGHT } from "./cockpitTableMetrics";
-import { buildFieldCodeMap, buildFieldNameMap } from "./cockpitHealthMapper";
+import {
+  buildFieldCodeMap,
+  buildFieldNameMap,
+  filterRowsByNodeFields,
+} from "./cockpitHealthMapper";
 import {
   mapCockpitAbnormalOverview,
   normalizeAnomalyOverviewList,
@@ -127,8 +131,9 @@ export default {
     applyOverviewList(list) {
       const fieldNameMap = buildFieldNameMap(this.parentOpt);
       const fieldCodeMap = buildFieldCodeMap(this.parentOpt);
+      const scopedList = filterRowsByNodeFields(list, this.maplistArr);
       const mapped = mapCockpitAbnormalOverview(
-        list,
+        scopedList,
         fieldNameMap,
         fieldCodeMap,
       );

+ 8 - 33
src/views/admin/cockpitManage/component/CockpitHealthPanel.vue

@@ -65,8 +65,8 @@ import {
   buildFieldCodeMap,
   buildFieldNameMap,
   mapHealthscoresWindList,
-  collectFieldCodes,
   filterEvaluatedRows,
+  filterRowsByNodeFields,
   sortHealthTableData,
 } from "./cockpitHealthMapper";
 
@@ -284,16 +284,14 @@ export default {
           };
         },
       );
+      const scoped = filterRowsByNodeFields(rows, this.maplistArr);
       // 只展示已评估(有任意一台风机评分数据)的场站
-      const evaluated = filterEvaluatedRows(rows);
+      const evaluated = filterEvaluatedRows(scoped);
       // 按排序配置排序
       const sorted = sortHealthTableData(evaluated, this.sortConfig);
       this.tableData = sorted;
       this.scoreDistribution = aggregateHealthScoreDistribution(sorted);
     },
-    collectScopedFieldCodes() {
-      return collectFieldCodes(this.maplistArr);
-    },
     buildHealthRequestParams() {
       const fieldCode =
         this.maplistArr?.codeNumber || this.maplistArr?.fieldCode;
@@ -313,34 +311,11 @@ export default {
       this.loading = true;
       try {
         let list = [];
-        if (selectedNode.codeType === "field") {
-          // 选中单个场站 — 直接请求
-          const params = this.buildHealthRequestParams();
-          if (params) {
-            const res = await getHealthscoresWindList(params);
-            list = Array.isArray(res?.data) ? res.data : [];
-          }
-        } else {
-          // 选中区域/公司 — 收集范围内所有 field,并发请求
-          const fieldCodes = this.collectScopedFieldCodes();
-          if (fieldCodes.length) {
-            const requests = fieldCodes.map((fc) => {
-              const p = { fieldCode: fc };
-              if (this.datatime) p.datatime = this.datatime;
-              return getHealthscoresWindList(p).then((res) =>
-                Array.isArray(res?.data) ? res.data : []
-              ).catch(() => []);
-            });
-            const results = await Promise.all(requests);
-            list = results.flat();
-          } else {
-            // 无子 field,回退到直接传公司 codeNumber
-            const params = this.buildHealthRequestParams();
-            if (params) {
-              const res = await getHealthscoresWindList(params);
-              list = Array.isArray(res?.data) ? res.data : [];
-            }
-          }
+        // fieldCode 可传公司/区域 codeNumber,接口一次返回下属全部风场
+        const params = this.buildHealthRequestParams();
+        if (params) {
+          const res = await getHealthscoresWindList(params);
+          list = Array.isArray(res?.data) ? res.data : [];
         }
         this.lastHealthList = list;
         this.applyHealthList(list);

+ 185 - 81
src/views/admin/cockpitManage/component/CockpitRegionRankPanel.vue

@@ -4,18 +4,29 @@
 
     <div class="cockpit-region-rank__body">
       <!-- 健康排名 -->
-      <div v-show="activeTab === 'health'" class="cockpit-region-rank__pane">
-        <div v-if="loading" class="cockpit-region-rank__loading" v-loading="true" />
+      <div
+        v-show="activeTab === 'health'"
+        v-hasPermi="HEALTH_TAB_PERMI"
+        class="cockpit-region-rank__pane"
+      >
+        <div
+          v-if="loading"
+          class="cockpit-region-rank__loading"
+          v-loading="true"
+        />
         <template v-else-if="healthRanks.length">
           <div
             v-for="(item, index) in healthRanks"
             :key="item.codeNumber"
-            class="cockpit-region-rank__row"
+            class="cockpit-region-rank__row is-clickable"
+            :class="{ 'is-active': isActiveRegion(item) }"
+            @click="handleSelectRegion(item)"
           >
             <span
               class="cockpit-region-rank__badge"
               :class="getBadgeClass(index)"
-            >{{ index + 1 }}</span>
+              >{{ index + 1 }}</span
+            >
             <span class="cockpit-region-rank__name" :title="item.regionName">
               {{ item.regionName }}
             </span>
@@ -23,12 +34,11 @@
               <span
                 v-for="lv in levelBars"
                 :key="lv.key"
-                class="cockpit-region-rank__dot"
-                :style="{ background: lv.color }"
+                class="cockpit-region-rank__level"
+                :style="{ color: lv.color }"
                 :title="`${lv.label}: ${item[lv.key]}台`"
-              />
-              <span class="cockpit-region-rank__count">
-                优{{ item.excellent }}
+              >
+                {{ lv.label }}{{ item[lv.key] }}
               </span>
             </span>
           </div>
@@ -37,23 +47,36 @@
       </div>
 
       <!-- 异常排名 -->
-      <div v-show="activeTab === 'abnormal'" class="cockpit-region-rank__pane">
-        <div v-if="loading" class="cockpit-region-rank__loading" v-loading="true" />
+      <div
+        v-show="activeTab === 'abnormal'"
+        v-hasPermi="ABNORMAL_TAB_PERMI"
+        class="cockpit-region-rank__pane"
+      >
+        <div
+          v-if="loading"
+          class="cockpit-region-rank__loading"
+          v-loading="true"
+        />
         <template v-else-if="abnormalRanks.length">
           <div
             v-for="(item, index) in abnormalRanks"
             :key="item.codeNumber"
-            class="cockpit-region-rank__row"
+            class="cockpit-region-rank__row is-clickable"
+            :class="{ 'is-active': isActiveRegion(item) }"
+            @click="handleSelectRegion(item)"
           >
             <span
               class="cockpit-region-rank__badge"
               :class="getBadgeClass(index)"
-            >{{ index + 1 }}</span>
+              >{{ index + 1 }}</span
+            >
             <span class="cockpit-region-rank__name" :title="item.regionName">
               {{ item.regionName }}
             </span>
             <span class="cockpit-region-rank__abnormal-count">
-              <i class="el-icon-warning-outline cockpit-region-rank__warn-icon" />
+              <i
+                class="el-icon-warning-outline cockpit-region-rank__warn-icon"
+              />
               {{ item.errCount }} 台
             </span>
           </div>
@@ -65,37 +88,47 @@
 </template>
 
 <script>
+import { store } from "@/store";
 import CockpitTechTabs from "./CockpitTechTabs.vue";
-import { getHealthscoresWindList } from "@/api/healthAnalyse";
-import { getAnomalyOverview } from "@/api/anomalyAnalyse";
+import { getHealthscoresRegionList } from "@/api/healthAnalyse";
+import { getAnomalyRegionList } from "@/api/anomalyAnalyse";
 import {
-  collectFieldCodes,
-  buildFieldNameMap,
-  buildFieldCodeMap,
-  mapHealthscoresWindList,
-  aggregateRegionHealth,
-  aggregateRegionAbnormal,
+  mapHealthscoresRegionList,
+  mapAnomalyRegionList,
 } from "./cockpitHealthMapper";
-import {
-  normalizeAnomalyOverviewList,
-  mapCockpitAbnormalOverview,
-} from "./cockpitAbnormalMapper";
+
+const HEALTH_TAB_PERMI = ["home:health:btn"];
+const ABNORMAL_TAB_PERMI = ["home:anomalyDetection:btn"];
+
+function hasTabPermi(permi) {
+  const flags = Array.isArray(permi) ? permi : [];
+  if (!flags.length) return true;
+  const allPermission = "*:*:*";
+  const permissions = store?.getters?.permissions || [];
+  return permissions.some(
+    (permission) => permission === allPermission || flags.includes(permission),
+  );
+}
 
 export default {
   name: "CockpitRegionRankPanel",
   components: { CockpitTechTabs },
   props: {
-    /** 当前选中节点(含 children 的根/集团节点) */
+    /** 排名数据所属根节点(集团),不随区域点击切换 */
     rootNode: { type: Object, default: () => ({}) },
+    /** 当前选中的区域公司 code,用于高亮 */
+    activeCode: { type: [String, Number], default: "" },
     parentOpt: { type: Array, default: () => [] },
     datatime: { type: String, default: "" },
   },
   data() {
     return {
+      HEALTH_TAB_PERMI,
+      ABNORMAL_TAB_PERMI,
       activeTab: "health",
       rankTabs: [
-        { label: "健康排名", value: "health" },
-        { label: "异常排名", value: "abnormal" },
+        { label: "健康排名", value: "health", permi: HEALTH_TAB_PERMI },
+        { label: "异常排名", value: "abnormal", permi: ABNORMAL_TAB_PERMI },
       ],
       loading: false,
       healthRanks: [],
@@ -123,62 +156,127 @@ export default {
         this.fetchRankData();
       }
     },
+    rankTabs: {
+      deep: true,
+      handler() {
+        this.syncActiveTabByPermi();
+      },
+    },
+  },
+  mounted() {
+    this.syncActiveTabByPermi();
   },
   methods: {
+    syncActiveTabByPermi() {
+      const visibleTabs = this.rankTabs.filter((tab) => hasTabPermi(tab.permi));
+      if (!visibleTabs.length) return;
+      if (!visibleTabs.some((tab) => tab.value === this.activeTab)) {
+        this.activeTab = visibleTabs[0].value;
+      }
+    },
     getBadgeClass(index) {
       if (index === 0) return "is-gold";
       if (index === 1) return "is-silver";
       if (index === 2) return "is-bronze";
       return "";
     },
+    isActiveRegion(item) {
+      if (!this.activeCode || !item?.codeNumber) return false;
+      return String(this.activeCode) === String(item.codeNumber);
+    },
+    buildRequestParams() {
+      const params = {};
+      const companyCode = this.rootNode?.codeNumber;
+      if (companyCode) params.companyCode = companyCode;
+      if (this.datatime) params.datatime = this.datatime;
+      return params;
+    },
+    findNodeByCode(nodes, codeNumber) {
+      const target = String(codeNumber || "");
+      if (!target) return null;
+      const list = Array.isArray(nodes) ? nodes : [];
+      for (let i = 0; i < list.length; i += 1) {
+        const node = list[i];
+        if (String(node.codeNumber) === target) return node;
+        const found = this.findNodeByCode(node.children, target);
+        if (found) return found;
+      }
+      return null;
+    },
+    findRegionNode(item) {
+      const byCode = this.findNodeByCode(
+        this.rootNode?.children || this.parentOpt,
+        item?.codeNumber,
+      );
+      if (byCode) return byCode;
+      const name = item?.regionName;
+      if (!name) return null;
+      const children = this.rootNode?.children || [];
+      return (
+        children.find(
+          (node) =>
+            node.companyName === name ||
+            node.fieldName === name ||
+            node.companyName === item.regionName,
+        ) || null
+      );
+    },
+    handleSelectRegion(item) {
+      const node = this.findRegionNode(item);
+      if (!node) return;
+      this.$emit("select-region", node);
+    },
     async fetchRankData() {
       const node = this.rootNode;
-      if (!node || !node.children || !node.children.length) return;
+      if (!node || !node.codeNumber) return;
+
+      const canHealth = hasTabPermi(HEALTH_TAB_PERMI);
+      const canAbnormal = hasTabPermi(ABNORMAL_TAB_PERMI);
+      if (!canHealth && !canAbnormal) {
+        this.healthRanks = [];
+        this.abnormalRanks = [];
+        return;
+      }
 
       this.loading = true;
+      const params = this.buildRequestParams();
       try {
-        // 收集根节点下所有 field 的 fieldCode
-        const fieldCodes = collectFieldCodes(node);
-        if (!fieldCodes.length) return;
-
-        // 并发请求所有场站的健康和异常数据
-        const healthRequests = fieldCodes.map((fc) => {
-          const p = { fieldCode: fc };
-          if (this.datatime) p.datatime = this.datatime;
-          return getHealthscoresWindList(p)
-            .then((res) => (Array.isArray(res?.data) ? res.data : []))
-            .catch(() => []);
-        });
-        const abnormalRequests = fieldCodes.map((fc) => {
-          const payload = { fieldCode: fc };
-          if (this.datatime) payload.datatime = this.datatime;
-          return getAnomalyOverview(payload)
-            .then((res) => {
-              const list = normalizeAnomalyOverviewList(res?.data);
-              const fieldNameMap = buildFieldNameMap(this.parentOpt);
-              const fieldCodeMap = buildFieldCodeMap(this.parentOpt);
-              return mapCockpitAbnormalOverview(list, fieldNameMap, fieldCodeMap).tableData;
-            })
-            .catch(() => []);
-        });
-
-        const [healthResults, abnormalResults] = await Promise.all([
-          Promise.all(healthRequests),
-          Promise.all(abnormalRequests),
+        const [healthRes, abnormalRes] = await Promise.all([
+          canHealth
+            ? getHealthscoresRegionList(params).catch((error) => {
+                console.error("加载区域健康排名失败:", error);
+                return null;
+              })
+            : Promise.resolve(null),
+          canAbnormal
+            ? getAnomalyRegionList(params).catch((error) => {
+                console.error("加载区域异常排名失败:", error);
+                return null;
+              })
+            : Promise.resolve(null),
         ]);
 
-        // 拼装成带 fieldCode 的行
-        const fieldNameMap = buildFieldNameMap(this.parentOpt);
-        const allHealthRows = healthResults.flat().map((item) => ({
-          ...mapHealthscoresWindList([item], fieldNameMap)[0],
-          fieldCode: String(item.fieldCode || item.codeNumber || ""),
-        }));
-        const allAbnormalRows = abnormalResults.flat();
+        if (canHealth) {
+          this.healthRanks =
+            healthRes?.code === 200
+              ? mapHealthscoresRegionList(healthRes.data)
+              : [];
+        } else {
+          this.healthRanks = [];
+        }
 
-        this.healthRanks = aggregateRegionHealth(node, allHealthRows);
-        this.abnormalRanks = aggregateRegionAbnormal(node, allAbnormalRows);
+        if (canAbnormal) {
+          this.abnormalRanks =
+            abnormalRes?.code === 200
+              ? mapAnomalyRegionList(abnormalRes.data)
+              : [];
+        } else {
+          this.abnormalRanks = [];
+        }
       } catch (e) {
         console.error("加载区域排名失败:", e);
+        this.healthRanks = [];
+        this.abnormalRanks = [];
       } finally {
         this.loading = false;
       }
@@ -223,6 +321,21 @@ export default {
   font-size: 13px;
   line-height: 28px;
   color: var(--cockpit-panel-text, #303133);
+
+  &.is-clickable {
+    cursor: pointer;
+    border-radius: 4px;
+    padding: 0 4px;
+    margin: 0 -4px;
+
+    &:hover {
+      background: rgba(0, 210, 255, 0.12);
+    }
+
+    &.is-active {
+      background: rgba(0, 210, 255, 0.2);
+    }
+  }
 }
 
 .cockpit-region-rank__badge {
@@ -262,23 +375,14 @@ export default {
   flex-shrink: 0;
   display: flex;
   align-items: center;
-  gap: 3px;
-}
-
-.cockpit-region-rank__dot {
-  width: 8px;
-  height: 8px;
-  border-radius: 2px;
-  flex-shrink: 0;
+  gap: 6px;
 }
 
-.cockpit-region-rank__count {
-  font-size: 12px;
+.cockpit-region-rank__level {
+  font-size: 11px;
   font-weight: 600;
-  color: #7ecf51;
-  margin-left: 2px;
-  min-width: 32px;
-  text-align: right;
+  white-space: nowrap;
+  line-height: 1;
 }
 
 .cockpit-region-rank__abnormal-count {

+ 98 - 0
src/views/admin/cockpitManage/component/cockpitHealthMapper.js

@@ -125,6 +125,92 @@ export function aggregateRegionHealth(rootNode, allRows) {
 }
 
 /**
+ * 将区域健康接口列表映射为排名 UI 结构
+ * @param {Array} data
+ * @returns {Array} [{ regionName, codeNumber, excellent, good, medium, poor, total }]
+ */
+export function mapHealthscoresRegionList(data) {
+  const list = Array.isArray(data)
+    ? data
+    : Array.isArray(data?.list)
+      ? data.list
+      : Array.isArray(data?.records)
+        ? data.records
+        : [];
+  const rows = list.map((item) => {
+    const excellent = toCount(
+      item.excellentCount ?? item.excellent ?? item.youCount,
+    );
+    const good = toCount(item.goodCount ?? item.good ?? item.liangCount);
+    const medium = toCount(
+      item.fairCount ?? item.mediumCount ?? item.medium ?? item.zhongCount,
+    );
+    const poor = toCount(item.poorCount ?? item.poor ?? item.chaCount);
+    const total = excellent + good + medium + poor;
+    return {
+      regionName:
+        item.companyName ||
+        item.regionName ||
+        item.fieldName ||
+        item.companyCode ||
+        item.codeNumber ||
+        "—",
+      codeNumber: String(
+        item.companyCode || item.codeNumber || item.regionCode || "",
+      ),
+      excellent,
+      good,
+      medium,
+      poor,
+      total,
+    };
+  });
+  return rows
+    .filter((row) => row.total > 0 && row.codeNumber)
+    .sort((a, b) => b.excellent - a.excellent || b.good - a.good);
+}
+
+/**
+ * 将区域异常接口列表映射为排名 UI 结构
+ * @param {Array} data
+ * @returns {Array} [{ regionName, codeNumber, errCount }]
+ */
+export function mapAnomalyRegionList(data) {
+  const list = Array.isArray(data)
+    ? data
+    : Array.isArray(data?.list)
+      ? data.list
+      : Array.isArray(data?.records)
+        ? data.records
+        : [];
+  const rows = list.map((item) => {
+    const errCount = toCount(
+      item.errCount ??
+        item.totalAnomalyCount ??
+        item.anomalyCount ??
+        item.focusCount ??
+        item.attentionCount,
+    );
+    return {
+      regionName:
+        item.companyName ||
+        item.regionName ||
+        item.fieldName ||
+        item.companyCode ||
+        item.codeNumber ||
+        "—",
+      codeNumber: String(
+        item.companyCode || item.codeNumber || item.regionCode || "",
+      ),
+      errCount,
+    };
+  });
+  return rows
+    .filter((row) => row.errCount > 0 && row.codeNumber)
+    .sort((a, b) => b.errCount - a.errCount);
+}
+
+/**
  * 将多场站异常数据按直属省份/子公司节点聚合,返回区域异常排名列表
  * @param {object} rootNode   - 当前根节点
  * @param {Array} allAbnormal - 已展开的全部风场异常行 [{ fieldCode, errCount }]
@@ -174,6 +260,18 @@ export function collectFieldCodes(node) {
 }
 
 /**
+ * 只保留当前选中组织节点下属风场,用于排名点击后右侧面板联动
+ */
+export function filterRowsByNodeFields(rows, node) {
+  const codes = new Set(collectFieldCodes(node));
+  if (!codes.size) return rows || [];
+  return (rows || []).filter((row) => {
+    const code = String(row.fieldCode || row.codeNumber || row.fieldId || "");
+    return Boolean(code) && codes.has(code);
+  });
+}
+
+/**
  * 过滤出有实际评估数据(有任一台风机被评估)的场站行
  * 若 row 的 excellent + good + medium + poor 全为 0,则视为无数据
  * @param {Array} rows

+ 45 - 36
src/views/anomalyDetection/anomalyDetectionDetail.vue

@@ -5,41 +5,43 @@
     element-loading-background="rgba(5, 14, 35, 0.75)"
   >
     <div class="anomaly-detail-toolbar-container">
-      <div class="anomaly-detail-toolbar">
-        <el-button
-          class="anomaly-detail-back"
-          size="small"
-          icon="el-icon-back"
-          @click="goBack"
-        >
-          返回列表
-        </el-button>
-        <div class="anomaly-detail-tabs">
-          <button
-            v-for="tab in moduleTabs"
-            :key="tab.key"
-            type="button"
-            class="anomaly-detail-tab"
-            :class="{ active: activeTab === tab.key }"
-            @click="switchTab(tab.key)"
+      <div class="anomaly-detail-top">
+        <div class="anomaly-detail-top__left">
+          <el-button
+            class="anomaly-detail-back"
+            size="small"
+            icon="el-icon-back"
+            @click="goBack"
           >
-            {{ tab.label }}
-          </button>
-        </div>
-        <div class="anomaly-detail-filters">
+            返回列表
+          </el-button>
           <div
             v-if="displayFieldName || displayMachineType"
             class="anomaly-detail-meta"
           >
             <span v-if="displayFieldName" class="anomaly-detail-meta__item">
-              风场名:{{ displayFieldName }}
+              <em class="anomaly-detail-meta__label">风场</em>
+              <strong class="anomaly-detail-meta__value">{{
+                displayFieldName
+              }}</strong>
             </span>
-            <span v-if="displayMachineType" class="anomaly-detail-meta__item">
-              机型:{{ displayMachineType }}
+            <span class="anomaly-detail-meta__item">
+              <em class="anomaly-detail-meta__label">机型</em>
+              <strong class="anomaly-detail-meta__value"
+                >XXXX{{ displayMachineType }}</strong
+              >
             </span>
+            <!-- <span v-if="displayMachineType" class="anomaly-detail-meta__item">
+              <em class="anomaly-detail-meta__label">机型</em>
+              <strong class="anomaly-detail-meta__value">{{
+                displayMachineType
+              }}</strong>
+            </span> -->
           </div>
+        </div>
+        <div class="anomaly-detail-filters">
           <label class="anomaly-detail-filter">
-            风机:
+            <span class="anomaly-detail-filter__label">风机</span>
             <el-select
               v-model="engineId"
               size="small"
@@ -55,7 +57,7 @@
             </el-select>
           </label>
           <label class="anomaly-detail-filter">
-            时间:
+            <span class="anomaly-detail-filter__label">时间</span>
             <el-date-picker
               v-model="datatime"
               type="date"
@@ -68,6 +70,20 @@
           </label>
         </div>
       </div>
+      <div class="anomaly-detail-toolbar">
+        <div class="anomaly-detail-tabs">
+          <button
+            v-for="tab in moduleTabs"
+            :key="tab.key"
+            type="button"
+            class="anomaly-detail-tab"
+            :class="{ active: activeTab === tab.key }"
+            @click="switchTab(tab.key)"
+          >
+            {{ tab.label }}
+          </button>
+        </div>
+      </div>
     </div>
     <!-- 各模块详情 -->
     <template v-for="mod in moduleDetailTabs">
@@ -106,8 +122,7 @@
                 :key="preset"
                 :class="{
                   active:
-                    trendRange[mod.key] === preset &&
-                    !isCustomTrend(mod.key),
+                    trendRange[mod.key] === preset && !isCustomTrend(mod.key),
                 }"
                 @click="setTrendRange(mod.key, preset)"
               >
@@ -265,17 +280,11 @@ export default {
       );
     },
     displayFieldName() {
-      return (
-        this.selectedWindCard?.fieldName ||
-        this.fieldName ||
-        ""
-      );
+      return this.selectedWindCard?.fieldName || this.fieldName || "";
     },
     displayMachineType() {
       return (
-        this.selectedWindCard?.machineType ||
-        this.machineType ||
-        ""
+        this.selectedWindCard?.machineTypeCode || this.machineTypeCode || ""
       );
     },
   },

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

@@ -8,6 +8,9 @@
       v-model="companyCode"
       :datatime.sync="datatime"
       :prefer-field-code="preferFieldCode"
+      :show-download-results="false"
+      report-module="anomaly"
+      :report-payload="anomalyReportPayload"
       @change="handleQueryChange"
     />
 
@@ -108,12 +111,20 @@ export default {
       datatime,
       loading: false,
       dashboard: getEmptyDashboard(),
+      lastOverviewData: {},
+      lastModelList: [],
       restoredState,
       preferFieldCode: fieldCode,
       windSortOrder: "asc",
     };
   },
   computed: {
+    anomalyReportPayload() {
+      return {
+        overviewData: this.lastOverviewData,
+        modelList: this.lastModelList,
+      };
+    },
     displayWindCards() {
       const direction = this.windSortOrder === "desc" ? -1 : 1;
       console.log(
@@ -216,6 +227,10 @@ export default {
             return { data: {} };
           }),
         ]);
+        this.lastOverviewData = overviewRes?.data || {};
+        this.lastModelList = Array.isArray(modelRes?.data)
+          ? modelRes.data
+          : modelRes?.data?.list || modelRes?.data?.records || [];
         this.dashboard = mapAnomalyDashboard({
           overviewRes,
           modelCountRes,

+ 88 - 25
src/views/anomalyDetection/styles/anomaly-detail.scss

@@ -7,10 +7,11 @@
   font-family: "Microsoft YaHei", system-ui, sans-serif;
 }
 .anomaly-detail-toolbar-container {
-  gap: 14px;
-  flex-wrap: wrap;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
   border-radius: 12px;
-  padding: 10px 14px;
+  padding: 12px 14px;
   margin-bottom: 20px;
 }
 
@@ -20,9 +21,6 @@
   justify-content: flex-start;
   gap: 14px;
   flex-wrap: wrap;
-  border-radius: 12px;
-  padding: 10px 14px;
-  margin-bottom: 20px;
 }
 
 .anomaly-detail-back {
@@ -49,41 +47,73 @@
   transition: all 0.25s ease;
 }
 
-.anomaly-detail-filters {
+.anomaly-detail-top {
   display: flex;
   align-items: center;
-  gap: 12px;
+  gap: 16px;
   flex-wrap: wrap;
-  margin-left: auto;
+  padding-bottom: 10px;
+  border-bottom: 1px solid var(--anomaly-card-border, #ebeef5);
+}
+
+.anomaly-detail-top__left {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  min-width: 0;
+}
+
+.anomaly-detail-filters {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 16px;
 }
 
 .anomaly-detail-meta {
   display: flex;
+  flex-direction: row;
   align-items: center;
-  gap: 10px;
-  flex-wrap: wrap;
-  font-size: 12px;
+  gap: 16px;
+  min-width: 0;
 }
 
-.anomaly-detail-meta__item {
+.anomaly-detail-meta__item,
+.anomaly-detail-filter {
   display: inline-flex;
   align-items: center;
-  max-width: 180px;
+  gap: 8px;
+  min-width: 0;
+}
+
+.anomaly-detail-meta__label,
+.anomaly-detail-filter__label {
+  flex-shrink: 0;
+  font-style: normal;
+  font-size: 12px;
+  font-weight: 400;
+  line-height: 1;
+  color: var(--anomaly-muted, #909399);
+}
+
+.anomaly-detail-meta__value {
+  min-width: 0;
+  max-width: 220px;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
-  color: var(--anomaly-muted, #606266);
+  font-size: 14px;
+  font-weight: 600;
+  color: var(--anomaly-text, #303133);
 }
 
 .anomaly-detail-filter {
   font-size: 12px;
-  display: flex;
-  align-items: center;
-  gap: 6px;
+  margin: 0;
 }
 
 .anomaly-detail-filter-select {
-  width: 180px;
+  width: 140px;
 }
 
 .anomaly-detail-filter-date {
@@ -403,7 +433,7 @@ html:not([data-theme="tech-blue"]) {
     }
   }
 
-  .anomaly-detail-toolbar,
+  .anomaly-detail-toolbar-container,
   .anomaly-detail-accordion,
   .anomaly-detail-chart-panel,
   .anomaly-detail-table-wrap,
@@ -424,10 +454,18 @@ html:not([data-theme="tech-blue"]) {
     }
   }
 
+  .anomaly-detail-top {
+    border-bottom-color: var(--anomaly-card-border, #ebeef5);
+  }
+
   .anomaly-detail-filter {
     color: var(--anomaly-page-text, #606266);
   }
 
+  .anomaly-detail-filter__label {
+    color: var(--anomaly-muted, #909399);
+  }
+
   .anomaly-detail-acc-item {
     border: 1px solid var(--anomaly-card-border, #ebeef5);
   }
@@ -547,8 +585,12 @@ html:not([data-theme="tech-blue"]) {
     }
   }
 
-  .anomaly-detail-meta__item {
-    color: var(--anomaly-muted, #606266);
+  .anomaly-detail-meta__label {
+    color: var(--anomaly-muted, #909399);
+  }
+
+  .anomaly-detail-meta__value {
+    color: var(--anomaly-text, #303133);
   }
 
   .anomaly-detail-trend-custom {
@@ -575,7 +617,7 @@ html[data-theme="tech-blue"] {
     color: #e2e8f0;
   }
 
-  .anomaly-detail-toolbar,
+  .anomaly-detail-toolbar-container,
   .anomaly-detail-accordion,
   .anomaly-detail-chart-panel,
   .anomaly-detail-table-wrap,
@@ -601,10 +643,27 @@ html[data-theme="tech-blue"] {
     }
   }
 
+  .anomaly-detail-top {
+    border-bottom-color: rgba(0, 242, 255, 0.18);
+  }
+
   .anomaly-detail-filter {
     color: #9ecce6;
   }
 
+  .anomaly-detail-filter__label {
+    color: rgba(183, 223, 245, 0.55);
+  }
+
+  .anomaly-detail-filter-select,
+  .anomaly-detail-filter-date {
+    .el-input__inner {
+      background: rgba(0, 16, 32, 0.55);
+      color: #c8e8f8;
+      border-color: rgba(0, 140, 210, 0.45);
+    }
+  }
+
   .anomaly-detail-acc-item {
     border: 1px solid rgba(0, 242, 255, 0.2);
   }
@@ -758,8 +817,12 @@ html[data-theme="tech-blue"] {
     }
   }
 
-  .anomaly-detail-meta__item {
-    color: rgba(183, 223, 245, 0.78);
+  .anomaly-detail-meta__label {
+    color: rgba(183, 223, 245, 0.55);
+  }
+
+  .anomaly-detail-meta__value {
+    color: #e8f6ff;
   }
 
   .anomaly-detail-trend-custom {

+ 90 - 10
src/views/anomalyDetection/utils/anomalyPlotChartBuilder.js

@@ -27,6 +27,18 @@ function chartTheme() {
     tooltipText: readCssVar("--anomaly-chart-tooltip-text", "#dbeafe"),
     legend: readCssVar("--anomaly-chart-legend", "#cde6ff"),
     title: readCssVar("--anomaly-title", "#e3f2ff"),
+    dataZoomBg: readCssVar(
+      "--anomaly-chart-datazoom-bg",
+      "rgba(16, 21, 39, 0.85)",
+    ),
+    dataZoomBorder: readCssVar(
+      "--anomaly-chart-datazoom-border",
+      "rgba(101, 167, 201, 0.35)",
+    ),
+    dataZoomFiller: readCssVar(
+      "--anomaly-chart-datazoom-filler",
+      "rgba(50, 115, 138, 0.55)",
+    ),
   };
 }
 
@@ -289,6 +301,78 @@ function buildSeries(panel, theme, options = {}) {
   return { series, useTime, visualMaps };
 }
 
+/** 检测器折线/散点图:区域圈选、滚轮缩放、拖拽平移、还原 */
+function buildPlotInteractionOptions(
+  theme,
+  { hasVisualMap, useTime, hasLegend } = {},
+) {
+  const sliderBottom = hasLegend ? 32 : 8;
+  return {
+    toolbox: {
+      right: hasVisualMap ? 76 : 8,
+      top: 4,
+      itemSize: 14,
+      itemGap: 8,
+      showTitle: true,
+      iconStyle: { borderColor: theme.legend },
+      emphasis: { iconStyle: { borderColor: theme.title } },
+      tooltip: {
+        show: true,
+        backgroundColor: theme.tooltipBg,
+        borderColor: theme.tooltipBorder,
+        textStyle: { color: theme.tooltipText, fontSize: 11 },
+      },
+      feature: {
+        dataZoom: {
+          xAxisIndex: 0,
+          yAxisIndex: 0,
+          title: { zoom: "区域缩放", back: "缩放还原" },
+        },
+        restore: { title: "还原" },
+        saveAsImage: {
+          title: "保存图片",
+          pixelRatio: 2,
+          backgroundColor: "transparent",
+        },
+      },
+    },
+    dataZoom: [
+      {
+        type: "inside",
+        xAxisIndex: 0,
+        filterMode: "none",
+        zoomOnMouseWheel: true,
+        moveOnMouseMove: true,
+        preventDefaultMouseMove: true,
+      },
+      {
+        type: "inside",
+        yAxisIndex: 0,
+        filterMode: "none",
+        zoomOnMouseWheel: "shift",
+        moveOnMouseMove: true,
+        preventDefaultMouseMove: true,
+      },
+      ...(useTime
+        ? [
+            {
+              type: "slider",
+              xAxisIndex: 0,
+              height: 16,
+              bottom: sliderBottom,
+              borderColor: theme.dataZoomBorder,
+              backgroundColor: theme.dataZoomBg,
+              fillerColor: theme.dataZoomFiller,
+              handleSize: "80%",
+              showDetail: false,
+              brushSelect: false,
+            },
+          ]
+        : []),
+    ],
+  };
+}
+
 export function buildPanelChartOption(panel, options = {}) {
   const theme = chartTheme();
   if (isCompositionPanel(panel)) {
@@ -301,22 +385,18 @@ export function buildPanelChartOption(panel, options = {}) {
   const yName = panel.yaxis || "";
   const hasVisualMap = visualMaps.length > 0;
   const timeAxisBottom = hasLegend ? 82 : 66;
+  const sliderExtraBottom = useTime ? (hasLegend ? 28 : 22) : 0;
 
   return {
     backgroundColor: "transparent",
+    ...buildPlotInteractionOptions(theme, { hasVisualMap, useTime, hasLegend }),
     grid: {
-      top: hasLegend ? 28 : 20,
+      top: hasLegend ? 44 : 36,
       left: yName ? 58 : 52,
       right: hasVisualMap ? 72 : 24,
-      bottom: useTime
-        ? timeAxisBottom
-        : xName
-          ? hasLegend
-            ? 68
-            : 52
-          : hasLegend
-            ? 56
-            : 40,
+      bottom:
+        (useTime ? timeAxisBottom : xName ? (hasLegend ? 68 : 52) : hasLegend ? 56 : 40) +
+        sliderExtraBottom,
       containLabel: true,
     },
     tooltip: {

+ 229 - 0
src/views/health/components/health/HealthHeader.vue

@@ -30,12 +30,47 @@
         @change="handleDateChange"
       />
     </div>
+
+    <button
+      v-if="showDownloadResults"
+      type="button"
+      class="health-header__item health-header__download"
+      :disabled="downloadLoading"
+      @click="handleDownloadResults"
+    >
+      <i class="el-icon-download"></i>
+      <span>{{ downloadLoading ? "下载中..." : "下载评估结果" }}</span>
+    </button>
+    <button
+      type="button"
+      class="health-header__item health-header__download"
+      :disabled="reportLoading"
+      @click="handleDownloadReport"
+    >
+      <i class="el-icon-document"></i>
+      <span>{{ reportLoading ? "报告生成中..." : "下载报告" }}</span>
+    </button>
   </div>
 </template>
 
 <script>
+import * as XLSX from "xlsx";
+import { saveAs } from "file-saver";
 import selecttree from "@/components/selecttree.vue";
 import { getSysOrganizationAuthTreeByRoleId } from "@/api/ledger.js";
+import { getHealthOverview } from "@/api/healthAnalyse";
+import { downloadReportWithNotify } from "@/api/reportDownload";
+import {
+  applyNoEvalHeaderNotes,
+  buildFarmSheetRow,
+  buildTurbineSheetRows,
+  FARM_SCORE_COLS,
+  TURBINE_LEVEL_COL,
+  TURBINE_SCORE_COLS,
+} from "@/views/health/utils/healthDownloadMapper";
+
+const HEALTH_RESULT_TEMPLATE =
+  "风场健康评估数据下载模版.xlsx";
 
 function getYesterdayString() {
   const date = new Date();
@@ -64,6 +99,19 @@ export default {
       type: String,
       default: "",
     },
+    showDownloadResults: {
+      type: Boolean,
+      default: true,
+    },
+    /** health | anomaly */
+    reportModule: {
+      type: String,
+      default: "health",
+    },
+    reportPayload: {
+      type: Object,
+      default: () => ({}),
+    },
   },
   data() {
     return {
@@ -76,6 +124,8 @@ export default {
         value: "codeNumber",
       },
       selectedField: null,
+      downloadLoading: false,
+      reportLoading: false,
     };
   },
   watch: {
@@ -154,6 +204,49 @@ export default {
       }
       return null;
     },
+    findAncestorPath(treeData, code) {
+      const target = String(code || "");
+      const walk = (nodes, trail) => {
+        const list = Array.isArray(nodes) ? nodes : [];
+        for (let i = 0; i < list.length; i += 1) {
+          const node = list[i];
+          if (!node) continue;
+          const next = trail.concat(node);
+          const matched = [
+            "codeNumber",
+            "fieldCode",
+            "fieldId",
+            "companyCode",
+          ].some((key) => node[key] != null && String(node[key]) === target);
+          if (matched) return next;
+          const found = walk(node.children, next);
+          if (found) return found;
+        }
+        return null;
+      };
+      return walk(treeData, []) || [];
+    },
+    buildFieldMeta() {
+      const field = this.selectedField || {};
+      const path = this.findAncestorPath(
+        this.parentOpt,
+        field.fieldCode || field.codeNumber || this.companyCode,
+      );
+      const parent = path.length >= 2 ? path[path.length - 2] : null;
+      const root = path[0] || null;
+      return {
+        fieldName: field.fieldName || field.companyName || "",
+        provinceName:
+          field.provinceName ||
+          field.province ||
+          parent?.companyName ||
+          root?.companyName ||
+          "",
+        cityName: field.cityName || field.city || parent?.companyName || "",
+        machineTypeCode: field.machineTypeCode || "",
+        engineCount: field.engineCount || "",
+      };
+    },
     findNodeByCode(treeData, code) {
       if (!code || !Array.isArray(treeData)) return null;
       const matchKeys = ["codeNumber", "fieldCode", "fieldId", "companyCode"];
@@ -237,6 +330,123 @@ export default {
       this.$emit("date-change", this.localDatatime);
       this.emitQueryChange();
     },
+    async handleDownloadResults() {
+      if (this.downloadLoading) return;
+      const fieldCode =
+        this.selectedField?.fieldCode ||
+        this.selectedField?.codeNumber ||
+        this.companyCode;
+      if (!fieldCode) {
+        this.$message.warning("请先选择风场");
+        return;
+      }
+      this.downloadLoading = true;
+      try {
+        const params = { fieldCode };
+        if (this.localDatatime) params.datatime = this.localDatatime;
+        const res = await getHealthOverview(params);
+        const data = res?.data || {};
+        const windVo = data.healthscoresWindVO || {};
+        const list = data.healthOverviewListVOList || [];
+        if (!windVo.fieldCode && !windVo.fieldId && !list.length) {
+          this.$message.warning("暂无健康评估结果可下载");
+          return;
+        }
+
+        const templateUrl = `${
+          process.env.BASE_URL || "/"
+        }files/${encodeURIComponent(HEALTH_RESULT_TEMPLATE)}`;
+        const templateRes = await fetch(templateUrl);
+        if (!templateRes.ok) {
+          throw new Error("下载模版失败");
+        }
+        const templateBuf = await templateRes.arrayBuffer();
+        const workbook = XLSX.read(templateBuf, { type: "array" });
+        const sheetNames = workbook.SheetNames || [];
+        if (sheetNames.length < 2) {
+          throw new Error("模版缺少风场/风机工作表");
+        }
+
+        const farmSheet = workbook.Sheets[sheetNames[0]];
+        const turbineSheet = workbook.Sheets[sheetNames[1]];
+        const farmRow = buildFarmSheetRow(windVo);
+        XLSX.utils.sheet_add_aoa(farmSheet, [farmRow], {
+          origin: "A2",
+        });
+        applyNoEvalHeaderNotes(farmSheet, [farmRow], FARM_SCORE_COLS);
+
+        const turbineRows = buildTurbineSheetRows(list, windVo);
+        if (turbineRows.length) {
+          XLSX.utils.sheet_add_aoa(turbineSheet, turbineRows, {
+            origin: "A2",
+          });
+        }
+        applyNoEvalHeaderNotes(turbineSheet, turbineRows, [
+          ...TURBINE_SCORE_COLS,
+          TURBINE_LEVEL_COL,
+        ]);
+
+        const out = XLSX.write(workbook, { bookType: "xlsx", type: "array" });
+        const farmName =
+          windVo.fieldName ||
+          this.selectedField?.fieldName ||
+          this.selectedField?.companyName ||
+          fieldCode;
+        const datePart =
+          this.localDatatime || windVo.sourceDatetime || "评估结果";
+        saveAs(
+          new Blob([out], {
+            type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+          }),
+          `${farmName}_健康评估结果_${datePart}.xlsx`,
+        );
+      } catch (error) {
+        console.error("下载健康评估结果失败:", error);
+        this.$message.error("下载失败,请稍后重试");
+      } finally {
+        this.downloadLoading = false;
+      }
+    },
+    async handleDownloadReport() {
+      if (this.reportLoading) return;
+      const fieldCode =
+        this.selectedField?.fieldCode ||
+        this.selectedField?.codeNumber ||
+        this.companyCode;
+      if (!fieldCode) {
+        this.$message.warning("请先选择风场");
+        return;
+      }
+      this.reportLoading = true;
+      try {
+        const farmName =
+          this.selectedField?.fieldName ||
+          this.selectedField?.companyName ||
+          "";
+        const payload = {
+          fieldCode,
+          datatime: this.localDatatime || "",
+          fieldName: farmName,
+          trendDays: 30,
+          fieldMeta: this.buildFieldMeta(),
+        };
+        if (this.reportModule === "health") {
+          const res = await getHealthOverview({
+            fieldCode,
+            ...(this.localDatatime ? { datatime: this.localDatatime } : {}),
+          });
+          payload.overviewData = res?.data || {};
+        }
+        if (this.reportModule === "anomaly" && this.reportPayload) {
+          Object.assign(payload, this.reportPayload);
+        }
+        await downloadReportWithNotify(this.reportModule, payload);
+      } catch (error) {
+        console.error("下载报告失败:", error);
+      } finally {
+        this.reportLoading = false;
+      }
+    },
   },
 };
 </script>
@@ -268,6 +478,25 @@ export default {
   padding-right: 8px;
 }
 
+.health-header__download {
+  cursor: pointer;
+  color: #e2e8f0;
+  font-size: 13px;
+  font-family: inherit;
+  white-space: nowrap;
+
+  &:disabled {
+    opacity: 0.6;
+    cursor: not-allowed;
+  }
+
+  .el-icon-download,
+  .el-icon-document {
+    color: #22d3ee;
+    font-size: 16px;
+  }
+}
+
 .health-header__farm-icon {
   color: #22d3ee;
   font-size: 16px;

+ 133 - 0
src/views/health/utils/healthDownloadMapper.js

@@ -0,0 +1,133 @@
+/**
+ * 健康评估结果 Excel 下载:按模版列顺序组装行数据
+ * 等级枚举与 unitDetailData LEVEL_STATUS_MAP / HEALTH_STATUS 一致
+ */
+import * as XLSX from "xlsx";
+import { HEALTH_STATUS } from "../components/health/unitDetailData";
+
+const NO_EVAL_NOTE = "(未达到评估标准)";
+const NO_EVAL_DISPLAY = "/";
+
+/** Sheet1 评分列:综合/系统/部件/结构 */
+export const FARM_SCORE_COLS = [4, 5, 6, 7];
+
+/** Sheet2 评分列(不含综合健康等级) */
+export const TURBINE_SCORE_COLS = [
+  6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
+];
+
+/** Sheet2 综合健康等级列 */
+export const TURBINE_LEVEL_COL = 7;
+
+const STATUS_KEY_TO_LEVEL = Object.keys(HEALTH_STATUS).reduce((acc, key) => {
+  if (key === "none") return acc;
+  const label = HEALTH_STATUS[key].label;
+  acc[key] = label;
+  acc[label] = label;
+  return acc;
+}, {});
+
+function cell(value) {
+  if (value == null || value === "") return "";
+  return value;
+}
+
+function isNoEvalScore(score) {
+  if (score == null || score === "") return false;
+  const value = Number(score);
+  return Number.isFinite(value) && value === -1;
+}
+
+/** 评分:-1 → 「/」,其余原样 */
+function scoreCell(score) {
+  if (score == null || score === "") return "";
+  if (isNoEvalScore(score)) return NO_EVAL_DISPLAY;
+  return score;
+}
+
+/**
+ * 综合健康等级 → 中文优/良/中/差
+ * 评分为 -1 时与评分一致展示「/」
+ */
+function levelCell(level, score) {
+  if (isNoEvalScore(score)) return NO_EVAL_DISPLAY;
+  if (level == null || level === "") return "";
+  const key = String(level).trim();
+  return STATUS_KEY_TO_LEVEL[key] || STATUS_KEY_TO_LEVEL[key.toLowerCase()] || key;
+}
+
+/**
+ * 若某列存在「/」(未达评估标准),表头追加说明
+ * @param {Object} sheet
+ * @param {Array<Array>} dataRows
+ * @param {number[]} colIndexes
+ */
+export function applyNoEvalHeaderNotes(sheet, dataRows, colIndexes) {
+  if (!sheet || !Array.isArray(colIndexes)) return;
+  const rows = Array.isArray(dataRows) ? dataRows : [];
+  colIndexes.forEach((c) => {
+    const hasNoEval = rows.some((row) => row && row[c] === NO_EVAL_DISPLAY);
+    if (!hasNoEval) return;
+    const ref = XLSX.utils.encode_cell({ r: 0, c });
+    const existing = sheet[ref];
+    const label = existing && existing.v != null ? String(existing.v) : "";
+    if (!label || label.includes("未达到评估标准")) return;
+    sheet[ref] = { t: "s", v: `${label}${NO_EVAL_NOTE}` };
+  });
+}
+
+/**
+ * Sheet1「风场范围」一行
+ * @param {Object} windVo healthscoresWindVO
+ */
+export function buildFarmSheetRow(windVo = {}) {
+  return [
+    cell(windVo.fieldCode || windVo.fieldId),
+    cell(windVo.fieldName),
+    cell(windVo.sourceDatetime),
+    cell(windVo.totalCount),
+    scoreCell(windVo.overallScore),
+    scoreCell(windVo.systemScore),
+    scoreCell(windVo.componentScore),
+    scoreCell(windVo.structureScore),
+    cell(windVo.excellentCount),
+    cell(windVo.goodCount),
+    cell(windVo.fairCount),
+    cell(windVo.poorCount),
+    cell(windVo.createTime),
+  ];
+}
+
+/**
+ * Sheet2 风机明细多行
+ * @param {Array} list healthOverviewListVOList
+ * @param {Object} windVo healthscoresWindVO(补风场编号/名称)
+ */
+export function buildTurbineSheetRows(list = [], windVo = {}) {
+  const fieldCode = windVo.fieldCode || windVo.fieldId || "";
+  const fieldName = windVo.fieldName || "";
+  return (Array.isArray(list) ? list : []).map((item, index) => [
+    index + 1,
+    cell(item.fieldId || fieldCode),
+    cell(fieldName),
+    cell(item.engineId || item.engineName),
+    cell(item.machineTypeCode),
+    cell(item.sourceDatetime || windVo.sourceDatetime),
+    scoreCell(item.overallScore),
+    levelCell(item.overallLevel, item.overallScore),
+    scoreCell(item.systemScore),
+    scoreCell(item.yawSystemScore),
+    scoreCell(item.pitchSystemScore),
+    scoreCell(item.controlSystemScore),
+    scoreCell(item.hydraulicSystemScore),
+    scoreCell(item.componentScore),
+    scoreCell(item.generatorScore),
+    scoreCell(item.converterScore),
+    scoreCell(item.gearboxScore),
+    scoreCell(item.mainShaftScore),
+    scoreCell(item.structureScore),
+    scoreCell(item.rotorScore),
+    scoreCell(item.towerScore),
+    cell(item.createTime),
+  ]);
+}

+ 11 - 1
vue.config.js

@@ -13,12 +13,15 @@ function resolve(dir) {
 function buildDevProxy(routes) {
   return routes.reduce((proxy, route) => {
     if (!route.target) return proxy;
-    const { context, target, pathRewrite, onProxyReq } = route;
+    const { context, target, pathRewrite, onProxyReq, timeout, proxyTimeout } =
+      route;
     proxy[context] = {
       target,
       changeOrigin: true,
       ...(pathRewrite ? { pathRewrite } : {}),
       ...(onProxyReq ? { onProxyReq } : {}),
+      ...(timeout ? { timeout } : {}),
+      ...(proxyTimeout ? { proxyTimeout } : {}),
     };
     return proxy;
   }, {});
@@ -118,6 +121,11 @@ module.exports = {
         },
       },
       {
+        context: "/tiles_map",
+        target: process.env.VUE_APP_MAP,
+        pathRewrite: { "^/tiles_map": "/tiles_map" },
+      },
+      {
         context: "/tiles",
         target: process.env.VUE_APP_MAP,
         pathRewrite: { "^/tiles": "/tiles" },
@@ -146,6 +154,8 @@ module.exports = {
         context: "/downLoadChart",
         target: process.env.VUE_APP_downLoadChartAPIPROXY,
         pathRewrite: { "^/downLoadChart": "" },
+        timeout: 30 * 60 * 1000,
+        proxyTimeout: 30 * 60 * 1000,
       },
     ]),
   },