Ver código fonte

新增大唐需求增加机型、风场名信息,趋势图新增3个月自定义时间

liujiejie 3 semanas atrás
pai
commit
db795982ca

+ 2 - 2
.env.dt

@@ -1,7 +1,7 @@
 ###
  # @Author: your name
  # @Date: 2026-03-06 11:19:04
- # @LastEditTime: 2026-07-29 09:43:54
+ # @LastEditTime: 2026-07-29 11:14:50
  # @LastEditors: milo-MacBook-Pro.local
  # @Description: In User Settings Edit
  # @FilePath: /performance-test/.env.dt
@@ -37,7 +37,7 @@ VUE_APP_DEV_PORT=8080
 #内网
 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
+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'

+ 11 - 2
docs/OPERATION_MANUAL_ANOMALY.md

@@ -282,6 +282,14 @@
 
 每张风机卡片分为两个区域:
 
+**标题区:**
+
+| 字段 | 说明 |
+|------|------|
+| 风机名称 | 机组名称 |
+| 风场名 | 所属风场名称(接口有返回时展示) |
+| 机型 | 风机机型型号(接口有返回时展示) |
+
 **功能诊断区(中央风机图标四周):**
 
 | 指标 | 说明 |
@@ -330,6 +338,7 @@
 | 返回列表 | 点击左上角 **「← 返回列表」** 按钮 | 返回异常检测风机列表页 |
 | 模块 Tab | 点击顶部模块标签按钮 | 切换不同异常分析维度(见下表) |
 | 风机 | 在下拉框中选择其他风机 | 切换查看同风场下其他机组,详情数据自动刷新 |
+| 风场名 / 机型 | 只读展示 | 显示当前机组所属风场名称与机型信息 |
 | 时间 | 在日期选择器中选择日期 | 切换分析日期,详情数据自动刷新 |
 
 **模块 Tab 列表:**
@@ -352,7 +361,7 @@
 | 风机异常占比(饼图) | 展示该风机各功能诊断模块异常占比 |
 | 各模块趋势图(折线图) | 各模块异常数据总数的历史趋势 |
 
-**趋势图时间范围切换:** 每个模块趋势图右上角提供 **「近 7 天」** / **「近 30 天」** 切换按钮,点击后图表数据按对应时间范围重新加载
+/** 趋势图时间范围切换:** 每个模块趋势图右上角提供 **「近 7 天」** / **「近 30 天」** / **「近 90 天」** 预设按钮,以及 **「自定义」**(1~90 天,最长约 3 个月)天数输入;点击后按对应 `timeRange` 重新加载趋势曲线
 
 ### 5.3 模块详情 Tab
 
@@ -509,7 +518,7 @@
 | A-17 | 近30日发电量判定 | 展开风速-功率模块该项 | 按 >120% / 80%~120% / <80% 显示超发/正常/欠发 |
 | A-18 | 功率质量散点图 | 展开功率质量异常 | 图表以散点图形式展示 |
 | A-19 | 详情页风机切换 | 下拉选择其他风机 | 详情数据按新风机刷新 |
-| A-20 | 趋势图时间范围 | 总览 Tab 切换 7/30 天 | 趋势折线图正确更新 |
+| A-20 | 趋势图时间范围 | 总览 Tab 切换 7/30/90或自定义(≤90) | 趋势折线图正确更新 |
 | A-21 | 返回列表 | 点击「返回列表」 | 回到列表页并恢复滚动位置 |
 | A-22 | 明细表 Tab 切换 | 切换数据感知/功能诊断明细表 | 表格内容正确切换 |
 | A-23 | 尾流菜单进入 | 点击左侧菜单「尾流分析(风资源尾流)」 | 正常进入尾流分析页面 |

+ 1 - 1
src/api/anomalyAnalyse.js

@@ -135,7 +135,7 @@ export function getAerodynamics(data) {
 }
 
 /**
- * 风机详情 - 模块异常趋势曲线(7/30 天)
+ * 风机详情 - 模块异常趋势曲线(7/30/90或自定义天数,最长 90
  * @param {{ fieldId: string, engineId: string, datatime?: string, timeRange?: number }} data
  */
 export function getPitchAnomalyTrend(data) {

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

@@ -185,9 +185,12 @@ import GeoJSON from "ol/format/GeoJSON";
 // 假设你已经有正确的山西省边界数据文件(GeoJSON 格式)
 // import shanxiBoundary from "./shanxi.json";
 import shanxiBoundary from "./jilin.json";
-import icon01 from "../../assets/img/iconFC.png";
-import icon02 from "../../assets/img/iconFC.png";
-import icon03 from "../../assets/img/iconFC.png";
+// import icon01 from "../../assets/img/iconFC.png";
+// import icon02 from "../../assets/img/iconFC.png";
+// import icon03 from "../../assets/img/iconFC.png";
+import icon01 from "../../assets/img/iconFJ.png";
+import icon02 from "../../assets/img/iconFJ.png";
+import icon03 from "../../assets/img/iconFJ.png";
 import icon04 from "../../assets/img/iconFJ.png";
 import icon05 from "../../assets/img/icon05.png";
 import icon06 from "../../assets/img/iconFJ.png";

+ 78 - 21
src/views/admin/cockpitManage/Index.vue

@@ -59,6 +59,18 @@
           </div>
         </section>
 
+        <!-- 集团/根节点级:区域健康与异常排名 -->
+        <section
+          v-if="ShowRi && isRootSelected"
+          class="cockpit-panel cockpit-panel--region-rank"
+        >
+          <CockpitRegionRankPanel
+            :root-node="selectedNode"
+            :parent-opt="parentOpt"
+            :datatime="datatime"
+          />
+        </section>
+
         <template v-if="ShowRi">
           <section
             v-hasPermi="['home:performance:performanceData']"
@@ -115,6 +127,7 @@ import Tmap from "@/components/map";
 import Rightdata from "./component/rightdata.vue";
 import Leftdata from "./component/leftdata.vue";
 import CockpitHealthPanel from "./component/CockpitHealthPanel.vue";
+import CockpitRegionRankPanel from "./component/CockpitRegionRankPanel.vue";
 
 function getYesterdayString() {
   const date = new Date();
@@ -133,6 +146,7 @@ export default {
     selecttree,
     Leftdata,
     CockpitHealthPanel,
+    CockpitRegionRankPanel,
   },
   data() {
     return {
@@ -145,8 +159,17 @@ export default {
       companyCode: "",
       defaultdata: {},
       datatime: getYesterdayString(),
+      selectedNode: {},
     };
   },
+  computed: {
+    isRootSelected() {
+      if (!this.selectedNode || !this.selectedNode.codeNumber) return false;
+      return this.parentOpt.some(
+        (root) => root.codeNumber === this.selectedNode.codeNumber,
+      );
+    },
+  },
   created() {
     this.GETtree();
     this.GETtotal();
@@ -210,38 +233,72 @@ export default {
       }
       return [];
     },
+    collectFieldNodes(node) {
+      const fields = [];
+      function walk(n) {
+        if (!n) return;
+        if (n.codeType === "field") {
+          fields.push(n);
+        }
+        if (n.children && n.children.length) {
+          n.children.forEach(walk);
+        }
+      }
+      walk(node);
+      return fields;
+    },
+    calcCenterPoint(fieldNodes) {
+      const validCoords = fieldNodes
+        .map((n) => this.parseCoordinates(n.longitudeAndLatitudeString))
+        .filter((c) => c.length >= 2);
+      if (!validCoords.length) return null;
+      const avgLon = validCoords.reduce((s, c) => s + c[0], 0) / validCoords.length;
+      const avgLat = validCoords.reduce((s, c) => s + c[1], 0) / validCoords.length;
+      return [avgLon, avgLat];
+    },
     parentChange(data) {
       this.maplist = data;
       this.maplistArr = data;
+      this.selectedNode = data;
       const mapRef = this.$refs.map;
       if (!mapRef || typeof mapRef.clearMarkers !== "function") {
         return;
       }
       mapRef.clearMarkers();
-      if (data.codeType === "field") {
-        if (this.parseCoordinates(data.longitudeAndLatitudeString).length > 0) {
+      // 收集当前选中范围内的所有 field 节点(含深层嵌套)
+      const fieldNodes = this.collectFieldNodes(data);
+      fieldNodes.forEach((node) => {
+        const coords = this.parseCoordinates(node.longitudeAndLatitudeString);
+        if (coords.length > 0) {
           mapRef.addMarker({
-            point: this.parseCoordinates(data.longitudeAndLatitudeString),
-            val: data.analysisState,
-            ...data,
+            point: coords,
+            val: node.analysisState,
+            ...node,
           });
-          return;
         }
-        mapRef.clearMarkers();
-      } else {
-        (data.children || []).forEach((element) => {
-          if (
-            this.parseCoordinates(element.longitudeAndLatitudeString).length >
-              0 &&
-            element.codeType === "field"
-          ) {
-            mapRef.addMarker({
-              point: this.parseCoordinates(element.longitudeAndLatitudeString),
-              val: element.analysisState,
-              ...element,
-            });
-          }
-        });
+      });
+
+      // 地图视角联动
+      const hasChildren = data.children && data.children.length > 0;
+      const isSingleField = data.codeType === "field" && !hasChildren;
+
+      if (this.isRootSelected) {
+        // 第一级(集团/根节点)— 重置为中国全图
+        if (typeof mapRef.moveAndZoom === "function") {
+          mapRef.moveAndZoom({ point: [104, 36], zoom: 4 });
+        }
+      } else if (isSingleField) {
+        // 单个场站 — 飞到该场站,较大缩放
+        const coords = this.parseCoordinates(data.longitudeAndLatitudeString);
+        if (coords.length >= 2 && typeof mapRef.moveAndZoom === "function") {
+          mapRef.moveAndZoom({ point: coords, zoom: 9 });
+        }
+      } else if (fieldNodes.length > 0) {
+        // 区域/省份公司 — 计算所有子场站坐标中心,用省级缩放
+        const center = this.calcCenterPoint(fieldNodes);
+        if (center && typeof mapRef.moveAndZoom === "function") {
+          mapRef.moveAndZoom({ point: center, zoom: 6 });
+        }
       }
     },
     featureClick(data) {

+ 56 - 11
src/views/admin/cockpitManage/component/CockpitHealthPanel.vue

@@ -32,6 +32,8 @@
           :table-data="tableData"
           :loading="loading"
           :datatime="datatime"
+          :sort-config="sortConfig"
+          @sort-change="handleSortChange"
         />
       </div>
 
@@ -63,6 +65,9 @@ import {
   buildFieldCodeMap,
   buildFieldNameMap,
   mapHealthscoresWindList,
+  collectFieldCodes,
+  filterEvaluatedRows,
+  sortHealthTableData,
 } from "./cockpitHealthMapper";
 
 const HEALTH_TAB_PERMI = ["home:health:btn"];
@@ -74,8 +79,7 @@ function hasTabPermi(permi) {
   const allPermission = "*:*:*";
   const permissions = store?.getters?.permissions || [];
   return permissions.some(
-    (permission) =>
-      permission === allPermission || flags.includes(permission),
+    (permission) => permission === allPermission || flags.includes(permission),
   );
 }
 
@@ -100,6 +104,7 @@ export default {
       tableData: [],
       scoreDistribution: [],
       lastHealthList: [],
+      sortConfig: { prop: "", order: "descending" },
       legendItems: [
         { name: "优", color: "#7ECF51" },
         { name: "良", color: "#2979ff" },
@@ -167,7 +172,9 @@ export default {
   },
   methods: {
     syncActiveTabByPermi() {
-      const visibleTabs = this.healthTabs.filter((tab) => hasTabPermi(tab.permi));
+      const visibleTabs = this.healthTabs.filter((tab) =>
+        hasTabPermi(tab.permi),
+      );
       if (!visibleTabs.length) return;
       if (!visibleTabs.some((tab) => tab.value === this.activeTab)) {
         this.activeTab = visibleTabs[0].value;
@@ -255,6 +262,10 @@ export default {
         true,
       );
     },
+    handleSortChange(sort) {
+      this.sortConfig = sort;
+      this.applyHealthList(this.lastHealthList);
+    },
     applyHealthList(list) {
       const fieldNameMap = buildFieldNameMap(this.parentOpt);
       const fieldCodeMap = buildFieldCodeMap(this.parentOpt);
@@ -273,8 +284,15 @@ export default {
           };
         },
       );
-      this.tableData = rows;
-      this.scoreDistribution = aggregateHealthScoreDistribution(rows);
+      // 只展示已评估(有任意一台风机评分数据)的场站
+      const evaluated = filterEvaluatedRows(rows);
+      // 按排序配置排序
+      const sorted = sortHealthTableData(evaluated, this.sortConfig);
+      this.tableData = sorted;
+      this.scoreDistribution = aggregateHealthScoreDistribution(sorted);
+    },
+    collectScopedFieldCodes() {
+      return collectFieldCodes(this.maplistArr);
     },
     buildHealthRequestParams() {
       const fieldCode =
@@ -289,14 +307,41 @@ export default {
       return params;
     },
     async fetchHealthData() {
-      const params = this.buildHealthRequestParams();
-      if (!params) {
-        return;
-      }
+      const selectedNode = this.maplistArr;
+      if (!selectedNode || !Object.keys(selectedNode).length) return;
+
       this.loading = true;
       try {
-        const res = await getHealthscoresWindList(params);
-        const list = Array.isArray(res?.data) ? res.data : [];
+        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 : [];
+            }
+          }
+        }
         this.lastHealthList = list;
         this.applyHealthList(list);
       } catch (error) {

+ 62 - 4
src/views/admin/cockpitManage/component/CockpitHealthTable.vue

@@ -8,10 +8,19 @@
         class="titleYC cockpit-unified-table-head cockpit-unified-table-head--health"
       >
         <span>风场名称</span>
-        <span>优</span>
-        <span>良</span>
-        <span>中</span>
-        <span>差</span>
+        <span
+          v-for="col in sortableCols"
+          :key="col.prop"
+          class="cockpit-sortable-th"
+          :class="{ 'is-active': sortConfig.prop === col.prop }"
+          @click="toggleSort(col.prop)"
+        >
+          {{ col.label }}
+          <i
+            class="cockpit-sort-icon"
+            :class="getSortIconClass(col.prop)"
+          />
+        </span>
       </p>
       <Tabroll
         :table-data="displayTableData"
@@ -40,6 +49,10 @@ export default {
     loading: { type: Boolean, default: false },
     bodyHeight: { type: Number, default: COCKPIT_TABLE_BODY_HEIGHT },
     datatime: { type: String, default: "" },
+    sortConfig: {
+      type: Object,
+      default: () => ({ prop: "", order: "descending" }),
+    },
   },
   data() {
     return {
@@ -52,6 +65,12 @@ export default {
         poor: "16%",
       },
       countFields: ["excellent", "good", "medium", "poor"],
+      sortableCols: [
+        { prop: "excellent", label: "优" },
+        { prop: "good", label: "良" },
+        { prop: "medium", label: "中" },
+        { prop: "poor", label: "差" },
+      ],
     };
   },
   computed: {
@@ -69,6 +88,20 @@ export default {
     },
   },
   methods: {
+    toggleSort(prop) {
+      const current = this.sortConfig || {};
+      let order = "descending";
+      if (current.prop === prop) {
+        order = current.order === "descending" ? "ascending" : "descending";
+      }
+      this.$emit("sort-change", { prop, order });
+    },
+    getSortIconClass(prop) {
+      if (this.sortConfig.prop !== prop) return "el-icon-d-caret";
+      return this.sortConfig.order === "ascending"
+        ? "el-icon-caret-top"
+        : "el-icon-caret-bottom";
+    },
     handleFieldNavigate(row) {
       navigateToHealthDashboard(this.$router, this.$store, {
         fieldCode: row?.fieldCode,
@@ -91,4 +124,29 @@ export default {
   font-size: 14px;
   font-weight: 600;
 }
+
+.cockpit-sortable-th {
+  cursor: pointer;
+  user-select: none;
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+
+  &:hover {
+    color: var(--cockpit-accent, #409eff);
+  }
+
+  &.is-active {
+    color: var(--cockpit-accent, #409eff);
+  }
+}
+
+.cockpit-sort-icon {
+  font-size: 12px;
+  opacity: 0.6;
+
+  .is-active & {
+    opacity: 1;
+  }
+}
 </style>

+ 297 - 0
src/views/admin/cockpitManage/component/CockpitRegionRankPanel.vue

@@ -0,0 +1,297 @@
+<template>
+  <div class="cockpit-region-rank">
+    <CockpitTechTabs v-model="activeTab" :tabs="rankTabs" variant="panel" />
+
+    <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" />
+        <template v-else-if="healthRanks.length">
+          <div
+            v-for="(item, index) in healthRanks"
+            :key="item.codeNumber"
+            class="cockpit-region-rank__row"
+          >
+            <span
+              class="cockpit-region-rank__badge"
+              :class="getBadgeClass(index)"
+            >{{ index + 1 }}</span>
+            <span class="cockpit-region-rank__name" :title="item.regionName">
+              {{ item.regionName }}
+            </span>
+            <span class="cockpit-region-rank__bars">
+              <span
+                v-for="lv in levelBars"
+                :key="lv.key"
+                class="cockpit-region-rank__dot"
+                :style="{ background: lv.color }"
+                :title="`${lv.label}: ${item[lv.key]}台`"
+              />
+              <span class="cockpit-region-rank__count">
+                优{{ item.excellent }}
+              </span>
+            </span>
+          </div>
+        </template>
+        <p v-else class="cockpit-region-rank__empty">暂无评估数据</p>
+      </div>
+
+      <!-- 异常排名 -->
+      <div v-show="activeTab === 'abnormal'" 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"
+          >
+            <span
+              class="cockpit-region-rank__badge"
+              :class="getBadgeClass(index)"
+            >{{ 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" />
+              {{ item.errCount }} 台
+            </span>
+          </div>
+        </template>
+        <p v-else class="cockpit-region-rank__empty">暂无异常数据</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import CockpitTechTabs from "./CockpitTechTabs.vue";
+import { getHealthscoresWindList } from "@/api/healthAnalyse";
+import { getAnomalyOverview } from "@/api/anomalyAnalyse";
+import {
+  collectFieldCodes,
+  buildFieldNameMap,
+  buildFieldCodeMap,
+  mapHealthscoresWindList,
+  aggregateRegionHealth,
+  aggregateRegionAbnormal,
+} from "./cockpitHealthMapper";
+import {
+  normalizeAnomalyOverviewList,
+  mapCockpitAbnormalOverview,
+} from "./cockpitAbnormalMapper";
+
+export default {
+  name: "CockpitRegionRankPanel",
+  components: { CockpitTechTabs },
+  props: {
+    /** 当前选中节点(含 children 的根/集团节点) */
+    rootNode: { type: Object, default: () => ({}) },
+    parentOpt: { type: Array, default: () => [] },
+    datatime: { type: String, default: "" },
+  },
+  data() {
+    return {
+      activeTab: "health",
+      rankTabs: [
+        { label: "健康排名", value: "health" },
+        { label: "异常排名", value: "abnormal" },
+      ],
+      loading: false,
+      healthRanks: [],
+      abnormalRanks: [],
+      levelBars: [
+        { key: "excellent", label: "优", color: "#7ECF51" },
+        { key: "good", label: "良", color: "#2979ff" },
+        { key: "medium", label: "中", color: "#EECB5F" },
+        { key: "poor", label: "差", color: "#E16757" },
+      ],
+    };
+  },
+  watch: {
+    rootNode: {
+      handler(val) {
+        if (val && val.codeNumber) {
+          this.fetchRankData();
+        }
+      },
+      immediate: true,
+      deep: false,
+    },
+    datatime() {
+      if (this.rootNode && this.rootNode.codeNumber) {
+        this.fetchRankData();
+      }
+    },
+  },
+  methods: {
+    getBadgeClass(index) {
+      if (index === 0) return "is-gold";
+      if (index === 1) return "is-silver";
+      if (index === 2) return "is-bronze";
+      return "";
+    },
+    async fetchRankData() {
+      const node = this.rootNode;
+      if (!node || !node.children || !node.children.length) return;
+
+      this.loading = true;
+      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),
+        ]);
+
+        // 拼装成带 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();
+
+        this.healthRanks = aggregateRegionHealth(node, allHealthRows);
+        this.abnormalRanks = aggregateRegionAbnormal(node, allAbnormalRows);
+      } catch (e) {
+        console.error("加载区域排名失败:", e);
+      } finally {
+        this.loading = false;
+      }
+    },
+  },
+};
+</script>
+
+<style lang="scss" scoped>
+.cockpit-region-rank {
+  width: 100%;
+}
+
+.cockpit-region-rank__body {
+  margin-top: 4px;
+  min-height: 80px;
+}
+
+.cockpit-region-rank__pane {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  padding: 4px 0;
+}
+
+.cockpit-region-rank__loading {
+  height: 80px;
+}
+
+.cockpit-region-rank__empty {
+  margin: 0;
+  padding: 16px 0;
+  text-align: center;
+  font-size: 12px;
+  color: var(--cockpit-panel-muted, #909399);
+}
+
+.cockpit-region-rank__row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+  line-height: 28px;
+  color: var(--cockpit-panel-text, #303133);
+}
+
+.cockpit-region-rank__badge {
+  flex-shrink: 0;
+  width: 20px;
+  height: 20px;
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 11px;
+  font-weight: 700;
+  background: var(--cockpit-panel-muted, #c0c4cc);
+  color: #fff;
+
+  &.is-gold {
+    background: #f5a623;
+  }
+  &.is-silver {
+    background: #8c9eb5;
+  }
+  &.is-bronze {
+    background: #b87333;
+  }
+}
+
+.cockpit-region-rank__name {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 12px;
+}
+
+.cockpit-region-rank__bars {
+  flex-shrink: 0;
+  display: flex;
+  align-items: center;
+  gap: 3px;
+}
+
+.cockpit-region-rank__dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 2px;
+  flex-shrink: 0;
+}
+
+.cockpit-region-rank__count {
+  font-size: 12px;
+  font-weight: 600;
+  color: #7ecf51;
+  margin-left: 2px;
+  min-width: 32px;
+  text-align: right;
+}
+
+.cockpit-region-rank__abnormal-count {
+  flex-shrink: 0;
+  font-size: 12px;
+  font-weight: 600;
+  color: var(--cockpit-status-danger, #e16757);
+  display: flex;
+  align-items: center;
+  gap: 3px;
+}
+
+.cockpit-region-rank__warn-icon {
+  font-size: 13px;
+}
+</style>

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

@@ -89,3 +89,146 @@ export function aggregateHealthScoreDistribution(rows) {
     itemStyle: { color: LEVEL_PIE_COLORS[name] },
   }));
 }
+
+/**
+ * 将多场站健康数据按直属省份/子公司节点聚合,返回区域排名列表
+ * @param {object} rootNode - 当前根节点(含 children)
+ * @param {Array} allRows   - 已展开的全部风场健康行(含 fieldCode)
+ * @returns {Array} [{ regionName, fieldCode, excellent, good, medium, poor, total }]
+ */
+export function aggregateRegionHealth(rootNode, allRows) {
+  if (!rootNode || !rootNode.children || !rootNode.children.length) return [];
+  const result = [];
+  rootNode.children.forEach((regionNode) => {
+    // 收集该区域下所有 field 的 codeNumber
+    const codes = new Set(collectFieldCodes(regionNode));
+    const regionRows = (allRows || []).filter((r) => codes.has(String(r.fieldCode)));
+    const excellent = regionRows.reduce((s, r) => s + toCount(r.excellent), 0);
+    const good = regionRows.reduce((s, r) => s + toCount(r.good), 0);
+    const medium = regionRows.reduce((s, r) => s + toCount(r.medium), 0);
+    const poor = regionRows.reduce((s, r) => s + toCount(r.poor), 0);
+    const total = excellent + good + medium + poor;
+    if (total === 0) return; // 无评估数据则跳过
+    result.push({
+      regionName: regionNode.companyName || regionNode.fieldName || regionNode.codeNumber,
+      codeNumber: regionNode.codeNumber,
+      excellent,
+      good,
+      medium,
+      poor,
+      total,
+    });
+  });
+  // 默认按优台数降序排列
+  result.sort((a, b) => b.excellent - a.excellent || b.good - a.good);
+  return result;
+}
+
+/**
+ * 将多场站异常数据按直属省份/子公司节点聚合,返回区域异常排名列表
+ * @param {object} rootNode   - 当前根节点
+ * @param {Array} allAbnormal - 已展开的全部风场异常行 [{ fieldCode, errCount }]
+ * @returns {Array} [{ regionName, codeNumber, errCount }]
+ */
+export function aggregateRegionAbnormal(rootNode, allAbnormal) {
+  if (!rootNode || !rootNode.children || !rootNode.children.length) return [];
+  const result = [];
+  rootNode.children.forEach((regionNode) => {
+    const codes = new Set(collectFieldCodes(regionNode));
+    const regionRows = (allAbnormal || []).filter((r) => codes.has(String(r.fieldCode)));
+    const errCount = regionRows.reduce((s, r) => {
+      const n = Number(String(r.errCount).replace(/[^0-9]/g, ""));
+      return s + (Number.isFinite(n) ? n : 0);
+    }, 0);
+    if (errCount === 0) return;
+    result.push({
+      regionName: regionNode.companyName || regionNode.fieldName || regionNode.codeNumber,
+      codeNumber: regionNode.codeNumber,
+      errCount,
+    });
+  });
+  // 默认按异常台数降序(最需关注的在前)
+  result.sort((a, b) => b.errCount - a.errCount);
+  return result;
+}
+
+/**
+ * 从树节点中收集指定范围内的所有 field 节点的 codeNumber 列表
+ * @param {object} node - 当前选中的树节点
+ * @returns {string[]}
+ */
+export function collectFieldCodes(node) {
+  if (!node) return [];
+  const codes = [];
+  function walk(n) {
+    if (!n) return;
+    if (n.codeType === "field" && n.codeNumber) {
+      codes.push(String(n.codeNumber));
+    }
+    if (n.children && n.children.length) {
+      n.children.forEach(walk);
+    }
+  }
+  walk(node);
+  return codes;
+}
+
+/**
+ * 过滤出有实际评估数据(有任一台风机被评估)的场站行
+ * 若 row 的 excellent + good + medium + poor 全为 0,则视为无数据
+ * @param {Array} rows
+ * @param {boolean} filterNoData - 是否过滤掉无数据场站
+ * @returns {Array}
+ */
+export function filterEvaluatedRows(rows, filterNoData = true) {
+  if (!filterNoData) return rows || [];
+  return (rows || []).filter((row) => {
+    return (
+      toCount(row.excellent) + toCount(row.good) +
+      toCount(row.medium) + toCount(row.poor) > 0
+    );
+  });
+}
+
+/**
+ * 按健康评分分布对场站行排序
+ * 排序优先级:优台数多的在前;同优则良多在前;同优良则中多在前
+ * @param {Array} rows
+ * @param {{ prop: string, order: 'ascending'|'descending' }} sort
+ * @returns {Array}
+ */
+export function sortHealthTableData(rows, sort) {
+  if (!rows || !rows.length) return rows || [];
+  const { prop, order } = sort || {};
+  const dir = order === "ascending" ? 1 : -1;
+  const PROP_MAP = {
+    excellent: "excellent",
+    good: "good",
+    medium: "medium",
+    poor: "poor",
+    fieldName: "fieldName",
+    total: "_total",
+  };
+  const key = PROP_MAP[prop] || null;
+  const sorted = [...rows];
+  sorted.sort((a, b) => {
+    if (key === "_total") {
+      const ta = toCount(a.excellent) + toCount(a.good) + toCount(a.medium) + toCount(a.poor);
+      const tb = toCount(b.excellent) + toCount(b.good) + toCount(b.medium) + toCount(b.poor);
+      return (ta - tb) * dir;
+    }
+    if (key === "fieldName") {
+      return (a.fieldName || "").localeCompare(b.fieldName || "", "zh") * dir;
+    }
+    if (key) {
+      return (toCount(a[key]) - toCount(b[key])) * dir;
+    }
+    // 默认:优多的排前面,依次良、中、差
+    const diffEx = toCount(b.excellent) - toCount(a.excellent);
+    if (diffEx !== 0) return diffEx;
+    const diffGo = toCount(b.good) - toCount(a.good);
+    if (diffGo !== 0) return diffGo;
+    return toCount(b.medium) - toCount(a.medium);
+  });
+  return sorted;
+}

+ 110 - 13
src/views/anomalyDetection/anomalyDetectionDetail.vue

@@ -27,6 +27,17 @@
           </button>
         </div>
         <div class="anomaly-detail-filters">
+          <div
+            v-if="displayFieldName || displayMachineType"
+            class="anomaly-detail-meta"
+          >
+            <span v-if="displayFieldName" class="anomaly-detail-meta__item">
+              风场名:{{ displayFieldName }}
+            </span>
+            <span v-if="displayMachineType" class="anomaly-detail-meta__item">
+              机型:{{ displayMachineType }}
+            </span>
+          </div>
           <label class="anomaly-detail-filter">
             风机:
             <el-select
@@ -91,15 +102,33 @@
             </div>
             <div class="anomaly-detail-toggle">
               <span
-                :class="{ active: trendRange[mod.key] === 7 }"
-                @click="setTrendRange(mod.key, 7)"
-                >近7天</span
+                v-for="preset in trendPresets"
+                :key="preset"
+                :class="{
+                  active:
+                    trendRange[mod.key] === preset &&
+                    !isCustomTrend(mod.key),
+                }"
+                @click="setTrendRange(mod.key, preset)"
               >
+                近{{ preset }}天
+              </span>
               <span
-                :class="{ active: trendRange[mod.key] === 30 }"
-                @click="setTrendRange(mod.key, 30)"
-                >近30天</span
+                :class="{ active: isCustomTrend(mod.key) }"
+                @click="openCustomTrend(mod.key)"
               >
+                自定义
+              </span>
+              <el-input-number
+                v-if="isCustomTrend(mod.key)"
+                :value="trendRange[mod.key]"
+                :min="1"
+                :max="MAX_TREND_DAYS"
+                size="mini"
+                controls-position="right"
+                class="anomaly-detail-trend-custom"
+                @change="(val) => setCustomTrendRange(mod.key, val)"
+              />
             </div>
           </div>
           <div
@@ -180,6 +209,9 @@ const MODULE_DETAIL_TABS = [
   },
 ];
 
+const TREND_PRESETS = [7, 30, 90];
+const MAX_TREND_DAYS = 90;
+
 function emptyModuleData() {
   return {
     items: [],
@@ -195,8 +227,10 @@ export default {
       activeTab: "all",
       fieldCode: "",
       fieldId: "",
+      fieldName: "",
       engineId: "",
       engineName: "",
+      machineType: "",
       datatime: "",
       moduleTabs: [
         { key: "all", label: "异常趋势总览" },
@@ -211,6 +245,12 @@ export default {
         acc[mod.key] = 7;
         return acc;
       }, {}),
+      trendCustom: MODULE_DETAIL_TABS.reduce((acc, mod) => {
+        acc[mod.key] = false;
+        return acc;
+      }, {}),
+      trendPresets: TREND_PRESETS,
+      MAX_TREND_DAYS,
       engineOptions: [],
       windCards: [],
       trendCache: {},
@@ -218,6 +258,27 @@ export default {
       overviewCharts: {},
     };
   },
+  computed: {
+    selectedWindCard() {
+      return (
+        this.windCards.find((card) => card.engineId === this.engineId) || null
+      );
+    },
+    displayFieldName() {
+      return (
+        this.selectedWindCard?.fieldName ||
+        this.fieldName ||
+        ""
+      );
+    },
+    displayMachineType() {
+      return (
+        this.selectedWindCard?.machineType ||
+        this.machineType ||
+        ""
+      );
+    },
+  },
   created() {
     this.initFromRoute();
     this.bootstrap();
@@ -237,8 +298,10 @@ export default {
       const q = this.$route.query || {};
       this.fieldCode = q.fieldCode || "";
       this.fieldId = q.fieldId || q.fieldCode || "";
+      this.fieldName = q.fieldName || "";
       this.engineId = q.engineId || "";
       this.engineName = q.engineName || "";
+      this.machineType = q.machineType || "";
       this.datatime = q.datatime || "";
       if (q.tab === "all") {
         this.activeTab = "all";
@@ -289,6 +352,9 @@ export default {
         if (!this.fieldId && windCards[0]?.fieldId) {
           this.fieldId = windCards[0].fieldId;
         }
+        const selected = this.selectedWindCard;
+        if (selected?.fieldName) this.fieldName = selected.fieldName;
+        if (selected?.machineType) this.machineType = selected.machineType;
       } catch (error) {
         console.error("加载风机下拉列表失败:", error);
         this.engineOptions = this.engineId
@@ -309,13 +375,37 @@ export default {
       this.trendCache = {};
     },
     getSelectedWindCard() {
-      return (
-        this.windCards.find((card) => card.engineId === this.engineId) || null
-      );
+      return this.selectedWindCard;
     },
     getTrendCacheKey(moduleKey, range) {
       return `${moduleKey}:${range}`;
     },
+    normalizeTrendDays(days) {
+      const num = Number(days);
+      if (!Number.isFinite(num)) return 7;
+      return Math.min(MAX_TREND_DAYS, Math.max(1, Math.round(num)));
+    },
+    isCustomTrend(moduleKey) {
+      return !!this.trendCustom[moduleKey];
+    },
+    openCustomTrend(moduleKey) {
+      this.$set(this.trendCustom, moduleKey, true);
+      if (TREND_PRESETS.includes(this.trendRange[moduleKey])) {
+        this.$set(this.trendRange, moduleKey, this.trendRange[moduleKey]);
+      }
+    },
+    async setCustomTrendRange(moduleKey, days) {
+      const range = this.normalizeTrendDays(days);
+      this.$set(this.trendCustom, moduleKey, true);
+      if (this.trendRange[moduleKey] === range) {
+        await this.fetchOverviewTrend(moduleKey, range);
+        this.renderOverviewChart(moduleKey);
+        return;
+      }
+      this.$set(this.trendRange, moduleKey, range);
+      await this.fetchOverviewTrend(moduleKey, range);
+      this.renderOverviewChart(moduleKey);
+    },
     async fetchOverviewTrend(moduleKey, range) {
       const cacheKey = this.getTrendCacheKey(moduleKey, range);
       if (this.trendCache[cacheKey]) {
@@ -353,7 +443,6 @@ export default {
     renderPieChart() {
       if (!this.pieChart) return;
       const pieData = mapWindCardToPieData(this.getSelectedWindCard());
-      console.log(pieData, "pieData");
       this.pieChart.setOption(buildPieOption(pieData), true);
     },
     applyMockModuleData() {
@@ -424,12 +513,20 @@ export default {
       });
     },
     async setTrendRange(moduleKey, range) {
-      if (this.trendRange[moduleKey] === range) return;
-      this.trendRange[moduleKey] = range;
-      await this.fetchOverviewTrend(moduleKey, range);
+      const days = this.normalizeTrendDays(range);
+      this.$set(this.trendCustom, moduleKey, false);
+      if (this.trendRange[moduleKey] === days) {
+        this.renderOverviewChart(moduleKey);
+        return;
+      }
+      this.$set(this.trendRange, moduleKey, days);
+      await this.fetchOverviewTrend(moduleKey, days);
       this.renderOverviewChart(moduleKey);
     },
     handleEngineChange() {
+      const selected = this.selectedWindCard;
+      if (selected?.fieldName) this.fieldName = selected.fieldName;
+      if (selected?.machineType) this.machineType = selected.machineType;
       this.fetchAll();
     },
     async handleDatatimeChange() {

+ 14 - 1
src/views/anomalyDetection/components/AnomalyWindCard.vue

@@ -4,7 +4,20 @@
     :data-engine-id="card.engineId || ''"
   >
     <div class="anomaly-wind-card__header">
-      <div class="anomaly-wind-id">{{ card.name }}</div>
+      <div class="anomaly-wind-card__title-wrap">
+        <div class="anomaly-wind-id">{{ card.name }}</div>
+        <div
+          v-if="card.fieldName || card.machineType"
+          class="anomaly-wind-card__meta"
+        >
+          <span v-if="card.fieldName" class="anomaly-wind-card__meta-item">
+            风场:{{ card.fieldName }}
+          </span>
+          <span v-if="card.machineType" class="anomaly-wind-card__meta-item">
+            机型:{{ card.machineType }}
+          </span>
+        </div>
+      </div>
       <button
         type="button"
         class="anomaly-wind-detail__btn"

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

@@ -277,6 +277,12 @@ export default {
           fieldId: card.raw?.fieldId || card.fieldId || this.companyCode,
           engineId: card.engineId,
           engineName: card.name,
+          fieldName:
+            card.fieldName ||
+            this.selectedField?.fieldName ||
+            this.selectedField?.companyName ||
+            "",
+          machineType: card.machineType || "",
           datatime: this.datatime,
         },
       });

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

@@ -405,6 +405,28 @@
     margin-bottom: 10px;
   }
 
+  .anomaly-wind-card__title-wrap {
+    min-width: 0;
+    flex: 1;
+  }
+
+  .anomaly-wind-card__meta {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 4px 10px;
+    margin-top: 6px;
+  }
+
+  .anomaly-wind-card__meta-item {
+    font-size: 11px;
+    line-height: 1.3;
+    color: var(--anomaly-muted, #8da3c7);
+    max-width: 100%;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
   .anomaly-wind-card__modules {
     display: flex;
     flex-direction: column;

+ 48 - 0
src/views/anomalyDetection/styles/anomaly-detail.scss

@@ -57,6 +57,24 @@
   margin-left: auto;
 }
 
+.anomaly-detail-meta {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+  font-size: 12px;
+}
+
+.anomaly-detail-meta__item {
+  display: inline-flex;
+  align-items: center;
+  max-width: 180px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  color: var(--anomaly-muted, #606266);
+}
+
 .anomaly-detail-filter {
   font-size: 12px;
   display: flex;
@@ -334,6 +352,12 @@
   border: 1px solid transparent;
 }
 
+.anomaly-detail-trend-custom {
+  width: 100px;
+  margin-left: 6px;
+  vertical-align: middle;
+}
+
 .anomaly-detail-chart-box {
   height: 310px;
   width: 100%;
@@ -522,6 +546,18 @@ html:not([data-theme="tech-blue"]) {
       background: var(--bgopacity-color, rgba(0, 128, 128, 0.08));
     }
   }
+
+  .anomaly-detail-meta__item {
+    color: var(--anomaly-muted, #606266);
+  }
+
+  .anomaly-detail-trend-custom {
+    .el-input__inner {
+      background: var(--anomaly-input-bg, #ffffff);
+      color: var(--anomaly-text, #303133);
+      border-color: var(--anomaly-card-border, #dcdfe6);
+    }
+  }
 }
 
 /* 科技蓝大屏 */
@@ -721,4 +757,16 @@ html[data-theme="tech-blue"] {
       box-shadow: 0 0 8px rgba(0, 242, 255, 0.4);
     }
   }
+
+  .anomaly-detail-meta__item {
+    color: rgba(183, 223, 245, 0.78);
+  }
+
+  .anomaly-detail-trend-custom {
+    .el-input__inner {
+      background: rgba(0, 16, 32, 0.92);
+      color: #c8e8f8;
+      border-color: rgba(0, 140, 210, 0.45);
+    }
+  }
 }

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

@@ -121,6 +121,8 @@ export function buildWatchlistUnits(modelList, engineNameMap = {}) {
       engineId: item.engineId,
       fieldId: item.fieldId,
       name: formatEngineName(item, engineNameMap),
+      fieldName: resolveFieldName(item),
+      machineType: resolveMachineType(item),
       raw: item,
     }))
     .sort((a, b) => compareEngineNames(a.name, b.name));
@@ -319,11 +321,33 @@ function buildEngineNameMap(modelList = []) {
   return map;
 }
 
+function resolveFieldName(item) {
+  return (
+    item?.fieldName ||
+    item?.windFarmName ||
+    item?.farmName ||
+    ""
+  );
+}
+
+function resolveMachineType(item) {
+  return (
+    item?.machineTypeCode ||
+    item?.engineTypeName ||
+    item?.engineTypeCode ||
+    item?.modelName ||
+    item?.millTypeCode ||
+    ""
+  );
+}
+
 function mapModelListToWindCards(modelList, engineNameMap = {}) {
   return (modelList || []).map((item) => ({
     engineId: item.engineId,
     fieldId: item.fieldId,
     name: formatEngineName(item, engineNameMap),
+    fieldName: resolveFieldName(item),
+    machineType: resolveMachineType(item),
     summary: {
       windPower: toAnomalyPercent(item.model1WindpwrAnomalyRate),
       yaw: toAnomalyPercent(item.model2YawAnomalyRate),