فهرست منبع

联调异常检测接口

liujiejie 2 ماه پیش
والد
کامیت
75d985c3c1
40فایلهای تغییر یافته به همراه1977 افزوده شده و 677 حذف شده
  1. 1 0
      .env.dev
  2. 7 2
      .env.dt
  3. 11 0
      src/api/cockpitEnos.js
  4. 15 0
      src/api/wakeAnalyse.js
  5. 43 4
      src/components/selecttree.vue
  6. 1 0
      src/store/auth.js
  7. 28 2
      src/views/admin/cockpitManage/Index.vue
  8. 112 0
      src/views/admin/cockpitManage/cockpit-dashboard.scss
  9. 75 43
      src/views/admin/cockpitManage/component/CockpitAbnormalPanel.vue
  10. 11 1
      src/views/admin/cockpitManage/component/CockpitHealthPanel.vue
  11. 86 0
      src/views/admin/cockpitManage/component/cockpitAbnormalMapper.js
  12. 8 8
      src/views/admin/cockpitManage/component/leftdata.vue
  13. 4 3
      src/views/anomalyDetection/anomalyDetectionDetail.vue
  14. 18 7
      src/views/anomalyDetection/components/AnomalyDetailModulePanel.vue
  15. 9 1
      src/views/anomalyDetection/components/AnomalyDetectorPlotCarousel.vue
  16. 40 32
      src/views/anomalyDetection/components/AnomalyWindCard.vue
  17. 52 19
      src/views/anomalyDetection/styles/anomaly-dashboard.scss
  18. 39 1
      src/views/anomalyDetection/utils/anomalyDashboardMapper.js
  19. 14 5
      src/views/anomalyDetection/utils/anomalyDetailChartOptions.js
  20. 56 18
      src/views/anomalyDetection/utils/anomalyDetailMapper.js
  21. 7 6
      src/views/anomalyDetection/utils/anomalyDetectorPlotRegistry.js
  22. 16 3
      src/views/anomalyDetection/utils/anomalyMockData.js
  23. 22 1
      src/views/anomalyDetection/utils/anomalyRemoteData.js
  24. 8 3
      src/views/health/components/health/HealthScorePanel.vue
  25. 1 1
      src/views/health/components/health/HealthSubsystemPanel.vue
  26. 16 5
      src/views/windResourceWake/components/WrwFilterBar.vue
  27. 67 15
      src/views/windResourceWake/components/WrwOverviewTab.vue
  28. 89 11
      src/views/windResourceWake/components/WrwWakeTab.vue
  29. 99 85
      src/views/windResourceWake/components/WrwWakeTable.vue
  30. 0 131
      src/views/windResourceWake/data/mockData.js
  31. BIN
      src/views/windResourceWake/images/image(3).png
  32. 75 8
      src/views/windResourceWake/index.vue
  33. 43 2
      src/views/windResourceWake/styles/windResourceWake.scss
  34. 72 98
      src/views/windResourceWake/utils/overviewChartOptions.js
  35. 311 162
      src/views/windResourceWake/utils/wakeChartOptions.js
  36. 51 0
      src/views/windResourceWake/utils/wrwRemoteData.js
  37. 46 0
      src/views/windResourceWake/utils/wrwTableUtils.js
  38. 412 0
      src/views/windResourceWake/utils/wrwWakeMapper.js
  39. 0 0
      swagger.json
  40. 12 0
      vue.config.js

+ 1 - 0
.env.dev

@@ -43,6 +43,7 @@ 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'

+ 7 - 2
.env.dt

@@ -1,7 +1,7 @@
 ###
  # @Author: your name
  # @Date: 2026-03-06 11:19:04
- # @LastEditTime: 2026-06-18 14:19:13
+ # @LastEditTime: 2026-06-25 10:56:25
  # @LastEditors: milo-MacBook-Pro.local
  # @Description: In User Settings Edit
  # @FilePath: /performance-test/.env.dt
@@ -13,12 +13,17 @@ VUE_APP_ISSHOWHD='default'
 VUE_APP_Helath='dev'
 VUE_APP_TITLE='机组功率曲线异常检测数据分析系统'
 VUE_APP_PROJECT='dt'
-#外网
+# 本地开发页面 IP/端口(改后需重启 npm run serve:dt)
+# 0.0.0.0:可用本机局域网 IP 访问;127.0.0.1:仅本机
+# 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_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'
+# VUE_APP_DatangEnosData_APIPROXY='http://106.120.102.238:8899'
 # 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'

+ 11 - 0
src/api/cockpitEnos.js

@@ -0,0 +1,11 @@
+import axios from "axios";
+
+const PREFIX = "/DatangEnosData/DatangEnosData/windTurbine";
+
+/**
+ * 查询平台接入风机总台数(Enos)
+ * 响应:{ code: 200, datas: number }
+ */
+export function queryWindTurbineCount() {
+  return axios.get(`${PREFIX}/queryWindTurbineCount`);
+}

+ 15 - 0
src/api/wakeAnalyse.js

@@ -0,0 +1,15 @@
+import healthRequest from "@/utils/healthRequest";
+
+const PREFIX = "/energy-manage-analyse-service/wakewindfarm";
+
+/**
+ * 风场湍流尾流数据
+ * @param {{ fieldCode: string, datatime?: string }} params
+ */
+export function getWakeWindFarm(params) {
+  return healthRequest({
+    url: `${PREFIX}/getWakeWindFarm`,
+    method: "post",
+    params,
+  });
+}

+ 43 - 4
src/components/selecttree.vue

@@ -8,6 +8,7 @@
       :filterable="filterable"
       :filter-method="filterTree"
       :popper-class="popperClassName"
+      @visible-change="handleVisibleChange"
       @change="$forceUpdate()"
       @clear="clear"
       popper-append-to-body
@@ -168,19 +169,57 @@ export default {
     },
     filterNode(value, data) {
       if (!value) return true;
-      const labelKey = this.defaultParentProps.label || "label";
       const keyword = String(value).trim().toLowerCase();
       if (!keyword) return true;
-      const label = String(data[labelKey] || "").toLowerCase();
-      return label.includes(keyword);
+      return this.nodeMatchesKeyword(data, keyword);
+    },
+    nodeMatchesKeyword(data, keyword) {
+      if (!data) return false;
+      const labelKey = this.defaultParentProps.label || "label";
+      const childrenKey = this.defaultParentProps.children || "children";
+      const searchKeys = [
+        labelKey,
+        "companyName",
+        "fieldName",
+        "codeNumber",
+        "fieldCode",
+        "companyCode",
+      ];
+      const selfMatch = searchKeys.some((key) =>
+        String(data[key] || "")
+          .toLowerCase()
+          .includes(keyword),
+      );
+      if (selfMatch) return true;
+      const children = data[childrenKey] || [];
+      return children.some((child) => this.nodeMatchesKeyword(child, keyword));
     },
     filterTree(value) {
+      const keyword = value == null ? "" : String(value).trim();
+      // 已选中的回显文案不应触发树筛选,否则下拉只显示当前 1 条
+      if (keyword && keyword === String(this.label || "").trim()) {
+        this.resetTreeFilter();
+        return;
+      }
       this.$nextTick(() => {
         if (this.$refs.tree) {
-          this.$refs.tree.filter(value);
+          this.$refs.tree.filter(keyword);
         }
       });
     },
+    handleVisibleChange(visible) {
+      if (!this.filterable) return;
+      if (visible) {
+        this.$nextTick(() => {
+          if (this.$refs.elSelect) {
+            this.$refs.elSelect.query = "";
+          }
+          this.resetTreeFilter();
+        });
+        return;
+      }
+      this.resetTreeFilter();
+    },
     //递归筛选,回显label
     findLabel(arr, value) {
       const matchKeys = [

+ 1 - 0
src/store/auth.js

@@ -72,6 +72,7 @@ export default {
         "/health",
         "/WZLapi",
         "/ETLapi",
+        "/DatangEnosData",
         "/AnalysisMulti",
         "/transDataWeb",
         "/sAlgorithm",

+ 28 - 2
src/views/admin/cockpitManage/Index.vue

@@ -17,6 +17,8 @@
             placeholder="请选择所属公司"
             :list="parentOpt"
             type="1"
+            filterable
+            popper-class="cockpit-stats-tree-popper"
             v-model="companyCode"
             @change="parentChange"
             :defaultParentProps="{
@@ -25,6 +27,19 @@
               value: 'codeNumber',
             }"
           />
+          <div class="cockpit-stats__date-row">
+            <el-date-picker
+              v-model="datatime"
+              type="date"
+              size="small"
+              placeholder="选择日期"
+              format="yyyy/MM/dd"
+              value-format="yyyy-MM-dd"
+              class="cockpit-stats__date"
+              clearable
+              popper-class="cockpit-stats-date-popper"
+            />
+          </div>
           <p class="cockpit-stats__row">
             <span class="SpText">已接入风场数:</span>
             <span>{{ totalList?.fieldSumNumber || 0 }} 座</span>
@@ -73,6 +88,7 @@
             :maplistArr="maplistArr"
             :defaultdata="defaultdata"
             :parent-opt="parentOpt"
+            :datatime="datatime"
           />
         </section>
 
@@ -100,6 +116,15 @@ import Rightdata from "./component/rightdata.vue";
 import Leftdata from "./component/leftdata.vue";
 import CockpitHealthPanel from "./component/CockpitHealthPanel.vue";
 
+function getYesterdayString() {
+  const date = new Date();
+  date.setDate(date.getDate() - 1);
+  const y = date.getFullYear();
+  const m = String(date.getMonth() + 1).padStart(2, "0");
+  const d = String(date.getDate()).padStart(2, "0");
+  return `${y}-${m}-${d}`;
+}
+
 export default {
   name: "Index",
   components: {
@@ -119,6 +144,7 @@ export default {
       parentOpt: [],
       companyCode: "",
       defaultdata: {},
+      datatime: getYesterdayString(),
     };
   },
   created() {
@@ -145,10 +171,10 @@ export default {
     },
     getDefaultTreeNode(treeData) {
       const root = treeData && treeData[0];
-      if (!root || !root.children || !root.children.length) {
+      if (!root || !root.codeNumber) {
         return null;
       }
-      return root.children[0];
+      return root;
     },
     async GETtree() {
       const res = await getSysOrganizationAuthTreeByRoleId();

+ 112 - 0
src/views/admin/cockpitManage/cockpit-dashboard.scss

@@ -158,6 +158,19 @@
       }
     }
 
+    .cockpit-stats__date-row {
+      margin: 6px 0 4px;
+      width: 100%;
+    }
+
+    .cockpit-stats__date {
+      width: 100%;
+    }
+
+    .cockpit-stats__date.el-date-editor.el-input {
+      width: 100%;
+    }
+
     .cockpit-stats__switch {
       margin-top: 6px;
       display: flex;
@@ -1106,6 +1119,40 @@ html[data-theme="tech-blue"] .cockpit-dashboard-page {
         0 0 12px rgba(0, 122, 210, 0.08);
     }
 
+    .cockpit-stats__date-row {
+      margin: 6px 0 4px;
+      width: 100%;
+    }
+
+    .cockpit-stats__date.el-date-editor {
+      width: 100%;
+    }
+
+    .cockpit-stats__date .el-input__prefix {
+      display: none !important;
+    }
+
+    .cockpit-stats__date.el-input--prefix .el-input__inner {
+      padding-left: 10px !important;
+    }
+
+    .cockpit-stats__date .el-input__inner {
+      height: 34px;
+      line-height: 34px;
+      padding-right: 28px;
+      font-weight: 600;
+      color: #fff !important;
+      background: var(--cockpit-input-bg) !important;
+      border-color: var(--cockpit-input-border) !important;
+      box-shadow: inset 0 0 0 1px rgba(0, 150, 220, 0.18),
+        0 0 12px rgba(0, 122, 210, 0.08);
+    }
+
+    .cockpit-stats__date .el-input__icon {
+      color: var(--cockpit-kpi-cyan, #4bffff) !important;
+      line-height: 34px;
+    }
+
     .el-switch__label {
       color: var(--cockpit-panel-muted);
       font-size: 12px;
@@ -1973,3 +2020,68 @@ html:not([data-theme="tech-blue"]) .cockpit-dashboard-page {
   width: min(1340px, 96vw) !important;
   margin-top: 6vh !important;
 }
+
+.cockpit-stats-date-popper {
+  background: var(--cockpit-panel-bg, #0d1930) !important;
+  border: 1px solid var(--cockpit-input-border, rgba(0, 242, 255, 0.25)) !important;
+  color: var(--cockpit-panel-text, #b7dff5) !important;
+
+  .el-date-picker__header-label,
+  .el-date-table th,
+  .el-date-table td span {
+    color: var(--cockpit-panel-text, #b7dff5) !important;
+  }
+
+  .el-date-table td.available:hover span {
+    color: var(--cockpit-kpi-cyan, #00f2ff) !important;
+  }
+
+  .el-date-table td.current:not(.disabled) span {
+    background: var(--cockpit-accent, #1890ff) !important;
+    color: #fff !important;
+  }
+}
+
+.cockpit-stats-tree-popper.selecttree-popper {
+  background: var(--cockpit-panel-bg, #0d1930) !important;
+  border: 1px solid var(--cockpit-input-border, rgba(0, 242, 255, 0.25)) !important;
+
+  .popper__arrow::after {
+    border-bottom-color: var(--cockpit-panel-bg, #0d1930) !important;
+  }
+
+  .el-select-dropdown__item.hasTree {
+    background: var(--cockpit-panel-bg, #0d1930) !important;
+
+    &.hover,
+    &:hover,
+    &.selected {
+      background: var(--cockpit-panel-bg, #0d1930) !important;
+    }
+  }
+
+  .el-tree {
+    background: transparent !important;
+    color: var(--cockpit-panel-text, #b7dff5) !important;
+  }
+
+  .el-tree-node__label {
+    color: var(--cockpit-panel-text, #b7dff5) !important;
+  }
+
+  .el-tree-node__content:hover {
+    background: rgba(0, 170, 255, 0.15) !important;
+  }
+
+  .el-tree-node.is-current > .el-tree-node__content,
+  .el-tree--highlight-current
+    .el-tree-node.is-current
+    > .el-tree-node__content {
+    background: var(--cockpit-accent, #1890ff) !important;
+    color: #ffffff !important;
+  }
+
+  .el-tree-node.is-current > .el-tree-node__content .el-tree-node__label {
+    color: #ffffff !important;
+  }
+}

+ 75 - 43
src/views/admin/cockpitManage/component/CockpitAbnormalPanel.vue

@@ -9,9 +9,9 @@
         <span class="cockpit-abnormal-kpi__corner is-bl" aria-hidden="true" />
         <span class="cockpit-abnormal-kpi__corner is-br" aria-hidden="true" />
         <strong class="cockpit-abnormal-kpi__value">
-          {{ normalTotal }}<span class="cockpit-abnormal-kpi__unit"></span>
+          {{ normalTotal }}<span class="cockpit-abnormal-kpi__unit"></span>
         </strong>
-        <span class="cockpit-abnormal-kpi__label">正常总数</span>
+        <span class="cockpit-abnormal-kpi__label">接入台数</span>
       </div>
       <div
         class="cockpit-abnormal-kpi__card cockpit-abnormal-kpi__card--danger"
@@ -21,9 +21,9 @@
         <span class="cockpit-abnormal-kpi__corner is-bl" aria-hidden="true" />
         <span class="cockpit-abnormal-kpi__corner is-br" aria-hidden="true" />
         <strong class="cockpit-abnormal-kpi__value">
-          {{ abnormalTotal }}<span class="cockpit-abnormal-kpi__unit"></span>
+          {{ abnormalTotal }}<span class="cockpit-abnormal-kpi__unit"></span>
         </strong>
-        <span class="cockpit-abnormal-kpi__label">异常数</span>
+        <span class="cockpit-abnormal-kpi__label">异常数</span>
       </div>
     </div>
 
@@ -51,9 +51,15 @@
 </template>
 
 <script>
-import { queryStatistics } from "@/api/ledger.js";
+import { getAnomalyOverview } from "@/api/anomalyAnalyse";
+import { queryWindTurbineCount } from "@/api/cockpitEnos";
 import Tabroll from "./tabroll.vue";
 import { COCKPIT_TABLE_BODY_HEIGHT } from "./cockpitTableMetrics";
+import {
+  buildFieldNameMap,
+  mapCockpitAbnormalOverview,
+  normalizeAnomalyOverviewList,
+} from "./cockpitAbnormalMapper";
 
 export default {
   name: "CockpitAbnormalPanel",
@@ -61,12 +67,16 @@ export default {
   props: {
     maplistArr: { type: Object, default: () => ({}) },
     defaultdata: { type: Object, default: () => ({}) },
+    datatime: { type: String, default: "" },
+    parentOpt: { type: Array, default: () => [] },
   },
   data() {
     return {
       loading: false,
-      equipment: {},
+      abnormalTotal: 0,
+      normalTotal: 0,
       tableData: [],
+      lastOverviewList: [],
       tableColumns: [{ prop: "fieldName" }, { prop: "errCount" }],
       columnWidths: {
         fieldName: "50%",
@@ -75,56 +85,78 @@ export default {
       tableBodyHeight: COCKPIT_TABLE_BODY_HEIGHT,
     };
   },
-  computed: {
-    abnormalTotal() {
-      const e = this.equipment;
-      return e.errDeviceCount ?? e.errCounrt ?? e.errCount ?? 0;
-    },
-    normalTotal() {
-      const e = this.equipment;
-      if (e.normalCount != null) return e.normalCount;
-      if (e.engineNormalCount != null) return e.engineNormalCount;
-      if (e.totalCount != null && this.abnormalTotal != null) {
-        return Math.max(0, Number(e.totalCount) - Number(this.abnormalTotal));
-      }
-      return 0;
-    },
+  mounted() {
+    this.fetchConnectedTotal();
   },
   watch: {
     maplistArr: {
       handler(val) {
         if (val && Object.keys(val).length > 0) {
-          this.fetchStatistics();
+          this.fetchOverview();
         }
       },
       immediate: true,
       deep: true,
     },
+    datatime() {
+      this.fetchOverview();
+    },
+    parentOpt: {
+      handler() {
+        if (this.lastOverviewList.length) {
+          this.applyOverviewList(this.lastOverviewList);
+        }
+      },
+      deep: true,
+    },
   },
   methods: {
-    fetchStatistics() {
-      if (!this.maplistArr?.codeNumber) return;
+    buildRequestPayload() {
+      const fieldCode =
+        this.maplistArr?.codeNumber || this.maplistArr?.fieldCode;
+      if (!fieldCode) return null;
+      const payload = { fieldCode };
+      if (this.datatime) {
+        payload.datatime = this.datatime;
+      }
+      return payload;
+    },
+    applyOverviewList(list) {
+      const fieldNameMap = buildFieldNameMap(this.parentOpt);
+      const mapped = mapCockpitAbnormalOverview(list, fieldNameMap);
+      this.abnormalTotal = mapped.abnormalTotal;
+      this.tableData = mapped.tableData;
+    },
+    async fetchConnectedTotal() {
+      try {
+        const res = await queryWindTurbineCount();
+        const body = res?.data;
+        if (body && Number(body.code) === 200) {
+          const count = Number(body.datas);
+          this.normalTotal = Number.isFinite(count) && count >= 0 ? count : 0;
+        }
+      } catch (error) {
+        console.error("加载接入台数失败:", error);
+      }
+    },
+    async fetchOverview() {
+      const payload = this.buildRequestPayload();
+      if (!payload) return;
+
       this.loading = true;
-      const end = new Date();
-      const start = new Date();
-      start.setMonth(start.getMonth() - 6);
-      queryStatistics({
-        codeNumber: this.maplistArr.codeNumber,
-        codeType: this.maplistArr.codeType,
-        startTime: this.$formatDate(start),
-        endTime: this.$formatDate(end),
-      })
-        .then((res) => {
-          const data = res.data || {};
-          this.equipment = data;
-          this.tableData = (data.errList || []).map((row) => ({
-            fieldName: row.fieldName ?? row.windFarmName ?? "—",
-            errCount: `${row.errCount ?? row.abnormalEngineCount ?? row.engineErrCount ?? 0}台`,
-          }));
-        })
-        .finally(() => {
-          this.loading = false;
-        });
+      try {
+        const res = await getAnomalyOverview(payload);
+        const list = normalizeAnomalyOverviewList(res?.data);
+        this.lastOverviewList = list;
+        this.applyOverviewList(list);
+      } catch (error) {
+        console.error("加载异常概览失败:", error);
+        this.lastOverviewList = [];
+        this.abnormalTotal = 0;
+        this.tableData = [];
+      } finally {
+        this.loading = false;
+      }
     },
   },
 };

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

@@ -40,6 +40,8 @@
         <CockpitAbnormalPanel
           :maplistArr="maplistArr"
           :defaultdata="defaultdata"
+          :datatime="datatime"
+          :parent-opt="parentOpt"
         />
       </div>
     </div>
@@ -65,6 +67,7 @@ export default {
     maplistArr: { type: Object, default: () => ({}) },
     defaultdata: { type: Object, default: () => ({}) },
     parentOpt: { type: Array, default: () => [] },
+    datatime: { type: String, default: "" },
   },
   data() {
     return {
@@ -94,6 +97,9 @@ export default {
       deep: true,
       immediate: true,
     },
+    datatime() {
+      this.fetchHealthData();
+    },
     parentOpt: {
       handler() {
         if (this.tableData.length) {
@@ -213,7 +219,11 @@ export default {
       if (!fieldCode) {
         return null;
       }
-      return { fieldCode };
+      const params = { fieldCode };
+      if (this.datatime) {
+        params.datatime = this.datatime;
+      }
+      return params;
     },
     async fetchHealthData() {
       const params = this.buildHealthRequestParams();

+ 86 - 0
src/views/admin/cockpitManage/component/cockpitAbnormalMapper.js

@@ -0,0 +1,86 @@
+import { buildFieldNameMap } from "./cockpitHealthMapper";
+
+function toCount(value) {
+  const num = Number(value);
+  if (!Number.isFinite(num) || num < 0) return 0;
+  return Math.round(num);
+}
+
+export function normalizeAnomalyOverviewList(data) {
+  if (!data) return [];
+  if (Array.isArray(data)) return data;
+  if (Array.isArray(data?.list)) return data.list;
+  if (Array.isArray(data?.records)) return data.records;
+  if (typeof data === "object") {
+    if (
+      data.fieldName ||
+      data.fieldId ||
+      data.totalAnomalyCount != null ||
+      data.sensorAnomalyCount != null ||
+      data.detectorAnomalyCount != null
+    ) {
+      return [data];
+    }
+  }
+  return [];
+}
+
+function resolveFieldName(item, fieldNameMap) {
+  const fieldKey = item?.fieldId || item?.fieldCode || "";
+  return (
+    item?.fieldName ||
+    item?.windFarmName ||
+    fieldNameMap[fieldKey] ||
+    fieldKey ||
+    ""
+  );
+}
+
+export function mapCockpitAbnormalOverview(list, fieldNameMap = {}) {
+  const rows = (list || []).map((item) => {
+    const fieldName = resolveFieldName(item, fieldNameMap) || "—";
+    return {
+      fieldName,
+      errCount: `${toCount(item.totalAnomalyCount)}台`,
+      _totalAnomalyCount: toCount(item.totalAnomalyCount),
+      _normalCount: toCount(
+        item.normalEngineCount ??
+          item.engineNormalCount ??
+          item.normalCount ??
+          item.engineNormalNumber,
+      ),
+      _engineTotal: toCount(
+        item.engineTotalCount ??
+          item.totalEngineCount ??
+          item.engineGroupNumber ??
+          item.engineCount,
+      ),
+    };
+  });
+
+  const tableData = rows
+    .filter((row) => row.fieldName !== "—")
+    .map(({ fieldName, errCount }) => ({ fieldName, errCount }));
+
+  const abnormalTotal = rows.reduce(
+    (sum, row) => sum + row._totalAnomalyCount,
+    0,
+  );
+
+  const normalExplicit = rows.reduce((sum, row) => sum + row._normalCount, 0);
+  const engineTotal = rows.reduce((sum, row) => sum + row._engineTotal, 0);
+  const normalTotal =
+    normalExplicit > 0
+      ? normalExplicit
+      : engineTotal > 0
+        ? Math.max(0, engineTotal - abnormalTotal)
+        : 0;
+
+  return {
+    abnormalTotal,
+    normalTotal,
+    tableData,
+  };
+}
+
+export { buildFieldNameMap };

+ 8 - 8
src/views/admin/cockpitManage/component/leftdata.vue

@@ -574,15 +574,15 @@ export default {
     }
   },
   mounted() {
-    if (this.shouldPollInbound) {
-      this.startPolling();
-    }
-  },
-  beforeDestroy() {
-    if (this.shouldPollInbound) {
-      this.stopPolling();
-    }
+    // if (this.shouldPollInbound) {
+    //   this.startPolling();
+    // }
   },
+  // beforeDestroy() {
+  //   if (this.shouldPollInbound) {
+  //     this.stopPolling();
+  //   }
+  // },
   methods: {
     getbacklog() {
       queryWaitTask().then((res) => {

+ 4 - 3
src/views/anomalyDetection/anomalyDetectionDetail.vue

@@ -64,6 +64,7 @@
         v-if="activeTab === mod.key"
         :key="mod.key"
         :module-key="mod.key"
+        :engine-id="engineId"
         :items="moduleData[mod.key].items"
         :table-title="mod.tableTitle"
         :trend-range="trendRange[mod.key]"
@@ -131,6 +132,7 @@ import {
   mapWindCardToPieData,
 } from "./utils/anomalyDetailMapper";
 import { mapWindCardsFromModelRes } from "./utils/anomalyDashboardMapper";
+import { clearDetectorPlotCache } from "./utils/anomalyDetectorPlotRegistry";
 import {
   buildPieOption,
   getTrendChartOption,
@@ -364,6 +366,7 @@ export default {
       if (!this.engineId) return;
       this.loading = true;
       this.clearTrendCache();
+      clearDetectorPlotCache();
       try {
         if (this.fieldId) {
           await Promise.all([
@@ -389,9 +392,7 @@ export default {
         const res = await apiFn(this.buildRequestPayload());
         if (res && res.code === 200 && res.data) {
           const mapped = mapModuleApiToDetail(moduleKey, res.data);
-          const baseItems = mapped.items.length
-            ? mapped.items.map((item, idx) => ({ ...item, open: idx === 0 }))
-            : [];
+          const baseItems = mapped.items.length ? mapped.items.map((item) => ({ ...item })) : [];
           const items = await enrichModuleItems(baseItems);
           this.moduleData[moduleKey] = { items };
         }

+ 18 - 7
src/views/anomalyDetection/components/AnomalyDetailModulePanel.vue

@@ -39,6 +39,8 @@
       </div>
       <div class="anomaly-detail-chart-panel">
         <AnomalyDetectorPlotCarousel
+          :key="`${engineId}-${activeDetectorKey}-${activeGraphPath}`"
+          :engine-id="engineId"
           :detector-key="activeDetectorKey"
           :detector-title="activeDetectorTitle"
           :graph-path="activeGraphPath"
@@ -115,6 +117,7 @@ export default {
   components: { AnomalyDetectorPlotCarousel },
   props: {
     moduleKey: { type: String, required: true },
+    engineId: { type: String, default: "" },
     items: { type: Array, default: () => [] },
     tableTitle: { type: String, default: "" },
     trendRange: { type: Number, default: 7 },
@@ -122,6 +125,7 @@ export default {
   data() {
     return {
       tableTab: "sensor",
+      activeOpenDetectorKey: "",
       localItems: [],
       tableLoading: false,
       tableRefreshKey: "",
@@ -171,27 +175,33 @@ export default {
     items: {
       immediate: true,
       handler(val) {
-        const openKey = this.activeItem?.detectorKey;
+        const openKey = this.activeOpenDetectorKey;
         this.localItems = (val || []).map((item, idx) => {
           const shouldOpen = openKey
             ? item.detectorKey === openKey
-            : item.open !== undefined
-              ? item.open
-              : idx === 0;
+            : idx === 0;
           return {
             ...item,
             open: shouldOpen,
-            sensorRows: item.sensorRows || [],
-            diagRows: item.diagRows || [],
-            detailLoaded: item.detailLoaded || false,
+            sensorRows: [],
+            diagRows: [],
+            detailLoaded: false,
           };
         });
         if (this.localItems.length && !this.localItems.some((item) => item.open)) {
           this.localItems[0].open = true;
         }
+        const active = this.localItems.find((item) => item.open);
+        if (active?.detectorKey) {
+          this.activeOpenDetectorKey = active.detectorKey;
+        }
+        this.$nextTick(() => {
+          this.loadActiveItemTables(true);
+        });
       },
     },
     moduleKey() {
+      this.activeOpenDetectorKey = "";
       this.tableTab = "sensor";
       this.resetTablePagination();
     },
@@ -236,6 +246,7 @@ export default {
     },
     async toggleItem(idx) {
       if (this.localItems[idx]?.open) return;
+      this.activeOpenDetectorKey = this.localItems[idx]?.detectorKey || "";
       this.localItems = this.localItems.map((item, i) => ({
         ...item,
         open: i === idx,

+ 9 - 1
src/views/anomalyDetection/components/AnomalyDetectorPlotCarousel.vue

@@ -67,6 +67,7 @@ import {
 export default {
   name: "AnomalyDetectorPlotCarousel",
   props: {
+    engineId: { type: String, default: "" },
     detectorKey: { type: String, default: "" },
     detectorTitle: { type: String, default: "" },
     graphPath: { type: String, default: "" },
@@ -87,6 +88,9 @@ export default {
     },
   },
   watch: {
+    engineId() {
+      this.loadPlot();
+    },
     detectorKey: {
       immediate: true,
       handler() {
@@ -126,7 +130,11 @@ export default {
 
       this.loading = true;
       try {
-        const plotJson = await loadDetectorPlot(this.detectorKey, this.graphPath);
+        const plotJson = await loadDetectorPlot(
+          this.detectorKey,
+          this.graphPath,
+          this.engineId,
+        );
         if (plotJson && plotJson.__imageUrl) {
           this.imageSrc = plotJson.__imageUrl;
           this.imageTitle = this.detectorTitle;

+ 40 - 32
src/views/anomalyDetection/components/AnomalyWindCard.vue

@@ -3,38 +3,8 @@
     class="anomaly-card anomaly-wind-card"
     :data-engine-id="card.engineId || ''"
   >
-    <div class="anomaly-wind-id">{{ card.name }}</div>
-
-    <div class="anomaly-wind-metrics">
-      <div class="anomaly-wind-metric anomaly-wind-metric--wind">
-        <span class="anomaly-num">{{ card.summary.windPower }}%</span>
-        <span class="anomaly-wind-label">风速-功率异常</span>
-      </div>
-      <div class="anomaly-wind-metric anomaly-wind-metric--yaw">
-        <span class="anomaly-num">{{ card.summary.yaw }}%</span>
-        <span class="anomaly-wind-label">偏航系统异常</span>
-      </div>
-      <div class="anomaly-wind-turbine" aria-hidden="true">
-        <img class="anomaly-wind-turbine__icon" :src="turbineIcon" alt="" />
-      </div>
-      <div class="anomaly-wind-metric anomaly-wind-metric--pitch">
-        <span class="anomaly-num">{{ card.summary.pitch }}%</span>
-        <span class="anomaly-wind-label">变桨系统异常</span>
-      </div>
-      <div class="anomaly-wind-metric anomaly-wind-metric--run">
-        <span class="anomaly-num">{{ card.summary.run }}%</span>
-        <span class="anomaly-wind-label">运行状态综合异常</span>
-      </div>
-      <div class="anomaly-wind-metric anomaly-wind-metric--aero">
-        <span class="anomaly-num">{{ card.summary.aero }}%</span>
-        <span class="anomaly-wind-label">气动性能异常</span>
-      </div>
-    </div>
-
-    <div class="anomaly-wind-footer">
-      <div class="anomaly-wind-sensors">
-        数据感知异常类型:{{ card.sensorTypes }}
-      </div>
+    <div class="anomaly-wind-card__header">
+      <div class="anomaly-wind-id">{{ card.name }}</div>
       <button
         type="button"
         class="anomaly-wind-detail__btn"
@@ -44,6 +14,44 @@
         <i class="el-icon-arrow-right anomaly-wind-detail__icon"></i>
       </button>
     </div>
+
+    <div class="anomaly-wind-card__modules">
+      <div class="anomaly-wind-module">
+        <div class="anomaly-wind-module__title">功能诊断</div>
+        <div class="anomaly-wind-metrics">
+          <div class="anomaly-wind-metric anomaly-wind-metric--wind">
+            <span class="anomaly-num">{{ card.summary.windPower }}%</span>
+            <span class="anomaly-wind-label">风速-功率异常</span>
+          </div>
+          <div class="anomaly-wind-metric anomaly-wind-metric--yaw">
+            <span class="anomaly-num">{{ card.summary.yaw }}%</span>
+            <span class="anomaly-wind-label">偏航系统异常</span>
+          </div>
+          <div class="anomaly-wind-turbine" aria-hidden="true">
+            <img class="anomaly-wind-turbine__icon" :src="turbineIcon" alt="" />
+          </div>
+          <div class="anomaly-wind-metric anomaly-wind-metric--pitch">
+            <span class="anomaly-num">{{ card.summary.pitch }}%</span>
+            <span class="anomaly-wind-label">变桨系统异常</span>
+          </div>
+          <div class="anomaly-wind-metric anomaly-wind-metric--run">
+            <span class="anomaly-num">{{ card.summary.run }}%</span>
+            <span class="anomaly-wind-label">运行状态综合异常</span>
+          </div>
+          <div class="anomaly-wind-metric anomaly-wind-metric--aero">
+            <span class="anomaly-num">{{ card.summary.aero }}%</span>
+            <span class="anomaly-wind-label">气动性能异常</span>
+          </div>
+        </div>
+      </div>
+
+      <div class="anomaly-wind-module anomaly-wind-module--sensor">
+        <div class="anomaly-wind-module__title">数据感知</div>
+        <div class="anomaly-wind-sensors">
+          异常类型:{{ card.sensorTypes }}
+        </div>
+      </div>
+    </div>
   </div>
 </template>
 

+ 52 - 19
src/views/anomalyDetection/styles/anomaly-dashboard.scss

@@ -275,6 +275,43 @@
     padding: 12px;
   }
 
+  .anomaly-wind-card__header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    margin-bottom: 10px;
+  }
+
+  .anomaly-wind-card__modules {
+    display: flex;
+    flex-direction: column;
+    gap: 10px;
+  }
+
+  .anomaly-wind-module {
+    padding: 8px;
+    border-radius: 6px;
+    background: rgba(0, 36, 64, 0.22);
+    border: 1px solid rgba(126, 172, 199, 0.18);
+  }
+
+  .anomaly-wind-module__title {
+    margin-bottom: 8px;
+    font-size: 11px;
+    font-weight: 600;
+    line-height: 1.2;
+    color: var(--anomaly-title, #b7dff5);
+  }
+
+  .anomaly-wind-module--sensor {
+    .anomaly-wind-sensors {
+      font-size: 11px;
+      line-height: 1.5;
+      word-break: break-all;
+    }
+  }
+
   .anomaly-wind-turbine {
     grid-column: 3;
     grid-row: 1;
@@ -295,9 +332,7 @@
   }
 
   .anomaly-wind-id {
-    position: absolute;
-    left: 12px;
-    top: 10px;
+    flex-shrink: 0;
     font-size: 12px;
     font-weight: 700;
     line-height: 1;
@@ -306,7 +341,6 @@
   }
 
   .anomaly-wind-metrics {
-    padding-top: 30px;
     display: grid;
     grid-template-columns: repeat(3, minmax(0, 1fr));
     grid-template-rows: auto auto;
@@ -363,18 +397,7 @@
     word-break: break-all;
   }
 
-  .anomaly-wind-footer {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    gap: 12px;
-    margin-top: 16px;
-    padding-top: 10px;
-    border-top: 1px solid rgba(126, 172, 199, 0.25);
-  }
-
   .anomaly-wind-sensors {
-    flex: 1;
     min-width: 0;
     font-size: 11px;
     line-height: 1.5;
@@ -543,8 +566,13 @@
       color: var(--anomaly-wind-label, #909399);
     }
 
-    .anomaly-wind-footer {
-      border-top-color: var(--anomaly-divider, #ebeef5);
+    .anomaly-wind-module {
+      background: var(--anomaly-module-bg, #f8fafc);
+      border-color: var(--anomaly-divider, #ebeef5);
+    }
+
+    .anomaly-wind-module__title {
+      color: var(--anomaly-title, #303133);
     }
 
     .anomaly-wind-sensors {
@@ -722,8 +750,13 @@
       color: #b8d3e6;
     }
 
-    .anomaly-wind-footer {
-      border-top-color: rgba(126, 172, 199, 0.45);
+    .anomaly-wind-module {
+      background: rgba(17, 22, 39, 0.72);
+      border-color: rgba(95, 189, 227, 0.22);
+    }
+
+    .anomaly-wind-module__title {
+      color: #9cc7df;
     }
 
     .anomaly-wind-sensors {

+ 39 - 1
src/views/anomalyDetection/utils/anomalyDashboardMapper.js

@@ -102,6 +102,25 @@ function normalizeModelList(modelRes) {
   return [];
 }
 
+function normalizeAnomalyOverviewList(data) {
+  if (!data) return [];
+  if (Array.isArray(data)) return data;
+  if (Array.isArray(data?.list)) return data.list;
+  if (Array.isArray(data?.records)) return data.records;
+  if (typeof data === "object") {
+    if (
+      data.fieldName ||
+      data.fieldId ||
+      data.totalAnomalyCount != null ||
+      data.sensorAnomalyCount != null ||
+      data.detectorAnomalyCount != null
+    ) {
+      return [data];
+    }
+  }
+  return [];
+}
+
 function formatRatioText(count, total) {
   if (!total) return "占比 0%";
   const pct = ((Number(count) || 0) / total) * 100;
@@ -250,7 +269,26 @@ export function mapAnomalyDashboard({
   modelRes,
   barChartRes,
 }) {
-  const overviewData = overviewRes?.data || {};
+  const overviewList = normalizeAnomalyOverviewList(overviewRes?.data);
+  const overviewData =
+    overviewList.length === 1
+      ? overviewList[0]
+      : overviewList.length > 1
+        ? {
+            totalAnomalyCount: overviewList.reduce(
+              (sum, item) => sum + (Number(item.totalAnomalyCount) || 0),
+              0,
+            ),
+            sensorAnomalyCount: overviewList.reduce(
+              (sum, item) => sum + (Number(item.sensorAnomalyCount) || 0),
+              0,
+            ),
+            detectorAnomalyCount: overviewList.reduce(
+              (sum, item) => sum + (Number(item.detectorAnomalyCount) || 0),
+              0,
+            ),
+          }
+        : overviewRes?.data || {};
   const modelCount = modelCountRes?.data || {};
   const sensorCount = sensorCountRes?.data || {};
   const heatmapData = heatmapRes?.data || {};

+ 14 - 5
src/views/anomalyDetection/utils/anomalyDetailChartOptions.js

@@ -116,7 +116,7 @@ export const TREND_MOCK = {
         color: "#b983ff",
       },
       {
-        name: "降载异常",
+        name: "降载情况",
         data: [12, 15, 18, 14, 20, 16, 22],
         color: "#00ff88",
       },
@@ -139,7 +139,7 @@ export const TREND_MOCK = {
         color: "#b983ff",
       },
       {
-        name: "降载异常",
+        name: "降载情况",
         data: [
           10, 12, 14, 13, 16, 15, 18, 17, 20, 19, 18, 16, 15, 14, 13, 16, 18,
           20, 19, 17, 15, 14, 13, 12, 11, 10, 12, 14, 13, 12,
@@ -220,9 +220,18 @@ function chartTheme() {
 function shiftPieColor(hex, amount) {
   const color = String(hex || "#22e8ff").replace("#", "");
   if (color.length < 6) return hex;
-  const r = Math.max(0, Math.min(255, parseInt(color.substr(0, 2), 16) + amount));
-  const g = Math.max(0, Math.min(255, parseInt(color.substr(2, 2), 16) + amount));
-  const b = Math.max(0, Math.min(255, parseInt(color.substr(4, 2), 16) + amount));
+  const r = Math.max(
+    0,
+    Math.min(255, parseInt(color.substr(0, 2), 16) + amount),
+  );
+  const g = Math.max(
+    0,
+    Math.min(255, parseInt(color.substr(2, 2), 16) + amount),
+  );
+  const b = Math.max(
+    0,
+    Math.min(255, parseInt(color.substr(4, 2), 16) + amount),
+  );
   return `rgb(${r}, ${g}, ${b})`;
 }
 

+ 56 - 18
src/views/anomalyDetection/utils/anomalyDetailMapper.js

@@ -1,31 +1,72 @@
-import {
-  fetchRemoteJson,
-  parseFilePathTables,
-} from "./anomalyRemoteData";
+import { fetchRemoteJson, parseFilePathTables } from "./anomalyRemoteData";
 import { mapSensorTypeLabel } from "./anomalyMapEnums";
 
 const MODULE_PO_CONFIG = {
   wind: [
-    { key: "anomalyPowercurvePO", title: "风速-功率曲线异常", detectorKey: "anomalyPowercurvePO" },
-    { key: "anomalyScatterPO", title: "风速-功率散点异常", detectorKey: "anomalyScatterPO" },
+    {
+      key: "anomalyPowercurvePO",
+      title: "风速-功率曲线异常",
+      detectorKey: "anomalyPowercurvePO",
+    },
+    {
+      key: "anomalyScatterPO",
+      title: "风速-功率散点异常",
+      detectorKey: "anomalyScatterPO",
+    },
   ],
   yaw: [
-    { key: "anomalyStaticyawPO", title: "偏航静态异常", detectorKey: "anomalyStaticyawPO" },
-    { key: "anomalyCabletwistPO", title: "偏航扭缆异常", detectorKey: "anomalyCabletwistPO" },
+    {
+      key: "anomalyStaticyawPO",
+      title: "偏航静态异常",
+      detectorKey: "anomalyStaticyawPO",
+    },
+    {
+      key: "anomalyCabletwistPO",
+      title: "偏航扭缆异常",
+      detectorKey: "anomalyCabletwistPO",
+    },
   ],
   pitch: [
-    { key: "anomalyPitchregulationPO", title: "桨叶不一致异常", detectorKey: "anomalyPitchregulationPO" },
-    { key: "anomalyPitchcoordPO", title: "变桨协调异常", detectorKey: "anomalyPitchcoordPO" },
+    {
+      key: "anomalyPitchregulationPO",
+      title: "桨叶不一致异常",
+      detectorKey: "anomalyPitchregulationPO",
+    },
+    {
+      key: "anomalyPitchcoordPO",
+      title: "变桨协调异常",
+      detectorKey: "anomalyPitchcoordPO",
+    },
   ],
   run: [
-    { key: "anomalyPowerqualityPO", title: "功率质量异常", detectorKey: "anomalyPowerqualityPO" },
-    { key: "anomalyOperationPO", title: "运行状态异常", detectorKey: "anomalyOperationPO" },
-    { key: "anomalyDeloadPO", title: "降载异常", detectorKey: "anomalyDeloadPO" },
+    {
+      key: "anomalyPowerqualityPO",
+      title: "功率质量异常",
+      detectorKey: "anomalyPowerqualityPO",
+    },
+    {
+      key: "anomalyOperationPO",
+      title: "运行状态异常",
+      detectorKey: "anomalyOperationPO",
+    },
+    {
+      key: "anomalyDeloadPO",
+      title: "降载情况",
+      detectorKey: "anomalyDeloadPO",
+    },
   ],
   aero: [
     { key: "anomalyCpPO", title: "Cp功率系数异常", detectorKey: "anomalyCpPO" },
-    { key: "anomalyCpTsrPO", title: "TSR-Cp联合分布异常", detectorKey: "anomalyCpTsrPO" },
-    { key: "anomalyTsrPO", title: "TSR叶尖速比异常", detectorKey: "anomalyTsrPO" },
+    {
+      key: "anomalyCpTsrPO",
+      title: "TSR-Cp联合分布异常",
+      detectorKey: "anomalyCpTsrPO",
+    },
+    {
+      key: "anomalyTsrPO",
+      title: "TSR叶尖速比异常",
+      detectorKey: "anomalyTsrPO",
+    },
   ],
 };
 
@@ -197,9 +238,6 @@ export function mapModuleApiToDetail(moduleKey, apiData) {
   const items = configs.map((cfg) =>
     mapPoToItem(apiData?.[cfg.key], cfg.title, cfg.detectorKey),
   );
-  if (items.length && items[0]) {
-    items[0].open = true;
-  }
   return { items, raw: apiData };
 }
 

+ 7 - 6
src/views/anomalyDetection/utils/anomalyDetectorPlotRegistry.js

@@ -1,7 +1,4 @@
-import {
-  fetchRemoteJson,
-  isRemoteImageUrl,
-} from "./anomalyRemoteData";
+import { fetchRemoteJson, isRemoteImageUrl } from "./anomalyRemoteData";
 
 /** 检测器 key → 本地 JSON 懒加载(files_list,仅开发回退) */
 const PLOT_LOADERS = {
@@ -59,8 +56,12 @@ export const PLOT_IMAGE_FALLBACK = {};
 
 const plotCache = new Map();
 
-export async function loadDetectorPlot(detectorKey, graphPath) {
-  const cacheKey = graphPath || detectorKey || "";
+export function clearDetectorPlotCache() {
+  plotCache.clear();
+}
+
+export async function loadDetectorPlot(detectorKey, graphPath, engineId = "") {
+  const cacheKey = [engineId, graphPath, detectorKey].filter(Boolean).join("::");
   if (!cacheKey) return null;
   if (plotCache.has(cacheKey)) return plotCache.get(cacheKey);
 

+ 16 - 3
src/views/anomalyDetection/utils/anomalyMockData.js

@@ -15,7 +15,14 @@ function buildMockItems(moduleKey, itemDefs) {
   });
 }
 
-function poItem(name, isAnomaly, detectorCount, detectorRatio, sensorText, detectorKey) {
+function poItem(
+  name,
+  isAnomaly,
+  detectorCount,
+  detectorRatio,
+  sensorText,
+  detectorKey,
+) {
   return {
     title: name,
     anomalyModelName: name,
@@ -37,7 +44,13 @@ export function getMockModuleDetail(moduleKey) {
   const map = {
     wind: {
       items: buildMockItems("wind", [
-        [null, true, 30, 40, "功率值越界 | 风速值越界 | 变桨值越界 | 转速值越界"],
+        [
+          null,
+          true,
+          30,
+          40,
+          "功率值越界 | 风速值越界 | 变桨值越界 | 转速值越界",
+        ],
         [null, false, 0, 0, "—"],
       ]),
       sensorRows: [
@@ -101,7 +114,7 @@ export function getMockModuleDetail(moduleKey) {
       diagRows: [
         { type: "功率质量异常", score: 35, time: "2025-01-01 13:41" },
         { type: "运行状态异常", score: 18, time: "2025-01-01 13:43" },
-        { type: "降载异常", score: 12, time: "2025-01-01 13:46" },
+        { type: "降载情况", score: 12, time: "2025-01-01 13:46" },
         { type: "/", score: "/", time: "2025-01-01 13:45" },
       ],
     },

+ 22 - 1
src/views/anomalyDetection/utils/anomalyRemoteData.js

@@ -1,6 +1,7 @@
 import { mapSensorTypeLabel } from "./anomalyMapEnums";
 
 const SENSOR_LIST_KEYS = [
+  "sensorAnomalies",
   "sensorRows",
   "sensorDetail",
   "sensorAnomalyList",
@@ -14,6 +15,7 @@ const SENSOR_LIST_KEYS = [
 ];
 
 const DIAG_LIST_KEYS = [
+  "detectorAnomalies",
   "diagRows",
   "detectorDetail",
   "detectorAnomalyList",
@@ -44,6 +46,7 @@ function mapDetailRow(row, kind) {
     row.type ||
     row.异常类型 ||
     row.anomalyType ||
+    (kind === "sensor" ? row.sensorAnomalyType : row.detectorAnomalyType) ||
     row.sensorAnomalyType ||
     row.detectorAnomalyType ||
     "";
@@ -52,13 +55,17 @@ function mapDetailRow(row, kind) {
       row.SCADA数据时间 ||
       row.scadaTime ||
       row.monitorTime ||
-      row.datetime,
+      row.datetime ||
+      row.createTime ||
+      row.dataTime ||
+      row.dateResource,
   );
   const score =
     row.score ??
     row.异常分数 ??
     row.anomalyScore ??
     row.detectorScore ??
+    row.detectorAnomalyScore ??
     "";
 
   if (kind === "sensor") {
@@ -111,6 +118,20 @@ export function parseFilePathTables(fileJson) {
   }
 
   if (typeof fileJson === "object") {
+    if (
+      Array.isArray(fileJson.sensorAnomalies) ||
+      Array.isArray(fileJson.detectorAnomalies)
+    ) {
+      return {
+        sensorRows: (fileJson.sensorAnomalies || [])
+          .map((row) => mapDetailRow(row, "sensor"))
+          .filter((row) => row && (row.type || row.time)),
+        diagRows: (fileJson.detectorAnomalies || [])
+          .map((row) => mapDetailRow(row, "diag"))
+          .filter((row) => row && (row.type || row.time || row.score !== "")),
+      };
+    }
+
     const sensorList = pickList(fileJson, SENSOR_LIST_KEYS);
     const diagList = pickList(fileJson, DIAG_LIST_KEYS);
 

+ 8 - 3
src/views/health/components/health/HealthScorePanel.vue

@@ -3,7 +3,7 @@
     <div class="scan-line"></div>
     <h3 class="health-score-panel__title">
       <i class="el-icon-success health-score-panel__icon"></i>
-      全场健康度实时评分
+      全场健康度
     </h3>
     <div ref="chartRef" class="health-score-panel__chart"></div>
     <div class="health-score-panel__stats">
@@ -12,7 +12,10 @@
         :key="item.label"
         class="health-score-panel__stat"
       >
-        <div class="health-score-panel__stat-value" :style="{ color: item.color }">
+        <div
+          class="health-score-panel__stat-value"
+          :style="{ color: item.color }"
+        >
           {{ item.value }}
         </div>
         <div class="health-score-panel__stat-label">{{ item.label }}</div>
@@ -89,7 +92,9 @@ export default {
     renderChart() {
       if (!this.chart) return;
       const { scoreColor, labelColor, pieBorder } = this.getChartTheme();
-      const totalLevelColor = getLevelSummaryColor(scoreToLevel(this.totalScore));
+      const totalLevelColor = getLevelSummaryColor(
+        scoreToLevel(this.totalScore),
+      );
       this.chart.setOption({
         backgroundColor: "transparent",
         series: [

+ 1 - 1
src/views/health/components/health/HealthSubsystemPanel.vue

@@ -2,7 +2,7 @@
   <section class="health-subsystem-panel glass-card subsystem-card">
     <h3 class="health-subsystem-panel__title">
       <i class="el-icon-data-analysis health-subsystem-panel__icon"></i>
-      子系统健康度多维对比
+      子系统健康度
     </h3>
     <div ref="chartRef" class="health-subsystem-panel__chart"></div>
   </section>

+ 16 - 5
src/views/windResourceWake/components/WrwFilterBar.vue

@@ -5,10 +5,10 @@
       <selecttree
         class="wrw-filter-selecttree"
         size="small"
+        filterable
         popper-class="wrw-tree-popper"
-        placeholder="请选择所属公司"
+        placeholder="请选择风场"
         :list="parentOpt"
-        type="1"
         v-model="localFarm"
         @change="handleSelectChange"
         :defaultParentProps="defaultParentProps"
@@ -79,16 +79,24 @@ export default {
         const processedData = this.processTreeData(res.data || []);
         this.parentOpt = processedData;
 
-        const defaultData = (res.data || [])[0];
-        if (defaultData && !this.localFarm) {
+        const defaultNode = this.getDefaultTreeNode(processedData);
+        if (defaultNode) {
           this.$nextTick(() => {
-            this.handleSelectChange(defaultData);
+            this.handleSelectChange(defaultNode);
           });
         }
       } catch (error) {
         console.error("加载组织树失败:", error);
       }
     },
+    getDefaultTreeNode(treeData) {
+      const root = treeData && treeData[0];
+      if (!root?.children?.length) {
+        return null;
+      }
+      const firstChild = root.children[0];
+      return firstChild?.codeNumber ? firstChild : null;
+    },
     processTreeData(treeData) {
       const processedData = [];
       const processNode = (node) => {
@@ -134,12 +142,15 @@ export default {
     border: none !important;
     color: #d9efff !important;
     padding-left: 0 !important;
+    padding-right: 28px !important;
     height: 28px;
     line-height: 28px;
+    cursor: text;
   }
 
   ::v-deep .el-input__icon {
     color: #5ab3dc !important;
+    cursor: pointer;
   }
 }
 </style>

+ 67 - 15
src/views/windResourceWake/components/WrwOverviewTab.vue

@@ -1,10 +1,15 @@
 <template>
   <div class="wrw-overview-tab">
     <div class="wrw-overview-section">
-      <div class="wrw-overview-section-title">A2 各机组风速分布</div>
+      <div class="wrw-overview-section-title">各机组风速分布</div>
       <p class="wrw-overview-section-desc">各机组风速概率密度对比</p>
       <div class="wrw-card wrw-overview-card">
-        <div ref="windDistRef" class="wrw-overview-chart"></div>
+        <div
+          v-if="hasWindDistData"
+          ref="windDistRef"
+          class="wrw-overview-chart"
+        ></div>
+        <div v-else class="wrw-chart-empty">暂无风速分布数据</div>
       </div>
     </div>
     <div class="wrw-overview-section">
@@ -13,19 +18,22 @@
         各机组风向与风速段分布(N 为正北)
       </p>
       <div class="wrw-card wrw-overview-card">
-        <div class="wrw-wind-rose-grid">
+        <div v-if="displayTurbines.length" class="wrw-wind-rose-grid">
           <div
-            v-for="(name, index) in turbineNames"
-            :key="name"
+            v-for="(turbine, index) in displayTurbines"
+            :key="turbine.engineId || turbine.name || index"
             class="wrw-wind-rose-cell"
           >
-            <div class="wrw-wind-rose-cell-title">{{ name }}</div>
+            <div class="wrw-wind-rose-cell-title">
+              {{ turbine.engineId || turbine.name }}
+            </div>
             <div
               :ref="'windRoseRef' + index"
               class="wrw-wind-rose-cell-chart"
             ></div>
           </div>
         </div>
+        <div v-else class="wrw-chart-empty">暂无风向玫瑰图数据</div>
         <div class="wrw-wind-rose-legend">
           <span
             v-for="(band, index) in roseSpeedBands"
@@ -43,7 +51,6 @@
 
 <script>
 import * as echarts from "echarts";
-import { turbineNames } from "../data/mockData";
 import {
   getWindDistOption,
   getSingleWindRoseOption,
@@ -53,15 +60,45 @@ import {
 
 export default {
   name: "WrwOverviewTab",
+  props: {
+    chartData: {
+      type: Object,
+      default: () => ({}),
+    },
+  },
   data() {
     return {
-      turbineNames,
-      roseSpeedBands: ROSE_SPEED_BANDS,
       roseColors: ROSE_COLORS,
       charts: {},
       inited: false,
     };
   },
+  computed: {
+    hasWindDistData() {
+      return (this.chartData?.windSpeedDistribution?.turbines || []).length > 0;
+    },
+    roseSpeedBands() {
+      const bands = this.chartData?.wakeRose?.speedBands;
+      return Array.isArray(bands) && bands.length ? bands : ROSE_SPEED_BANDS;
+    },
+    displayTurbines() {
+      const roseTurbines = this.chartData?.wakeRose?.turbines;
+      return Array.isArray(roseTurbines) ? roseTurbines : [];
+    },
+  },
+  watch: {
+    chartData: {
+      deep: true,
+      handler() {
+        this.refreshCharts();
+      },
+    },
+    displayTurbines: {
+      handler() {
+        this.refreshCharts();
+      },
+    },
+  },
   mounted() {
     window.addEventListener("resize", this.handleResize);
   },
@@ -70,17 +107,32 @@ export default {
     this.disposeCharts();
   },
   methods: {
+    refreshCharts() {
+      this.disposeCharts();
+      this.$nextTick(() => {
+        this.$nextTick(() => {
+          this.initCharts();
+        });
+      });
+    },
     initCharts() {
-      if (this.inited) {
-        this.handleResize();
-        return;
+      if (this.hasWindDistData) {
+        this.initChart(
+          "windDist",
+          this.$refs.windDistRef,
+          getWindDistOption(this.chartData?.windSpeedDistribution),
+        );
       }
-      this.initChart("windDist", this.$refs.windDistRef, getWindDistOption());
-      this.turbineNames.forEach((_, index) => {
+      this.displayTurbines.forEach((turbine, index) => {
         const ref = this.$refs[`windRoseRef${index}`];
         const el = Array.isArray(ref) ? ref[0] : ref;
-        this.initChart(`windRose${index}`, el, getSingleWindRoseOption(index));
+        this.initChart(
+          `windRose${index}`,
+          el,
+          getSingleWindRoseOption(index, this.chartData?.wakeRose, turbine),
+        );
       });
+
       this.inited = true;
     },
     initChart(key, el, option) {

+ 89 - 11
src/views/windResourceWake/components/WrwWakeTab.vue

@@ -4,18 +4,35 @@
 
     <div class="wrw-row">
       <div class="wrw-col wrw-card">
-        <div ref="scatter1Ref" class="wrw-chart"></div>
+        <el-image
+          v-if="scatter1Image"
+          class="wrw-chart wrw-chart--image"
+          :src="scatter1Image"
+          fit="contain"
+          :preview-src-list="[scatter1Image]"
+        />
+        <div
+          v-else-if="hasScatter1Data"
+          ref="scatter1Ref"
+          class="wrw-chart"
+        ></div>
+        <div v-else class="wrw-chart-empty wrw-chart">暂无数据</div>
       </div>
       <div class="wrw-col wrw-card">
-        <div ref="scatter2Ref" class="wrw-chart"></div>
+        <div v-if="hasScatter2Data" ref="scatter2Ref" class="wrw-chart"></div>
+        <div v-else class="wrw-chart-empty wrw-chart">暂无数据</div>
       </div>
     </div>
 
     <div class="wrw-section-title">尾流影响评估</div>
 
     <div class="wrw-card">
-      <div ref="barRef" class="wrw-chart wrw-chart--bar"></div>
-      <WrwWakeTable />
+      <div v-if="hasBarData" class="wrw-bar-charts">
+        <div ref="barLossRef" class="wrw-chart wrw-chart--bar"></div>
+        <div ref="barTurbulenceRef" class="wrw-chart wrw-chart--bar"></div>
+      </div>
+      <div v-else class="wrw-chart-empty wrw-chart wrw-chart--bar">暂无数据</div>
+      <WrwWakeTable :table-data="tableData" />
     </div>
   </div>
 </template>
@@ -26,18 +43,54 @@ import WrwWakeTable from "./WrwWakeTable.vue";
 import {
   getScatter1Option,
   getScatter2Option,
-  getBarOption,
+  getLossDelLossBarOption,
+  getLossDelTurbulenceBarOption,
 } from "../utils/wakeChartOptions";
 
 export default {
   name: "WrwWakeTab",
   components: { WrwWakeTable },
+  props: {
+    chartData: {
+      type: Object,
+      default: () => ({}),
+    },
+    tableData: {
+      type: Array,
+      default: () => [],
+    },
+  },
   data() {
     return {
       charts: {},
       inited: false,
     };
   },
+  computed: {
+    scatter1Image() {
+      return this.chartData?.wakeDistribute?.__imageUrl || "";
+    },
+    hasScatter1Data() {
+      const points = this.chartData?.wakeDistribute?.points;
+      return Array.isArray(points) && points.length > 0;
+    },
+    hasScatter2Data() {
+      const points = this.chartData?.wakeLossConfident?.points;
+      return Array.isArray(points) && points.length > 0;
+    },
+    hasBarData() {
+      const items = this.chartData?.velocityDeficitBar?.items;
+      return Array.isArray(items) && items.length > 0;
+    },
+  },
+  watch: {
+    chartData: {
+      deep: true,
+      handler() {
+        this.refreshCharts();
+      },
+    },
+  },
   mounted() {
     window.addEventListener("resize", this.handleResize);
   },
@@ -46,14 +99,39 @@ export default {
     this.disposeCharts();
   },
   methods: {
+    refreshCharts() {
+      this.disposeCharts();
+      this.$nextTick(() => {
+        this.initCharts();
+      });
+    },
     initCharts() {
-      if (this.inited) {
-        this.handleResize();
-        return;
+      if (!this.scatter1Image && this.hasScatter1Data) {
+        this.initChart(
+          "scatter1",
+          this.$refs.scatter1Ref,
+          getScatter1Option(this.chartData?.wakeDistribute),
+        );
+      }
+      if (this.hasScatter2Data) {
+        this.initChart(
+          "scatter2",
+          this.$refs.scatter2Ref,
+          getScatter2Option(this.chartData?.wakeLossConfident),
+        );
+      }
+      if (this.hasBarData) {
+        this.initChart(
+          "barLoss",
+          this.$refs.barLossRef,
+          getLossDelLossBarOption(this.chartData?.velocityDeficitBar),
+        );
+        this.initChart(
+          "barTurbulence",
+          this.$refs.barTurbulenceRef,
+          getLossDelTurbulenceBarOption(this.chartData?.velocityDeficitBar),
+        );
       }
-      this.initChart("scatter1", this.$refs.scatter1Ref, getScatter1Option());
-      this.initChart("scatter2", this.$refs.scatter2Ref, getScatter2Option());
-      this.initChart("bar", this.$refs.barRef, getBarOption());
       this.inited = true;
     },
     initChart(key, el, option) {

+ 99 - 85
src/views/windResourceWake/components/WrwWakeTable.vue

@@ -1,105 +1,116 @@
 <template>
   <div class="wrw-wake-table">
-    <div class="wrw-table-wrapper">
-      <table class="wrw-table">
-        <thead>
-          <tr>
-            <th>风机名称</th>
-            <th>是否受尾流影响</th>
-            <th>附加湍流强度</th>
-            <th>尾流速度损耗</th>
-            <th>置信度</th>
-            <th>图片</th>
-          </tr>
-        </thead>
-        <tbody>
-          <tr v-for="item in pageData" :key="item.name">
-            <td>{{ item.name }}</td>
-            <td>
-              <span
-                :class="
-                  item.affected === 'yes' ? 'wrw-status-yes' : 'wrw-status-no'
-                "
-              >
-                {{ item.affected === "yes" ? "是" : "否" }}
-              </span>
-            </td>
-            <td :class="getTurbulenceClass(item.turbulence)">
-              {{ item.turbulence }}
-            </td>
-            <td :class="getLossClass(item.loss)">{{ item.loss }}</td>
-            <td :class="getConfidenceClass(item.confidence)">
-              {{ item.confidence }}
-            </td>
-            <td>
-              <div class="wrw-image-container" title="点击查看大图">
-                <el-image
-                  class="wrw-table-thumb"
-                  :src="item.image"
-                  :preview-src-list="[item.image]"
-                  fit="cover"
+    <template v-if="tableData.length">
+      <div class="wrw-table-wrapper">
+        <table class="wrw-table">
+          <thead>
+            <tr>
+              <th>风机名称</th>
+              <th>是否受尾流影响</th>
+              <th>附加湍流强度</th>
+              <th>尾流速度损耗(m/s)</th>
+              <th>置信度</th>
+              <th>尾流速度亏损分布图</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="item in pageData" :key="item.engineId || item.name">
+              <td>{{ item.name }}</td>
+              <td>
+                <span
+                  :class="
+                    item.affected === 'yes' ? 'wrw-status-yes' : 'wrw-status-no'
+                  "
                 >
-                  <div slot="error" class="wrw-table-thumb-error">
-                    <i class="el-icon-picture-outline"></i>
-                  </div>
-                </el-image>
-              </div>
-            </td>
-          </tr>
-        </tbody>
-      </table>
-    </div>
+                  {{ item.affected === "yes" ? "是" : "否" }}
+                </span>
+              </td>
+              <td :class="getTurbulenceClass(item.turbulence)">
+                {{ item.turbulence }}
+              </td>
+              <td :class="getLossClass(item.loss)">{{ item.loss }}</td>
+              <td :class="getConfidenceClass(item.confidence)">
+                {{ item.confidence }}
+              </td>
+              <td>
+                <div class="wrw-image-container" title="点击查看大图">
+                  <el-image
+                    class="wrw-table-thumb"
+                    :src="item.image"
+                    :preview-src-list="[item.image]"
+                    fit="cover"
+                  >
+                    <div slot="error" class="wrw-table-thumb-error">
+                      <i class="el-icon-picture-outline"></i>
+                    </div>
+                  </el-image>
+                </div>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+      </div>
 
-    <div class="wrw-pagination">
-      <button type="button" :disabled="currentPage === 1" @click="goToPage(1)">
-        首页
-      </button>
-      <button type="button" :disabled="currentPage === 1" @click="prevPage">
-        上一页
-      </button>
-      <span class="wrw-page-info"
-        >第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span
-      >
-      <button
-        type="button"
-        :disabled="currentPage === totalPages"
-        @click="nextPage"
-      >
-        下一页
-      </button>
-      <button
-        type="button"
-        :disabled="currentPage === totalPages"
-        @click="goToPage(totalPages)"
-      >
-        末页
-      </button>
-      <select
-        v-model.number="pageSize"
-        class="wrw-page-size-select"
-        @change="handlePageSizeChange"
-      >
-        <option :value="3">每页3条</option>
-        <option :value="5">每页5条</option>
-        <option :value="10">每页10条</option>
-      </select>
-    </div>
+      <div class="wrw-pagination">
+        <button
+          type="button"
+          :disabled="currentPage === 1"
+          @click="goToPage(1)"
+        >
+          首页
+        </button>
+        <button type="button" :disabled="currentPage === 1" @click="prevPage">
+          上一页
+        </button>
+        <span class="wrw-page-info"
+          >第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span
+        >
+        <button
+          type="button"
+          :disabled="currentPage === totalPages"
+          @click="nextPage"
+        >
+          下一页
+        </button>
+        <button
+          type="button"
+          :disabled="currentPage === totalPages"
+          @click="goToPage(totalPages)"
+        >
+          末页
+        </button>
+        <select
+          v-model.number="pageSize"
+          class="wrw-page-size-select"
+          @change="handlePageSizeChange"
+        >
+          <option :value="3">每页3条</option>
+          <option :value="5">每页5条</option>
+          <option :value="10">每页10条</option>
+        </select>
+      </div>
+    </template>
+    <div v-else class="wrw-table-empty">暂无数据</div>
   </div>
 </template>
 
 <script>
 import {
-  tableData,
   getTurbulenceClass,
   getLossClass,
   getConfidenceClass,
-} from "../data/mockData";
+} from "../utils/wrwTableUtils";
 
 export default {
   name: "WrwWakeTable",
+  props: {
+    tableData: {
+      type: Array,
+      default: () => [],
+    },
+  },
   data() {
     return {
-      tableData,
       currentPage: 1,
       pageSize: 5,
     };
@@ -114,6 +125,9 @@ export default {
     },
   },
   watch: {
+    tableData() {
+      this.currentPage = 1;
+    },
     pageSize() {
       if (this.currentPage > this.totalPages) {
         this.currentPage = this.totalPages;

+ 0 - 131
src/views/windResourceWake/data/mockData.js

@@ -1,131 +0,0 @@
-export const turbineNames = [
-  "JH03",
-  "JH04",
-  "JH05",
-  "JH06",
-  "JH07",
-  "JH08",
-  "JH09",
-  "JH10",
-  "JH11",
-];
-
-const wakeTableImage = require("../images/image(3).png");
-
-export const tableData = [
-  {
-    name: "风机01",
-    affected: "yes",
-    turbulence: 80,
-    loss: 2,
-    confidence: "90%",
-    image: wakeTableImage,
-  },
-  {
-    name: "风机02",
-    affected: "no",
-    turbulence: 90,
-    loss: 1,
-    confidence: "80%",
-    image: wakeTableImage,
-  },
-  {
-    name: "风机03",
-    affected: "no",
-    turbulence: 90,
-    loss: 1,
-    confidence: "80%",
-    image: wakeTableImage,
-  },
-  {
-    name: "风机04",
-    affected: "yes",
-    turbulence: 75,
-    loss: 3,
-    confidence: "85%",
-    image: wakeTableImage,
-  },
-  {
-    name: "风机05",
-    affected: "no",
-    turbulence: 45,
-    loss: 1,
-    confidence: "95%",
-    image: wakeTableImage,
-  },
-  {
-    name: "风机06",
-    affected: "yes",
-    turbulence: 68,
-    loss: 4,
-    confidence: "78%",
-    image: wakeTableImage,
-  },
-  {
-    name: "风机07",
-    affected: "no",
-    turbulence: 55,
-    loss: 1,
-    confidence: "92%",
-    image: wakeTableImage,
-  },
-  {
-    name: "风机08",
-    affected: "yes",
-    turbulence: 72,
-    loss: 3,
-    confidence: "88%",
-    image: wakeTableImage,
-  },
-  {
-    name: "风机09",
-    affected: "no",
-    turbulence: 48,
-    loss: 2,
-    confidence: "90%",
-    image: wakeTableImage,
-  },
-  {
-    name: "风机10",
-    affected: "yes",
-    turbulence: 85,
-    loss: 5,
-    confidence: "75%",
-    image: wakeTableImage,
-  },
-  {
-    name: "风机11",
-    affected: "no",
-    turbulence: 52,
-    loss: 1,
-    confidence: "94%",
-    image: wakeTableImage,
-  },
-  {
-    name: "风机12",
-    affected: "yes",
-    turbulence: 70,
-    loss: 3,
-    confidence: "86%",
-    image: wakeTableImage,
-  },
-];
-
-export function getTurbulenceClass(value) {
-  if (value >= 80) return "wrw-value-high";
-  if (value >= 60) return "wrw-value-medium";
-  return "wrw-value-low";
-}
-
-export function getLossClass(value) {
-  if (value >= 3) return "wrw-value-high";
-  if (value >= 2) return "wrw-value-medium";
-  return "wrw-value-low";
-}
-
-export function getConfidenceClass(value) {
-  const num = parseInt(value, 10);
-  if (num >= 90) return "wrw-value-high";
-  if (num >= 80) return "wrw-value-medium";
-  return "wrw-value-low";
-}

BIN
src/views/windResourceWake/images/image(3).png


+ 75 - 8
src/views/windResourceWake/index.vue

@@ -1,5 +1,9 @@
 <template>
-  <div class="wind-resource-wake-page">
+  <div
+    v-loading="loading"
+    class="wind-resource-wake-page"
+    element-loading-background="rgba(0, 12, 24, 0.45)"
+  >
     <WrwFilterBar
       :farm.sync="selectedFarm"
       :date.sync="selectedDate"
@@ -19,8 +23,17 @@
       </button>
     </div>
 
-    <WrwOverviewTab v-show="activeTab === 'overview'" ref="overviewTab" />
-    <WrwWakeTab v-show="activeTab === 'wake'" ref="wakeTab" />
+    <WrwOverviewTab
+      v-show="activeTab === 'overview'"
+      ref="overviewTab"
+      :chart-data="wakeChartData"
+    />
+    <WrwWakeTab
+      v-show="activeTab === 'wake'"
+      ref="wakeTab"
+      :chart-data="wakeChartData"
+      :table-data="wakeTableData"
+    />
   </div>
 </template>
 
@@ -28,6 +41,17 @@
 import WrwFilterBar from "./components/WrwFilterBar.vue";
 import WrwOverviewTab from "./components/WrwOverviewTab.vue";
 import WrwWakeTab from "./components/WrwWakeTab.vue";
+import { getWakeWindFarm } from "@/api/wakeAnalyse";
+import { loadWakePageData } from "./utils/wrwWakeMapper";
+
+function getYesterday() {
+  const date = new Date();
+  date.setDate(date.getDate() - 1);
+  const year = date.getFullYear();
+  const month = String(date.getMonth() + 1).padStart(2, "0");
+  const day = String(date.getDate()).padStart(2, "0");
+  return `${year}-${month}-${day}`;
+}
 
 export default {
   name: "WindResourceWake",
@@ -40,13 +64,24 @@ export default {
     return {
       activeTab: "overview",
       selectedFarm: "",
-      selectedDate: "2024-04-28",
+      selectedDate: getYesterday(),
+      loading: false,
+      wakeChartData: {},
+      wakeTableData: [],
       tabs: [
         { key: "overview", label: "资源概览" },
         { key: "wake", label: "尾流情况" },
       ],
     };
   },
+  watch: {
+    selectedFarm() {
+      this.fetchWakeData();
+    },
+    selectedDate() {
+      this.fetchWakeData();
+    },
+  },
   mounted() {
     this.$nextTick(() => {
       this.initActiveTabCharts();
@@ -68,10 +103,42 @@ export default {
         this.$refs.wakeTab.initCharts();
       }
     },
-    handleFarmChange(data) {
-      if (!data || !data.codeNumber) return;
-      // TODO: 切换风场/组织后刷新尾流分析数据
-      console.log("当前选中:", data.companyName || data.fieldName, data);
+    handleFarmChange() {
+      // selectedFarm 由 .sync 更新,watch 触发请求
+    },
+    async fetchWakeData() {
+      if (!this.selectedFarm) return;
+
+      this.loading = true;
+      try {
+        const params = { fieldCode: this.selectedFarm };
+        if (this.selectedDate) {
+          params.datatime = this.selectedDate;
+        }
+        const res = await getWakeWindFarm(params);
+        const pageData = await loadWakePageData(res?.data);
+        this.wakeChartData = {
+          ...(pageData.charts || {}),
+          getWakeTurbineList: pageData.turbineList || res?.data?.getWakeTurbineList || [],
+        };
+        this.wakeTableData = pageData.tableData || [];
+
+        if (pageData.meta?.dataDate && !this.selectedDate) {
+          this.selectedDate = pageData.meta.dataDate;
+        }
+
+        this.$nextTick(() => {
+          this.$nextTick(() => {
+            this.initActiveTabCharts();
+          });
+        });
+      } catch (error) {
+        console.error("加载尾流分析数据失败:", error);
+        this.wakeChartData = {};
+        this.wakeTableData = [];
+      } finally {
+        this.loading = false;
+      }
     },
   },
 };

+ 43 - 2
src/views/windResourceWake/styles/windResourceWake.scss

@@ -50,6 +50,43 @@
   height: 350px;
 }
 
+.wrw-bar-charts {
+  // display: grid;
+  // grid-template-columns: repeat(2, minmax(0, 1fr));
+  // gap: 16px;
+}
+
+@media (max-width: 1024px) {
+  .wrw-bar-charts {
+    grid-template-columns: 1fr;
+  }
+}
+
+.wrw-chart--image {
+  display: block;
+  min-height: 300px;
+  background: var(--wrw-chart-grid-bg, rgba(6, 28, 48, 0.4));
+}
+
+.wrw-chart-empty {
+  min-height: 180px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--wrw-chart-empty, #8db6d1);
+  font-size: 13px;
+}
+
+.wrw-table-empty {
+  min-height: 120px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--wrw-chart-empty, #8db6d1);
+  font-size: 13px;
+  padding: 24px 0;
+}
+
 .wrw-overview-section {
   margin-top: 18px;
 }
@@ -271,7 +308,10 @@ html:not([data-theme="tech-blue"]) {
     color: var(--wrw-page-text, #303133);
 
     .el-loading-mask {
-      background-color: var(--wrw-loading-bg, rgba(255, 255, 255, 0.72)) !important;
+      background-color: var(
+        --wrw-loading-bg,
+        rgba(255, 255, 255, 0.72)
+      ) !important;
     }
   }
 
@@ -335,7 +375,8 @@ html:not([data-theme="tech-blue"]) {
   .wrw-overview-section-title {
     margin: 20px 0 10px;
     padding-left: 10px;
-    border-left: 4px solid var(--wrw-section-accent, var(--primary-color, #82780c));
+    border-left: 4px solid
+      var(--wrw-section-accent, var(--primary-color, #82780c));
     color: var(--wrw-section-title, #303133);
     font-size: 14px;
     font-weight: 600;

+ 72 - 98
src/views/windResourceWake/utils/overviewChartOptions.js

@@ -1,5 +1,5 @@
-import { turbineNames } from "../data/mockData";
 import { readCssVar } from "@/utils/chartTheme";
+import { formatWakeMetric } from "./wrwTableUtils";
 
 function getOverviewTheme() {
   return {
@@ -51,61 +51,36 @@ const colorList = [
   "#D4B86A",
 ];
 
-function buildDistSeries() {
-  const speedBins = Array.from({ length: 52 }, (_, i) =>
-    Number((i * 0.5).toFixed(1))
-  );
-  const baseDist = speedBins.map((x) => {
-    const narrowPeak = 0.135 * Math.exp(-Math.pow((x - 3.15) / 0.34, 2));
-    const shoulder = 0.115 * Math.exp(-Math.pow((x - 5.5) / 2.35, 2));
-    const lowWind = 0.035 * Math.exp(-Math.pow((x - 1.7) / 0.72, 2));
-    const tail = x > 7 ? 0.022 * Math.exp(-(x - 7) / 1.9) : 0;
-    return Number(
-      Math.min(0.24, narrowPeak + shoulder + lowWind + tail).toFixed(4)
-    );
-  });
-
-  return turbineNames.map((name, idx) => {
-    const peakLift = [0.012, 0.006, 0.032, -0.005, 0.0, 0.018, 0.006, 0.012, -0.008][idx];
-    const scale = 0.92 + idx * 0.018;
-    const data = speedBins.map((x, i) => {
-      const localPeak = Math.abs(x - 3.1) < 0.35 ? peakLift : 0;
-      const phase = Math.sin((i + idx) * 0.7) * 0.003;
-      return [
-        x,
-        Number(
-          Math.max(0, baseDist[i] * scale + localPeak + phase).toFixed(4)
-        ),
-      ];
-    });
-    return {
-      name,
-      type: "line",
-      step: "middle",
-      symbol: "none",
+function buildDistSeriesFromApi(data) {
+  if (!data?.turbines?.length) return null;
+  return data.turbines.map((turbine, idx) => ({
+    name: turbine.displayName || turbine.engineId || `机组${idx + 1}`,
+    type: "line",
+    step: "middle",
+    symbol: "none",
+    lineStyle: {
+      width: idx === data.turbines.length - 1 ? 2.2 : 1.8,
+      color: turbine.color || colorList[idx % colorList.length],
+      cap: "round",
+      join: "round",
+    },
+    emphasis: {
+      focus: "series",
       lineStyle: {
-        width: idx === 8 ? 2.2 : 1.8,
-        color: colorList[idx],
-        cap: "round",
-        join: "round",
+        width: 2.8,
+        shadowBlur: 12,
+        shadowColor: `${turbine.color || colorList[idx % colorList.length]}66`,
       },
-      emphasis: {
-        focus: "series",
-        lineStyle: {
-          width: 2.8,
-          shadowBlur: 12,
-          shadowColor: `${colorList[idx]}66`,
-        },
-      },
-      blur: { lineStyle: { opacity: 0.2 } },
-      data,
-    };
-  });
+    },
+    blur: { lineStyle: { opacity: 0.2 } },
+    data: Array.isArray(turbine.points) ? turbine.points : [],
+  }));
 }
 
-export function getWindDistOption() {
+export function getWindDistOption(data) {
   const overviewTheme = getOverviewTheme();
   const overviewTooltip = getOverviewTooltip(overviewTheme);
+  const apiSeries = buildDistSeriesFromApi(data);
 
   return {
     animationDuration: 800,
@@ -126,6 +101,7 @@ export function getWindDistOption() {
     tooltip: {
       ...overviewTooltip,
       trigger: "axis",
+      valueFormatter: (value) => formatWakeMetric(value),
       axisPointer: {
         type: "cross",
         crossStyle: { color: overviewTheme.pointerCross },
@@ -151,39 +127,39 @@ export function getWindDistOption() {
     },
     xAxis: {
       type: "value",
-      min: 0,
-      max: 25,
-      interval: 5,
-      name: "风速 (m/s)",
+      min: data?.xAxis?.min ?? 0,
+      max: data?.xAxis?.max ?? 25,
+      interval: data?.xAxis?.interval ?? 5,
+      name: data?.xAxis?.name || "风速 (m/s)",
       nameLocation: "middle",
       nameGap: 32,
       nameTextStyle: { color: overviewTheme.text, fontSize: 12 },
       axisLine: { lineStyle: { color: overviewTheme.axis } },
       axisTick: { show: true, lineStyle: { color: overviewTheme.axis } },
-      axisLabel: { color: overviewTheme.text, fontSize: 10 },
+      axisLabel: { color: overviewTheme.text, fontSize: 10, formatter: (value) => formatWakeMetric(value) },
       splitLine: {
         lineStyle: { color: overviewTheme.split, type: "dashed" },
       },
     },
     yAxis: {
       type: "value",
-      name: "概率密度",
+      name: data?.yAxis?.name || "概率密度",
       nameTextStyle: { color: overviewTheme.text, fontSize: 12 },
-      min: 0,
-      max: 0.24,
-      interval: 0.05,
+      min: data?.yAxis?.min ?? 0,
+      max: data?.yAxis?.max ?? 0.24,
+      interval: data?.yAxis?.interval ?? 0.05,
       axisLine: { show: true, lineStyle: { color: overviewTheme.axis } },
       axisTick: { show: true, lineStyle: { color: overviewTheme.axis } },
       axisLabel: {
         color: overviewTheme.text,
         fontSize: 10,
-        formatter: "{value}",
+        formatter: (value) => formatWakeMetric(value),
       },
       splitLine: {
         lineStyle: { color: overviewTheme.split, type: "dashed" },
       },
     },
-    series: buildDistSeries(),
+    series: apiSeries || [],
   };
 }
 
@@ -222,50 +198,47 @@ export const ROSE_COLORS = [
   "#E86B45",
 ];
 
-const ROSE_PREFERRED_DIR = [2, 2, 2, 2, 4, 12, 2, 2, 2];
-
-function buildRoseBandData(turbineIndex, bandIdx) {
-  const dirPeak = ROSE_PREFERRED_DIR[turbineIndex];
-  const base =
-    bandIdx === 0
-      ? 2.35
-      : bandIdx === 1
-      ? 2.05
-      : bandIdx === 2
-      ? 1.55
-      : bandIdx === 3
-      ? 0.78
-      : 0.26;
-
-  return ROSE_DIRECTIONS.map((_, dIdx) => {
-    const distance = Math.min(
-      Math.abs(dIdx - dirPeak),
-      16 - Math.abs(dIdx - dirPeak)
-    );
-    const radial = Math.max(0, 1 - distance / 6);
-    const jitter =
-      (((dIdx + turbineIndex * 3 + bandIdx * 5) % 7) - 3) * 0.06;
-    const sectorBoost = dIdx === dirPeak ? 0.8 + (turbineIndex % 3) * 0.25 : 0;
-    return Number(
-      Math.max(0.05, base + radial * base * 1.85 + sectorBoost + jitter).toFixed(
-        2
-      )
-    );
+function getRoseRadiusMax(turbineRose) {
+  if (!turbineRose?.bands?.length) return 14;
+  let max = 0;
+  turbineRose.bands.forEach((band) => {
+    (band.values || []).forEach((value) => {
+      const num = Number(value);
+      if (Number.isFinite(num) && num > max) max = num;
+    });
   });
+  if (max <= 0) return 14;
+  const interval = max <= 5 ? 1 : max <= 20 ? 3.5 : Math.ceil(max / 4);
+  return Math.ceil(max / interval) * interval;
+}
+
+function getRoseBandValues(turbineRose, bandName, bandIdx) {
+  const bands = turbineRose?.bands || [];
+  const matched = bands.find((item) => item.band === bandName);
+  if (matched && Array.isArray(matched.values)) return matched.values;
+  return bands[bandIdx]?.values || [];
 }
 
-export function getSingleWindRoseOption(turbineIndex) {
+export function getSingleWindRoseOption(turbineIndex, roseData, turbineItem) {
   const overviewTheme = getOverviewTheme();
   const overviewTooltip = getOverviewTooltip(overviewTheme);
 
-  const series = ROSE_SPEED_BANDS.map((band, bandIdx) => ({
+  const speedBands = roseData?.speedBands || ROSE_SPEED_BANDS;
+  const directions = roseData?.directions || ROSE_DIRECTIONS;
+  const turbineRose =
+    turbineItem ||
+    (Array.isArray(roseData?.turbines) ? roseData.turbines[turbineIndex] : null);
+  const radiusMax = getRoseRadiusMax(turbineRose);
+  const radiusInterval = radiusMax <= 5 ? 1 : radiusMax <= 20 ? 3.5 : radiusMax / 4;
+
+  const series = speedBands.map((band, bandIdx) => ({
     name: band,
     type: "bar",
     coordinateSystem: "polar",
     stack: "wind",
     barWidth: "78%",
     itemStyle: {
-      color: ROSE_COLORS[bandIdx],
+      color: ROSE_COLORS[bandIdx % ROSE_COLORS.length],
       opacity: 0.96,
       borderColor: "rgba(255, 255, 255, 0.32)",
       borderWidth: 0.4,
@@ -280,7 +253,7 @@ export function getSingleWindRoseOption(turbineIndex) {
         shadowColor: "rgba(146, 193, 233, 0.5)",
       },
     },
-    data: buildRoseBandData(turbineIndex, bandIdx),
+    data: getRoseBandValues(turbineRose, band, bandIdx),
   }));
 
   return {
@@ -293,7 +266,7 @@ export function getSingleWindRoseOption(turbineIndex) {
     },
     angleAxis: {
       type: "category",
-      data: ROSE_DIRECTIONS,
+      data: directions,
       boundaryGap: false,
       clockwise: true,
       startAngle: 90,
@@ -313,12 +286,13 @@ export function getSingleWindRoseOption(turbineIndex) {
     },
     radiusAxis: {
       min: 0,
-      max: 14,
-      interval: 3.5,
+      max: radiusMax,
+      interval: radiusInterval,
       axisLabel: {
         color: overviewTheme.text,
         fontSize: 8,
         showMinLabel: false,
+        formatter: (value) => formatWakeMetric(value),
       },
       axisLine: { show: false },
       axisTick: { show: false },
@@ -337,7 +311,7 @@ export function getSingleWindRoseOption(turbineIndex) {
       trigger: "item",
       formatter: (p) => {
         const dir = p.name || "—";
-        return `${p.marker}${p.seriesName}<br/>方向 ${dir} · 频次 ${p.value}`;
+        return `${p.marker}${p.seriesName}<br/>方向 ${dir} · 频次 ${formatWakeMetric(p.value)}`;
       },
     },
     series,

+ 311 - 162
src/views/windResourceWake/utils/wakeChartOptions.js

@@ -1,4 +1,5 @@
 import { readCssVar } from "@/utils/chartTheme";
+import { formatWakeMetric, formatWakePercent } from "./wrwTableUtils";
 
 function getWakeChartTheme() {
   return {
@@ -9,96 +10,268 @@ function getWakeChartTheme() {
   };
 }
 
-const scatterData = [
-  { name: "L9Xcm4u0", value: [333330, 4173350, 7.8] },
-  { name: "o23d6LBB", value: [333320, 4173300, 7.5] },
-  { name: "0DdPWnck", value: [333310, 4173380, 4.8] },
-  { name: "CZbp7no4", value: [333290, 4174350, 8.1] },
-  { name: "4dCWnzLK", value: [333300, 4174380, 2.5] },
-  { name: "3dORuyAV", value: [333240, 4173720, 3.4] },
-  { name: "SPlyMKa9", value: [333270, 4173320, 4.5] },
-  { name: "86n2qgiM", value: [333230, 4172950, 4.5] },
-  { name: "BCKL9lkw", value: [333420, 4173200, 34.6] },
-  { name: "qAqnO0YH", value: [333280, 4172580, 0.2] },
-  { name: "I7EJ4nPt", value: [333360, 4173450, 59.6] },
-  { name: "RQov6gm3", value: [333315, 4172900, 4.1] },
-  { name: "7xKAWgZW", value: [333410, 4172980, 84.7] },
-  { name: "olFRqfSf", value: [333400, 4173380, 12.8] },
-  { name: "NSfopOW3", value: [333370, 4173750, 2.4] },
-  { name: "3jyH154U", value: [333270, 4172520, 0.4] },
-];
+const SCATTER1_X_AXIS_NAME = "东西方向坐标(千米)";
+const SCATTER1_Y_AXIS_NAME = "南北方向坐标(千米)";
 
-const scatter2Data = [
-  { name: "L9Xcm4u0", value: [7.8, 92], itemStyle: { color: "#C0C0C0" } },
-  { name: "o23d6LBB", value: [7.5, 78], itemStyle: { color: "#D4C4B0" } },
-  { name: "0DdPWnck", value: [4.8, 85], itemStyle: { color: "#B0B0B0" } },
-  { name: "CZbp7no4", value: [8.1, 88], itemStyle: { color: "#A0A0A0" } },
-  { name: "4dCWnzLK", value: [2.5, 79], itemStyle: { color: "#C8C8C8" } },
-  { name: "3dORuyAV", value: [3.4, 95], itemStyle: { color: "#4CAF50" } },
-  { name: "SPlyMKa9", value: [4.5, 79], itemStyle: { color: "#C0C0C0" } },
-  { name: "86n2qgiM", value: [4.5, 91], itemStyle: { color: "#66BB6A" } },
-  { name: "BCKL9lkw", value: [34.6, 78], itemStyle: { color: "#D4C4B0" } },
-  { name: "qAqnO0YH", value: [0.2, 93], itemStyle: { color: "#4CAF50" } },
-  { name: "I7EJ4nPt", value: [59.6, 82], itemStyle: { color: "#CD853F" } },
-  { name: "RQov6gm3", value: [4.1, 94], itemStyle: { color: "#4CAF50" } },
-  { name: "7xKAWgZW", value: [84.7, 87], itemStyle: { color: "#E85D5D" } },
-  { name: "olFRqfSf", value: [12.8, 88], itemStyle: { color: "#66BB6A" } },
-  { name: "NSfopOW3", value: [2.4, 92], itemStyle: { color: "#4CAF50" } },
-  { name: "3jyH154U", value: [0.4, 67], itemStyle: { color: "#E85D5D" } },
-];
+function formatScatter1AxisValue(value) {
+  return formatWakeMetric(Number(value) / 1000);
+}
+
+function mapLayoutScatterPoints(points) {
+  if (!Array.isArray(points) || !points.length) return [];
+  return points.map((item) => ({
+    name: item.engineId || item.name || "",
+    value: [
+      item.east ?? item.value?.[0] ?? 0,
+      item.north ?? item.value?.[1] ?? 0,
+      item.lossPercent ?? item.velocityDeficit ?? item.value?.[2] ?? 0,
+    ],
+  }));
+}
+
+function mapWakeLossConfidentPoints(points) {
+  if (!Array.isArray(points) || !points.length) return [];
+  return points.map((item) => {
+    const confidenceRaw =
+      item.confidencePercent ?? item.confidentLevel ?? item.value?.[1] ?? 0;
+    const confidenceNum = Number(confidenceRaw);
+    const confidencePercent =
+      confidenceNum <= 1 ? confidenceNum * 100 : confidenceNum;
+    const loss =
+      item.velocityDeficit ?? item.lossPercent ?? item.value?.[0] ?? 0;
+    return {
+      name: item.engineId || item.name || "",
+      value: [loss, confidencePercent],
+    };
+  });
+}
+
+function shortEngineLabel(name) {
+  const text = String(name || "");
+  const match = text.match(/(\d+号风机)/);
+  return match ? match[1] : text;
+}
+
+const SCATTER2_QUADRANT_LABEL_LEFT = "72%";
+
+function computeScatter2Bounds(points, data = {}) {
+  const losses = points
+    .map((item) => Number(item.value?.[0]))
+    .filter((num) => Number.isFinite(num));
+  const confidences = points
+    .map((item) => Number(item.value?.[1]))
+    .filter((num) => Number.isFinite(num));
+
+  const lossMaxRaw = losses.length ? Math.max(...losses) : 30;
+  const confMinRaw = confidences.length ? Math.min(...confidences) : 65;
+  const confMaxRaw = confidences.length ? Math.max(...confidences) : 100;
+
+  return {
+    xMin: data?.xAxis?.min ?? 0,
+    xMax: data?.xAxis?.max ?? Math.ceil(lossMaxRaw * 1.15),
+    yMin:
+      data?.yAxis?.min ??
+      Math.max(0, Math.floor(confMinRaw - Math.max(5, confMinRaw * 0.05))),
+    yMax:
+      data?.yAxis?.max ??
+      Math.min(100, Math.ceil(confMaxRaw + Math.max(2, confMaxRaw * 0.02))),
+  };
+}
+
+function mapLossDelBarItems(items) {
+  if (!Array.isArray(items) || !items.length) {
+    return {
+      categories: [],
+      lossPercentData: [],
+      turbulenceData: [],
+    };
+  }
+  return {
+    categories: items.map((item) => item.engineId || item.name || ""),
+    lossPercentData: items.map(
+      (item) => item.lossPercent ?? item.velocityDeficit ?? item.loss ?? 0,
+    ),
+    turbulenceData: items.map(
+      (item) =>
+        item.turbulenceIntensity ?? item.turbulence ?? item.delPercent ?? 0,
+    ),
+  };
+}
+
+function buildLossDelSingleBarOption({
+  theme,
+  title,
+  categories,
+  data,
+  seriesName,
+  color,
+  yAxisName,
+}) {
+  return {
+    title: {
+      text: title,
+      left: "center",
+      top: 10,
+      textStyle: { color: theme.title, fontSize: 14, fontWeight: "bold" },
+    },
+    tooltip: {
+      trigger: "axis",
+      valueFormatter: (value) => formatWakeMetric(value),
+    },
+    grid: {
+      left: "3%",
+      right: "4%",
+      bottom: "15%",
+      top: "18%",
+      containLabel: true,
+    },
+    dataZoom: [
+      {
+        type: "slider",
+        show: true,
+        xAxisIndex: 0,
+        height: 18,
+        bottom: 4,
+        start: 0,
+        end:
+          categories.length > 0
+            ? Math.min(100, (12 / categories.length) * 100)
+            : 100,
+        textStyle: {
+          color: theme.text,
+        },
+        brushSelect: false,
+        handleSize: 12,
+      },
+      {
+        type: "inside",
+        xAxisIndex: 0,
+        zoomOnMouseWheel: true,
+        moveOnMouseMove: true,
+      },
+    ],
+    xAxis: {
+      type: "category",
+      data: categories,
+      axisLabel: { color: theme.text, rotate: 45, fontSize: 10 },
+      axisLine: { lineStyle: { color: theme.axis } },
+    },
+    yAxis: {
+      type: "value",
+      name: yAxisName,
+      nameTextStyle: { color, fontSize: 12 },
+      min: 0,
+      axisLabel: { color, formatter: (value) => formatWakeMetric(value) },
+      splitLine: { lineStyle: { color: theme.split } },
+    },
+    series: [
+      {
+        name: seriesName,
+        type: "bar",
+        barWidth: "45%",
+        data,
+        itemStyle: { color },
+        label: {
+          show: true,
+          position: "top",
+          color,
+          fontSize: 10,
+          formatter: (params) => formatWakeMetric(params.value),
+        },
+      },
+    ],
+  };
+}
+
+export function getLossDelLossBarOption(data) {
+  const theme = getWakeChartTheme();
+  const barMapped = mapLossDelBarItems(data?.items);
+
+  return buildLossDelSingleBarOption({
+    theme,
+    title: data?.lossTitle || "各机组日尺度发电量损失",
+    categories: barMapped.categories,
+    data: barMapped.lossPercentData,
+    seriesName: "发电量损失",
+    color: "#E85D5D",
+    yAxisName: "发电量损失(%)",
+  });
+}
 
-const barCategories = scatterData.map((item) => item.name);
-const lossData = scatterData.map((item) => item.value[2]);
-const delData = [
-  50.8, 54.6, 19.9, 17.2, 6.6, 20.7, 36.9, 24.6, 72.9, 3.5, 348.4, 13.5, 324.4,
-  14.1, 34.9, 3.6,
-];
+export function getLossDelTurbulenceBarOption(data) {
+  const theme = getWakeChartTheme();
+  const barMapped = mapLossDelBarItems(data?.items);
 
-export function getScatter1Option() {
+  return buildLossDelSingleBarOption({
+    theme,
+    title: data?.turbulenceTitle || "各机组附加湍流强度",
+    categories: barMapped.categories,
+    data: barMapped.turbulenceData,
+    seriesName: "附加湍流强度",
+    color: "#4D8BFF",
+    yAxisName: "附加湍流强度",
+  });
+}
+
+export function getScatter1Option(data) {
   const theme = getWakeChartTheme();
+  const points = mapLayoutScatterPoints(data?.points);
+  const visualMax = data?.visualMap?.max ?? 85;
 
   return {
     backgroundColor: "transparent",
     title: {
-      text: "风电场布局与机组日尺度发电量损失图(2025-10-24)",
+      text: data?.title || "风电场布局与机组日尺度发电量损失图",
       left: "center",
       top: 10,
       textStyle: { color: theme.title, fontSize: 14, fontWeight: "bold" },
     },
     tooltip: {
       formatter(params) {
-        const val = params.value || params.data;
-        return `${params.name}<br/>损失: ${Array.isArray(val) ? val[2] : val}%`;
+        const val = params.value || params.data?.value || [];
+        const east = Array.isArray(val) ? val[0] : 0;
+        const north = Array.isArray(val) ? val[1] : 0;
+        const loss = Array.isArray(val) ? val[2] : val;
+        return [
+          params.name,
+          `${SCATTER1_X_AXIS_NAME}: ${formatScatter1AxisValue(east)}`,
+          `${SCATTER1_Y_AXIS_NAME}: ${formatScatter1AxisValue(north)}`,
+          `损失: ${formatWakeMetric(loss)}%`,
+        ].join("<br/>");
       },
     },
     grid: { left: "12%", right: "15%", bottom: "13%", top: "15%" },
     xAxis: {
-      name: "东西方向坐标(米)",
-      nameTextStyle: { color: theme.text, fontSize: 12, padding: [10, 0, 0, 0] },
+      name: SCATTER1_X_AXIS_NAME,
+      nameTextStyle: {
+        color: theme.text,
+        fontSize: 12,
+        padding: [10, 0, 0, 0],
+      },
       nameLocation: "middle",
-      min: 333200,
-      max: 333450,
+      min: data?.xAxis?.min,
+      max: data?.xAxis?.max,
       axisLabel: {
         color: theme.text,
-        formatter(value) {
-          return (value / 1000000).toFixed(4);
-        },
+        formatter: formatScatter1AxisValue,
       },
       axisLine: { lineStyle: { color: theme.axis } },
       axisTick: { show: true },
       splitLine: { lineStyle: { color: theme.split } },
     },
     yAxis: {
-      name: "南北方向坐标(米)",
-      nameTextStyle: { color: theme.text, fontSize: 12, padding: [-50, 0, 35, 0] },
+      name: SCATTER1_Y_AXIS_NAME,
+      nameTextStyle: {
+        color: theme.text,
+        fontSize: 12,
+        padding: [-50, 0, 35, 0],
+      },
       nameLocation: "middle",
-      min: 4172500,
-      max: 4174000,
+      min: data?.yAxis?.min,
+      max: data?.yAxis?.max,
       axisLabel: {
         color: theme.text,
-        formatter(value) {
-          return (value / 1000000).toFixed(4);
-        },
+        formatter: formatScatter1AxisValue,
       },
       axisLine: { lineStyle: { color: theme.axis } },
       axisTick: { show: true },
@@ -109,8 +282,8 @@ export function getScatter1Option() {
       orient: "vertical",
       right: 0,
       top: "center",
-      min: 0,
-      max: 85,
+      min: data?.visualMap?.min ?? 0,
+      max: visualMax,
       inRange: {
         color: ["#E8DFD9", "#DCB699", "#E8966A", "#DC4E4E", "#B82E2E"],
       },
@@ -131,161 +304,137 @@ export function getScatter1Option() {
           fontSize: 9,
           formatter: "{b}",
         },
-        data: scatterData,
+        data: points,
         itemStyle: { borderColor: "#999", borderWidth: 1 },
       },
     ],
   };
 }
 
-export function getScatter2Option() {
+export function getScatter2Option(data) {
   const theme = getWakeChartTheme();
+  const points = mapWakeLossConfidentPoints(data?.points);
+  const bounds = computeScatter2Bounds(points, data);
 
   return {
     title: {
-      text: "日尺度损失与可信度散点图(2025-10-24)",
+      text: data?.title || "尾流速度损耗与置信度散点图",
       left: "center",
       top: 10,
       textStyle: { color: theme.title, fontSize: 14 },
     },
-    grid: { left: "8%", right: "8%", bottom: "13%", top: "15%" },
+    tooltip: {
+      formatter(params) {
+        const val = params.value || [];
+        return `${params.name}<br/>尾流速度损耗: ${formatWakeMetric(val[0])}<br/>结果可信度: ${formatWakePercent(val[1])}`;
+      },
+    },
+    grid: { left: "8%", right: "15%", bottom: "13%", top: "15%" },
     xAxis: {
-      name: "发电量损失(%)",
-      nameTextStyle: { color: theme.text, fontSize: 12, padding: [10, 0, 0, 0] },
+      name: data?.xAxis?.name || "尾流速度损耗",
+      nameTextStyle: {
+        color: theme.text,
+        fontSize: 12,
+        padding: [10, 0, 0, 0],
+      },
       nameLocation: "middle",
-      min: 0,
-      max: 90,
-      axisLabel: { color: theme.text },
+      min: bounds.xMin,
+      max: bounds.xMax,
+      axisLabel: {
+        color: theme.text,
+        formatter: (value) => formatWakeMetric(value),
+      },
       splitLine: { lineStyle: { color: theme.split } },
       axisLine: { lineStyle: { color: theme.axis } },
     },
     yAxis: {
-      name: "结果可信度(%)",
+      name: data?.yAxis?.name || "结果可信度(%)",
       nameTextStyle: { color: theme.text, fontSize: 12 },
-      min: 65,
-      max: 98,
-      axisLabel: { color: theme.text },
+      min: bounds.yMin,
+      max: bounds.yMax,
+      axisLabel: {
+        color: theme.text,
+        formatter: (value) => formatWakeMetric(value),
+      },
       splitLine: { lineStyle: { color: theme.split } },
       axisLine: { lineStyle: { color: theme.axis } },
     },
+    visualMap: {
+      type: "continuous",
+      orient: "vertical",
+      right: 0,
+      top: "center",
+      min: bounds.yMin,
+      max: bounds.yMax,
+      dimension: 1,
+      inRange: {
+        color: ["#E85D5D", "#E8966A", "#98d268", "#6bbf63", "#4CAF50", "#006837"],
+      },
+      textStyle: { color: theme.text, fontSize: 11 },
+      text: [
+        `结果可信度 ${formatWakeMetric(bounds.yMax)}%`,
+        `${formatWakeMetric(bounds.yMin)}%`,
+      ],
+      calculable: true,
+      itemWidth: 15,
+    },
     graphic: [
       {
         type: "text",
-        left: "70%",
-        top: "25%",
+        left: SCATTER2_QUADRANT_LABEL_LEFT,
+        top: "22%",
         style: {
           text: "高损失 高可信度",
           fill: "#4CAF50",
           fontSize: 14,
           fontWeight: "bold",
+          textAlign: "left",
         },
       },
       {
         type: "text",
-        left: "65%",
-        top: "75%",
+        left: SCATTER2_QUADRANT_LABEL_LEFT,
+        top: "72%",
         style: {
           text: "高损失 低可信度",
           fill: "#E85D5D",
           fontSize: 14,
           fontWeight: "bold",
+          textAlign: "left",
         },
       },
     ],
     series: [
       {
         type: "scatter",
-        symbolSize: 15,
+        symbolSize: 16,
         label: {
           show: true,
           position: "top",
           color: theme.text,
-          fontSize: 10,
-          formatter: "{b}",
+          fontSize: 9,
+          formatter: (params) => shortEngineLabel(params.name),
         },
-        data: scatter2Data,
-        itemStyle: { shadowBlur: 8, shadowColor: "rgba(0, 0, 0, 0.3)" },
-      },
-    ],
-  };
-}
-
-export function getBarOption() {
-  const theme = getWakeChartTheme();
-
-  return {
-    title: {
-      text: "各机组日尺度发电量损失与相对DEL指标(2025-10-24)",
-      left: "center",
-      top: 10,
-      textStyle: { color: theme.title, fontSize: 16, fontWeight: "bold" },
-    },
-    legend: {
-      data: ["发电量损失(%)", "相对DEL指标(%)"],
-      top: 40,
-      textStyle: { color: theme.text },
-    },
-    grid: {
-      left: "3%",
-      right: "4%",
-      bottom: "15%",
-      top: "20%",
-      containLabel: true,
-    },
-    xAxis: {
-      type: "category",
-      data: barCategories,
-      axisLabel: { color: theme.text, rotate: 45, fontSize: 10 },
-      axisLine: { lineStyle: { color: theme.axis } },
-    },
-    yAxis: [
-      {
-        type: "value",
-        name: "发电量损失(%)",
-        nameTextStyle: { color: "#E85D5D", fontSize: 12 },
-        min: 0,
-        max: 100,
-        axisLabel: { color: "#E85D5D", formatter: "{value}%" },
-        splitLine: { lineStyle: { color: theme.split } },
-      },
-      {
-        type: "value",
-        name: "相对DEL指标(%)",
-        nameTextStyle: { color: "#4D8BFF", fontSize: 12 },
-        min: 0,
-        max: 400,
-        axisLabel: { color: "#4D8BFF", formatter: "{value}%" },
-        splitLine: { show: false },
-      },
-    ],
-    series: [
-      {
-        name: "发电量损失(%)",
-        type: "bar",
-        barWidth: "30%",
-        data: lossData,
-        itemStyle: { color: "#E85D5D" },
-        label: {
-          show: true,
-          position: "top",
-          color: "#E85D5D",
-          fontSize: 10,
+        labelLayout: {
+          hideOverlap: false,
+          moveOverlap: "shiftY",
         },
-      },
-      {
-        name: "相对DEL指标(%)",
-        type: "bar",
-        barWidth: "30%",
-        yAxisIndex: 1,
-        data: delData,
-        itemStyle: { color: "#4D8BFF" },
-        label: {
-          show: true,
-          position: "top",
-          color: "#4D8BFF",
-          fontSize: 10,
+        emphasis: {
+          scale: 1.2,
+          label: {
+            show: true,
+            formatter: (params) => params.name,
+          },
+        },
+        data: points,
+        itemStyle: {
+          borderColor: "rgba(255, 255, 255, 0.45)",
+          borderWidth: 1,
+          shadowBlur: 8,
+          shadowColor: "rgba(0, 0, 0, 0.3)",
         },
       },
     ],
   };
 }
+

+ 51 - 0
src/views/windResourceWake/utils/wrwRemoteData.js

@@ -0,0 +1,51 @@
+export function isRemoteImageUrl(url) {
+  return /\.(png|jpe?g|gif|webp|svg)(\?.*)?$/i.test(String(url || ""));
+}
+
+function getHealthApiBase() {
+  if (process.env.NODE_ENV === "development") {
+    return "/healthApi";
+  }
+  return window?._BASE_CONFIG?.HEALTH_API || "/healthApi";
+}
+
+export function resolveResourceUrl(path) {
+  if (!path) return "";
+  const str = String(path).trim();
+  if (/^https?:\/\//i.test(str)) return str;
+  if (str.startsWith("//")) {
+    return `${window.location.protocol}${str}`;
+  }
+  if (str.startsWith("/healthApi")) {
+    return str;
+  }
+
+  const base = getHealthApiBase();
+  const normalizedPath = str.startsWith("/") ? str : `/${str}`;
+  return `${String(base).replace(/\/$/, "")}${normalizedPath}`;
+}
+
+function unwrapApiJsonPayload(json) {
+  if (!json || typeof json !== "object") return json;
+  if (json.code === 200 && json.data != null) {
+    return json.data;
+  }
+  return json;
+}
+
+export async function fetchRemoteJson(pathOrUrl) {
+  if (!pathOrUrl) return null;
+  const resolved = resolveResourceUrl(pathOrUrl);
+  if (!resolved) return null;
+
+  const response = await fetch(resolved, {
+    method: "GET",
+    credentials: "include",
+  });
+  if (!response.ok) {
+    throw new Error(`请求失败: ${response.status}`);
+  }
+
+  const json = await response.json();
+  return unwrapApiJsonPayload(json);
+}

+ 46 - 0
src/views/windResourceWake/utils/wrwTableUtils.js

@@ -0,0 +1,46 @@
+/** 尾流页指标统一保留 2 位小数 */
+export function formatWakeMetric(value) {
+  if (value == null || value === "") return "";
+  const num = Number(value);
+  if (!Number.isFinite(num)) return String(value);
+  return num.toFixed(2);
+}
+
+/** 置信度:接口 0~1 为小数比例,>1 为百分数 */
+export function formatWakePercent(value) {
+  if (value == null || value === "") return "";
+  const num = Number(value);
+  if (!Number.isFinite(num)) return String(value);
+  const pct = num <= 1 ? num * 100 : num;
+  return `${pct.toFixed(2)}%`;
+}
+
+function parseWakePercent(value) {
+  const raw = String(value).replace("%", "").trim();
+  const num = Number(raw);
+  return Number.isFinite(num) ? num : NaN;
+}
+
+export function getTurbulenceClass(value) {
+  const num = Number(value);
+  if (!Number.isFinite(num)) return "";
+  if (num >= 10) return "wrw-value-high";
+  if (num >= 1) return "wrw-value-medium";
+  return "wrw-value-low";
+}
+
+export function getLossClass(value) {
+  const num = Number(value);
+  if (!Number.isFinite(num)) return "";
+  if (num >= 0.5) return "wrw-value-high";
+  if (num >= 0.2) return "wrw-value-medium";
+  return "wrw-value-low";
+}
+
+export function getConfidenceClass(value) {
+  const num = parseWakePercent(value);
+  if (!Number.isFinite(num)) return "";
+  if (num >= 90) return "wrw-value-high";
+  if (num >= 80) return "wrw-value-medium";
+  return "wrw-value-low";
+}

+ 412 - 0
src/views/windResourceWake/utils/wrwWakeMapper.js

@@ -0,0 +1,412 @@
+import {
+  fetchRemoteJson,
+  isRemoteImageUrl,
+  resolveResourceUrl,
+} from "./wrwRemoteData";
+import { formatWakeMetric, formatWakePercent } from "./wrwTableUtils";
+
+/** 接口 *Path 字段 → 前端图表数据 key */
+const CHART_PATH_MAP = [
+  { chartKey: "windSpeedDistribution", pathKey: "windSpeedDistributionChart" },
+  { chartKey: "wakeRose", pathKey: "wakeRoseDiagramPath" },
+  { chartKey: "wakeDistribute", pathKey: "wakeDistributePath" },
+  { chartKey: "wakeLossConfident", pathKey: "wakeLossConfidentPath" },
+  { chartKey: "velocityDeficitBar", pathKey: "velocityDeficitPath" },
+];
+
+const CHART_PAYLOAD_ALIASES = {
+  windSpeedDistribution: ["windSpeedDistribution", "windSpeedDistributionChart"],
+  wakeRose: ["wakeRose", "windRose"],
+  wakeDistribute: ["wakeDistribute", "layoutLossScatter"],
+  wakeLossConfident: ["wakeLossConfident", "lossConfidenceScatter"],
+  velocityDeficitBar: ["velocityDeficitBar", "velocityDeficit", "lossDelBar"],
+};
+
+function emptyPageData() {
+  return {
+    charts: {},
+    tableData: [],
+    turbineNames: [],
+    wakeDescription: "",
+    meta: {},
+  };
+}
+
+function unwrapWindRosePayload(payload) {
+  if (!payload || typeof payload !== "object") return null;
+
+  let current = payload;
+  for (let depth = 0; depth < 4; depth += 1) {
+    if (current.windRose && typeof current.windRose === "object") {
+      current = current.windRose;
+      continue;
+    }
+    if (current.wakeRose && typeof current.wakeRose === "object") {
+      current = current.wakeRose;
+      continue;
+    }
+    if (current.data && typeof current.data === "object") {
+      const nested =
+        current.data.windRose || current.data.wakeRose || current.data;
+      if (nested && nested !== current) {
+        current = nested;
+        continue;
+      }
+    }
+    break;
+  }
+
+  return current;
+}
+
+function normalizeChartPayload(chartKey, payload) {
+  if (!payload || typeof payload !== "object") return null;
+  if (chartKey === "wakeRose") {
+    return unwrapWindRosePayload(payload);
+  }
+  const aliases = CHART_PAYLOAD_ALIASES[chartKey] || [chartKey];
+  for (let i = 0; i < aliases.length; i += 1) {
+    if (payload[aliases[i]]) {
+      return payload[aliases[i]];
+    }
+  }
+  if (Array.isArray(payload.turbines) || Array.isArray(payload.points)) {
+    return payload;
+  }
+  if (Array.isArray(payload.items)) {
+    return payload;
+  }
+  if (Array.isArray(payload.bands)) {
+    return payload;
+  }
+  return payload;
+}
+
+function hasChartPayload(chartKey, data) {
+  if (!data || typeof data !== "object") return false;
+  if (data.__imageUrl) return true;
+  if (Array.isArray(data.points) && data.points.length) return true;
+  if (Array.isArray(data.items) && data.items.length) return true;
+  if (Array.isArray(data.turbines) && data.turbines.length) return true;
+  if (chartKey === "wakeRose" && !isEmptyWakeRose(data)) return true;
+  return false;
+}
+
+function normalizeChartsObject(rawCharts = {}, vo = {}) {
+  const charts = { ...(rawCharts || {}) };
+
+  Object.keys(CHART_PAYLOAD_ALIASES).forEach((chartKey) => {
+    if (hasChartPayload(chartKey, charts[chartKey])) return;
+
+    const aliases = CHART_PAYLOAD_ALIASES[chartKey] || [chartKey];
+    for (let i = 0; i < aliases.length; i += 1) {
+      const alias = aliases[i];
+      const sources = [charts[alias], vo[alias]];
+      for (let j = 0; j < sources.length; j += 1) {
+        const source = sources[j];
+        if (!source) continue;
+        const normalized = normalizeChartPayload(chartKey, source);
+        if (hasChartPayload(chartKey, normalized)) {
+          charts[chartKey] = normalized;
+          return;
+        }
+      }
+    }
+  });
+
+  return charts;
+}
+
+async function loadChartFromPath(chartKey, path) {
+  if (!path) return null;
+  const resolved = resolveResourceUrl(path);
+  if (isRemoteImageUrl(resolved)) {
+    return chartKey === "wakeDistribute" ? { __imageUrl: resolved } : null;
+  }
+  try {
+    const json = await fetchRemoteJson(resolved);
+    return normalizeChartPayload(chartKey, json);
+  } catch (error) {
+    console.warn(`加载尾流图表资源失败 [${chartKey}]:`, path, error);
+    return null;
+  }
+}
+
+async function loadDescriptionText(path) {
+  if (!path) return "";
+  const resolved = resolveResourceUrl(path);
+  if (isRemoteImageUrl(resolved)) return "";
+  try {
+    const response = await fetch(resolved, { method: "GET", credentials: "include" });
+    if (!response.ok) return "";
+    return response.text();
+  } catch (error) {
+    console.warn("加载尾流描述文本失败:", path, error);
+    return "";
+  }
+}
+
+function formatConfidence(confidentLevel) {
+  return formatWakePercent(confidentLevel);
+}
+
+function formatNumber(value) {
+  return formatWakeMetric(value);
+}
+
+export function mapWakeTurbineList(list) {
+  if (!Array.isArray(list)) return [];
+  return list.map((item) => ({
+    name: item.engineId || item.engineName || item.name || "—",
+    engineId: item.engineId || "",
+    affected: item.isWake === 1 || item.isWake === true ? "yes" : "no",
+    turbulence: formatNumber(item.turbulenceIntensity),
+    loss: formatNumber(item.velocityDeficit),
+    confidence: formatConfidence(item.confidentLevel),
+    image: resolveResourceUrl(item.wakeTurbinePath || item.image || ""),
+  }));
+}
+
+function extractTurbineNames(charts, turbineList) {
+  const names = [];
+  const pushName = (id) => {
+    if (id && !names.includes(id)) names.push(id);
+  };
+
+  charts.windSpeedDistribution?.turbines?.forEach((item) => {
+    pushName(item.displayName || item.engineId);
+  });
+  charts.wakeRose?.turbines?.forEach((item) => {
+    pushName(item.engineId);
+  });
+  if (Array.isArray(turbineList)) {
+    turbineList.forEach((item) => pushName(item.engineId));
+  }
+  return names;
+}
+
+function formatDataDate(dataDate) {
+  if (!dataDate) return "";
+  return String(dataDate).slice(0, 10);
+}
+
+function isEmptyWakeRose(rose) {
+  return !rose || !Array.isArray(rose.turbines) || !rose.turbines.length;
+}
+
+function hasRoseBands(turbine) {
+  return (
+    turbine &&
+    Array.isArray(turbine.bands) &&
+    turbine.bands.some((band) => Array.isArray(band.values) && band.values.length)
+  );
+}
+
+function normalizeTurbineRoseData(engineId, payload) {
+  const normalized = unwrapWindRosePayload(payload);
+  if (!normalized || typeof normalized !== "object") return null;
+
+  const meta = {
+    speedBands: normalized.speedBands,
+    directions: normalized.directions,
+  };
+
+  if (Array.isArray(normalized.turbines) && normalized.turbines.length) {
+    const matched =
+      normalized.turbines.find((item) => item.engineId === engineId) ||
+      normalized.turbines[0];
+    if (!hasRoseBands(matched)) return null;
+    return {
+      engineId: matched.engineId || engineId,
+      bands: matched.bands,
+      speedBands: meta.speedBands,
+      directions: meta.directions,
+    };
+  }
+
+  if (hasRoseBands(normalized)) {
+    return {
+      engineId: normalized.engineId || engineId,
+      bands: normalized.bands,
+      speedBands: meta.speedBands,
+      directions: meta.directions,
+    };
+  }
+
+  return null;
+}
+
+function getTurbineRosePath(item) {
+  const path =
+    item?.wakeRoseDiagramPath ||
+    item?.windRoseDiagramPath ||
+    item?.wakeRosePath ||
+    "";
+  return typeof path === "string" ? path.trim() : "";
+}
+
+function getInlineTurbineRoseSource(item) {
+  if (!item || typeof item !== "object") return null;
+
+  const pathField =
+    item.wakeRoseDiagramPath || item.windRoseDiagramPath || item.wakeRosePath;
+  if (pathField && typeof pathField === "object") {
+    return normalizeTurbineRoseData(item.engineId, pathField);
+  }
+
+  if (item.windRose || item.wakeRose) {
+    return normalizeTurbineRoseData(
+      item.engineId,
+      item.windRose || item.wakeRose,
+    );
+  }
+
+  return null;
+}
+
+async function loadTurbineRoseFromItem(item) {
+  const engineId = item?.engineId || "";
+  const inlineRose = getInlineTurbineRoseSource(item);
+  if (inlineRose) return inlineRose;
+
+  const rosePath = getTurbineRosePath(item);
+  if (!rosePath || isRemoteImageUrl(resolveResourceUrl(rosePath))) {
+    return null;
+  }
+
+  try {
+    const json = await fetchRemoteJson(rosePath);
+    return normalizeTurbineRoseData(engineId, json);
+  } catch (error) {
+    console.warn(`加载机组玫瑰图失败 [${engineId}]:`, rosePath, error);
+    return null;
+  }
+}
+
+async function loadTurbineRoseDiagrams(turbineList) {
+  if (!Array.isArray(turbineList) || !turbineList.length) {
+    return [];
+  }
+
+  const results = await Promise.all(
+    turbineList.map((item) => loadTurbineRoseFromItem(item)),
+  );
+
+  return results.filter(Boolean);
+}
+
+function mergeWakeRoseData(farmRose, turbineList, perTurbineRoses) {
+  const roseMap = new Map();
+
+  (farmRose?.turbines || []).forEach((item) => {
+    if (item?.engineId) roseMap.set(item.engineId, item);
+  });
+  perTurbineRoses.forEach((item) => {
+    if (item?.engineId) roseMap.set(item.engineId, item);
+  });
+
+  if (!roseMap.size) return farmRose || null;
+
+  const turbines = [];
+  if (Array.isArray(turbineList)) {
+    turbineList.forEach((item) => {
+      const id = item?.engineId;
+      if (id && roseMap.has(id)) {
+        turbines.push(roseMap.get(id));
+        roseMap.delete(id);
+      }
+    });
+  }
+  roseMap.forEach((item) => turbines.push(item));
+
+  const metaSource =
+    perTurbineRoses.find((item) => item.speedBands || item.directions) ||
+    farmRose ||
+    {};
+
+  return {
+    speedBands: metaSource.speedBands || farmRose?.speedBands,
+    directions: metaSource.directions || farmRose?.directions,
+    turbines: turbines.map(({ speedBands, directions, ...item }) => item),
+  };
+}
+
+export async function loadWakePageData(vo) {
+  if (!vo || typeof vo !== "object") {
+    return emptyPageData();
+  }
+
+  const turbineList = Array.isArray(vo.getWakeTurbineList)
+    ? vo.getWakeTurbineList
+    : [];
+  let charts = normalizeChartsObject(
+    vo.charts && typeof vo.charts === "object" ? vo.charts : {},
+    vo,
+  );
+
+  const [wakeDescription, perTurbineRoses] = await Promise.all([
+    loadDescriptionText(vo.wakeDiscriptionPath),
+    loadTurbineRoseDiagrams(turbineList),
+    ...CHART_PATH_MAP.filter(({ chartKey }) => chartKey !== "wakeRose").map(
+      async ({ chartKey, pathKey }) => {
+        if (charts[chartKey]) return;
+        const loaded = await loadChartFromPath(chartKey, vo[pathKey]);
+        if (loaded) {
+          charts[chartKey] = loaded;
+        }
+      },
+    ),
+  ]);
+
+  const mergedWakeRose = mergeWakeRoseData(null, turbineList, perTurbineRoses);
+  if (mergedWakeRose?.turbines?.length) {
+    charts.wakeRose = mergedWakeRose;
+  } else if (isEmptyWakeRose(charts.wakeRose)) {
+    const farmRose = await loadChartFromPath("wakeRose", vo.wakeRoseDiagramPath);
+    if (farmRose && !isEmptyWakeRose(farmRose)) {
+      charts.wakeRose = farmRose;
+    }
+  }
+
+  const tableData = mapWakeTurbineList(turbineList);
+  const turbineNames = extractTurbineNames(charts, turbineList);
+
+  if (
+    !hasChartPayload("velocityDeficitBar", charts.velocityDeficitBar) &&
+    turbineList.length
+  ) {
+    charts.velocityDeficitBar = {
+      items: turbineList.map((item) => ({
+        engineId: item.engineId,
+        lossPercent: item.lossPercent ?? item.velocityDeficit,
+        turbulenceIntensity: item.turbulenceIntensity,
+        velocityDeficit: item.velocityDeficit,
+      })),
+    };
+  }
+
+  if (
+    !hasChartPayload("wakeLossConfident", charts.wakeLossConfident) &&
+    turbineList.length
+  ) {
+    charts.wakeLossConfident = {
+      points: turbineList.map((item) => ({
+        engineId: item.engineId,
+        velocityDeficit: item.velocityDeficit,
+        confidentLevel: item.confidentLevel,
+      })),
+    };
+  }
+
+  return {
+    charts,
+    tableData,
+    turbineNames,
+    wakeDescription,
+    turbineList,
+    meta: {
+      fieldId: vo.fieldId || "",
+      dataDate: formatDataDate(vo.dataDate),
+    },
+  };
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
swagger.json


+ 12 - 0
vue.config.js

@@ -61,6 +61,11 @@ module.exports = {
   },
   devServer: {
     // contentBase: path.join(__dirname, "public"),
+    // 页面 IP/端口:在各 .env.* 中配置 VUE_APP_DEV_HOST、VUE_APP_DEV_PORT
+    host: process.env.VUE_APP_DEV_HOST || "localhost",
+    port: process.env.VUE_APP_DEV_PORT
+      ? Number(process.env.VUE_APP_DEV_PORT)
+      : 8080,
     proxy: {
       "/api": {
         target: process.env.VUE_APP_APIPROXY,
@@ -71,6 +76,13 @@ module.exports = {
           "^/api": "", // 去掉 /api 前缀
         },
       },
+      "/DatangEnosData": {
+        target: process.env.VUE_APP_DatangEnosData_APIPROXY,
+        changeOrigin: true,
+        pathRewrite: {
+          "^/DatangEnosData": "",
+        },
+      },
       // 健康分析接口(勿用 /health,避免与路由 /home/health 及 history 回退冲突)
       "/healthApi": {
         target: process.env.VUE_APP_HEALTH_APIPROXY,

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است