Переглянути джерело

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

liujiejie 12 годин тому
батько
коміт
c769999384
25 змінених файлів з 4473 додано та 1 видалено
  1. 5 0
      downLoadServer/.env
  2. 3 0
      downLoadServer/package.json
  3. 38 0
      downLoadServer/scripts/createAnomalyTemplate.mjs
  4. 86 0
      downLoadServer/scripts/prepareReportTemplates.mjs
  5. 459 0
      downLoadServer/scripts/smoke-anomaly-report.mjs
  6. 131 0
      downLoadServer/scripts/smoke-health-report.mjs
  7. 116 0
      downLoadServer/scripts/validate-health-template.mjs
  8. BIN
      downLoadServer/src/public/file/异常检测数据分析报告模板(大唐版).docx
  9. 87 0
      downLoadServer/src/server/controllers/reportController.js
  10. 244 0
      downLoadServer/src/server/reportService/analyseApiClient.js
  11. 806 0
      downLoadServer/src/server/reportService/anomalyChartBuilder.js
  12. 391 0
      downLoadServer/src/server/reportService/anomalyReportMapper.js
  13. 438 0
      downLoadServer/src/server/reportService/anomalyReportService.js
  14. 604 0
      downLoadServer/src/server/reportService/docxReportBuilder.js
  15. 166 0
      downLoadServer/src/server/reportService/echartsRenderer.js
  16. 481 0
      downLoadServer/src/server/reportService/healthChartBuilder.js
  17. 190 0
      downLoadServer/src/server/reportService/healthReportMapper.js
  18. 149 0
      downLoadServer/src/server/reportService/healthReportService.js
  19. 55 0
      downLoadServer/src/server/reportService/reportTaskStore.js
  20. 14 0
      downLoadServer/src/server/routes/reportRoutes.js
  21. 10 1
      downLoadServer/src/server/server.js
  22. BIN
      downLoadServer/templates/_smoke_anomaly_report.docx
  23. BIN
      downLoadServer/templates/_smoke_health_report.docx
  24. BIN
      downLoadServer/templates/anomaly-report-template.docx
  25. BIN
      downLoadServer/templates/health-report-template.docx

+ 5 - 0
downLoadServer/.env

@@ -18,6 +18,11 @@ CHART_RENDER_TIMEOUT_MS=180000
 CHART_RENDER_CONCURRENCY=2
 CHART_MAX_PAGES=2
 CHART_MAX_COLORBAR_CATEGORIES=50
+# 分析服务网关。本地开发优先走前端代理(与页面同一条链路,避免 Node 直连被 reset)
+# ANALYSE_API_BASE_URL=http://10.172.12.211:16880
+ANALYSE_API_BASE_URL=http://127.0.0.1:8080/healthApi
+REPORT_TREND_CONCURRENCY=2
+REPORT_CHART_CONCURRENCY=2
 #   nginx 配置 minio
 #   env MINIO_ENDPOINT=192.168.50.233;
 #   env MINIO_PORT=6900;

+ 3 - 0
downLoadServer/package.json

@@ -6,6 +6,9 @@
   "type": "module",
   "scripts": {
     "start": "node index.mjs",
+    "prepare:templates": "node scripts/prepareReportTemplates.mjs",
+    "smoke:health-report": "node scripts/smoke-health-report.mjs",
+    "smoke:anomaly-report": "node scripts/smoke-anomaly-report.mjs",
     "test": "node src/test.mjs"
   },
   "keywords": [],

+ 38 - 0
downLoadServer/scripts/createAnomalyTemplate.mjs

@@ -0,0 +1,38 @@
+import fs from "fs";
+import path from "path";
+import PizZip from "pizzip";
+
+const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
+  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
+  <Default Extension="xml" ContentType="application/xml"/>
+  <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
+</Types>`;
+
+const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
+</Relationships>`;
+
+const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
+  <w:body>
+    <w:p><w:r><w:t>{field_name} 异常检测报告</w:t></w:r></w:p>
+    <w:p><w:r><w:t>评估日期:{datatime}</w:t></w:r></w:p>
+    <w:p><w:r><w:t>接入风机:{turbine_count} 台;异常风机:{abnormal_count} 台</w:t></w:r></w:p>
+    <w:p><w:r><w:t>【自动插入:异常概览图】</w:t></w:r></w:p>
+    <w:p><w:r><w:t>【自动插入:风机检测器图表】</w:t></w:r></w:p>
+    <w:sectPr/>
+  </w:body>
+</w:document>`;
+
+const zip = new PizZip();
+zip.file("[Content_Types].xml", contentTypes);
+zip.file("_rels/.rels", rels);
+zip.file("word/document.xml", documentXml);
+
+const outDir = path.join(process.cwd(), "templates");
+fs.mkdirSync(outDir, { recursive: true });
+const outPath = path.join(outDir, "anomaly-report-template.docx");
+fs.writeFileSync(outPath, zip.generate({ type: "nodebuffer" }));
+console.log("created:", outPath);

+ 86 - 0
downLoadServer/scripts/prepareReportTemplates.mjs

@@ -0,0 +1,86 @@
+/**
+ * 将健康/异常报告模板写入 templates/
+ * 用法:node scripts/prepareReportTemplates.mjs
+ */
+import path from "path";
+import fs from "fs";
+import { fileURLToPath } from "url";
+import PizZip from "pizzip";
+import {
+  HEALTH_IMAGE_MARKERS,
+  patchAnomalyTemplateXml,
+  patchTemplateXml,
+} from "../src/server/reportService/docxReportBuilder.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const root = path.join(__dirname, "..");
+const templatesDir = path.join(root, "templates");
+const publicHealth = path.join(
+  root,
+  "..",
+  "public",
+  "files",
+  "风电机组健康评估_风场级自动报告模板_V1.0.docx",
+);
+const publicAnomaly = path.join(
+  root,
+  "src",
+  "public",
+  "file",
+  "异常检测数据分析报告模板(大唐版).docx",
+);
+
+function writeTemplate(zip, targetName) {
+  fs.mkdirSync(templatesDir, { recursive: true });
+  const outPath = path.join(templatesDir, targetName);
+  if (fs.existsSync(outPath)) {
+    try {
+      fs.chmodSync(outPath, 0o644);
+    } catch (_e) {
+      // ignore
+    }
+    fs.unlinkSync(outPath);
+  }
+  fs.writeFileSync(outPath, zip.generate({ type: "nodebuffer" }), {
+    mode: 0o644,
+  });
+  return outPath;
+}
+
+function patchAndWrite(sourcePath, targetName, patchFn) {
+  if (!fs.existsSync(sourcePath)) {
+    throw new Error(`源模板不存在: ${sourcePath}`);
+  }
+  const content = fs.readFileSync(sourcePath, "binary");
+  const zip = new PizZip(content);
+  const documentFile = zip.file("word/document.xml");
+  if (!documentFile) throw new Error("模板缺少 word/document.xml");
+  zip.file("word/document.xml", patchFn(documentFile.asText()));
+  return writeTemplate(zip, targetName);
+}
+
+fs.mkdirSync(templatesDir, { recursive: true });
+
+if (fs.existsSync(publicHealth)) {
+  const healthOut = patchAndWrite(
+    publicHealth,
+    "health-report-template.docx",
+    (xml) => patchTemplateXml(xml, HEALTH_IMAGE_MARKERS),
+  );
+  console.log("健康模板已写入并插入图片占位:", healthOut);
+} else {
+  console.warn("未找到健康模板,跳过:", publicHealth);
+}
+
+if (fs.existsSync(publicAnomaly)) {
+  const anomalyOut = patchAndWrite(
+    publicAnomaly,
+    "anomaly-report-template.docx",
+    (xml) => patchAnomalyTemplateXml(xml),
+  );
+  console.log("异常模板已写入并补齐 image 占位:", anomalyOut);
+} else {
+  console.warn("未找到异常模板,跳过:", publicAnomaly);
+}
+
+console.log("完成。重启 downLoadServer 后即可生成报告。");

+ 459 - 0
downLoadServer/scripts/smoke-anomaly-report.mjs

@@ -0,0 +1,459 @@
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+import {
+  registerImageBuffer,
+  renderDocxReport,
+} from "../src/server/reportService/docxReportBuilder.js";
+import {
+  DETECTOR_TEMPLATE_CONFIG,
+  buildDetectorPlotOption,
+  buildFarmPlotJsons,
+  listDetectorPlotOptions,
+} from "../src/server/reportService/anomalyChartBuilder.js";
+import { buildDetectorSectionPayload } from "../src/server/reportService/anomalyReportMapper.js";
+import { renderEchartsOption } from "../src/server/reportService/echartsRenderer.js";
+import {
+  initChartService,
+  shutdownChartService,
+} from "../src/server/utils/chartService/index.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const EMPTY_PNG = Buffer.from(
+  "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC",
+  "base64",
+);
+
+function samplePlot(labelPrefix = "") {
+  return {
+    title: `${labelPrefix}偏航`,
+    xaxis: "时间",
+    yaxis: "偏移 (°)",
+    data: [
+      {
+        label: "正常偏航角",
+        mode: "markers",
+        timeData: [
+          "2026-07-14 04:00:00",
+          "2026-07-14 08:00:00",
+          "2026-07-14 12:00:00",
+        ],
+        yData: [180, 200, 190],
+      },
+      {
+        label: "12h均值",
+        mode: "lines",
+        timeData: [
+          "2026-07-14 04:00:00",
+          "2026-07-14 08:00:00",
+          "2026-07-14 12:00:00",
+        ],
+        yData: [185, 198, 120],
+      },
+    ],
+  };
+}
+
+function assert(condition, message) {
+  if (!condition) throw new Error(message);
+}
+
+const scatterFarm = buildFarmPlotJsons(
+  [
+    {
+      engineName: "A",
+      plotJson: {
+        xaxis: "风速",
+        yaxis: "功率",
+        data: [
+          { label: "散点", mode: "markers", xData: [1, 2], yData: [3, 4] },
+          { label: "上限", mode: "lines", xData: [1, 2], yData: [8, 9] },
+          { label: "下限", mode: "lines", xData: [1, 2], yData: [1, 2] },
+          { label: "参考功率曲线", mode: "lines", xData: [1, 2], yData: [5, 6] },
+        ],
+      },
+    },
+    {
+      engineName: "B",
+      plotJson: {
+        xaxis: "风速",
+        yaxis: "功率",
+        data: [
+          { label: "散点", mode: "markers", xData: [2, 3], yData: [4, 5] },
+          { label: "参考功率曲线", mode: "lines", xData: [1, 2], yData: [5, 6] },
+        ],
+      },
+    },
+  ],
+  "wind_power_scatter",
+  "全场散点",
+);
+assert(scatterFarm[0].data.filter((row) => row.__shared).length === 1, "参考功率曲线应只保留一条");
+assert(!scatterFarm[0].data.some((row) => /上限|下限/.test(row.label)), "散点总图不应含上下限");
+assert(
+  scatterFarm[0].data.some((row) => row.label === "A") &&
+    scatterFarm[0].data.some((row) => row.label === "B"),
+  "散点总图应按风机分系列",
+);
+
+const farmScatterOption = buildDetectorPlotOption(
+  scatterFarm[0],
+  scatterFarm[0].title,
+  "anomalyScatterPO",
+);
+const farmScatterSeries = (farmScatterOption.series || []).filter(
+  (item) => item.type === "scatter",
+);
+assert(farmScatterSeries.length > 0, "全场散点应输出 scatter 系列");
+assert(
+  farmScatterSeries.every((item) => Number(item.symbolSize) >= 10),
+  "全场散点 symbolSize 过小",
+);
+assert(
+  farmScatterSeries.every((item) => item.large === false),
+  "报告散点不应开启 large 模式",
+);
+
+const qualityPanels = listDetectorPlotOptions(
+  {
+    panels: [
+      {
+        panelTitle: "功率因数 | 异常点: 0",
+        xaxis: "时间",
+        yaxis: "功率因数",
+        data: [
+          {
+            label: "功率因数",
+            mode: "markers",
+            timeData: ["2026-08-01 00:00:00", "2026-08-01 01:00:00"],
+            yData: [0.98, 0.97],
+          },
+        ],
+      },
+      {
+        panelTitle: "电流不平衡度",
+        xaxis: "时间",
+        yaxis: "电流不平衡度",
+        data: [
+          {
+            label: "电流不平衡度",
+            mode: "markers",
+            timeData: ["2026-08-01 00:00:00", "2026-08-01 01:00:00"],
+            yData: [0.12, 0.15],
+          },
+        ],
+      },
+    ],
+  },
+  "DT01 电能质量分析",
+  "anomalyPowerqualityPO",
+);
+assert(qualityPanels.length === 2, "电能质量单机图应按 panel 全部出图");
+assert(
+  qualityPanels[1].yAxis.name === "电流不平衡度",
+  "第二张单机图应使用对应 panel 的 Y 轴",
+);
+assert(
+  (qualityPanels[1].series || []).some(
+    (item) => Array.isArray(item.data) && item.data.length > 0,
+  ),
+  "单机图系列不能为空",
+);
+
+const pitchFarm = buildFarmPlotJsons(
+  [
+    {
+      engineName: "101",
+      plotJson: {
+        panels: [
+          {
+            panelTitle: "桨距角时序",
+            xaxis: "时间",
+            yaxis: "桨距角",
+            data: [
+              { label: "桨叶 1", mode: "markers", timeData: ["2026-01-01 00:00"], yData: [1] },
+              { label: "桨叶 2", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [2] },
+              { label: "桨叶 3", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [3] },
+              { label: "阈值参考", mode: "lines", timeData: ["2026-01-01 00:00"], yData: [4] },
+            ],
+          },
+          {
+            panelTitle: "桨距角差",
+            xaxis: "时间",
+            yaxis: "差值",
+            data: [
+              { label: "桨叶 1", mode: "markers", timeData: ["2026-01-01 00:00"], yData: [1] },
+            ],
+          },
+        ],
+      },
+    },
+  ],
+  "pitch_regulation",
+  "全场变桨一致性",
+);
+assert(pitchFarm.length === 2, "变桨一致性总图应为 2 张");
+assert(
+  !pitchFarm[0].data.some((row) => /阈值/.test(row.originalLabel || "")),
+  "变桨一致性不应展示阈值参考",
+);
+
+const deloadFarm = buildFarmPlotJsons(
+  [
+    {
+      engineName: "101",
+      plotJson: {
+        panels: [
+          {
+            panelTitle: "风速",
+            xaxis: "时间",
+            yaxis: "风速",
+            data: [{ label: "风速", timeData: ["t"], yData: [1] }],
+          },
+          {
+            panelTitle: "有功功率时序",
+            xaxis: "时间",
+            yaxis: "有功功率",
+            data: [
+              { label: "正常", mode: "markers", timeData: ["t"], yData: [1] },
+              { label: "异常", mode: "markers", timeData: ["t"], yData: [2] },
+            ],
+          },
+        ],
+      },
+    },
+  ],
+  "ctrl_deload",
+  "全场降载",
+);
+assert(deloadFarm.length === 1, "降载总图只出有功功率时序");
+assert(/有功功率/.test(deloadFarm[0].title + deloadFarm[0].yaxis), "降载总图应为有功功率");
+
+const opFarm = buildFarmPlotJsons(
+  [
+    {
+      engineName: "101",
+      plotJson: {
+        panels: [
+          {
+            panelTitle: "投影",
+            xaxis: "x",
+            yaxis: "y",
+            data: [{ label: "簇", xData: [1], yData: [1] }],
+          },
+          {
+            panelTitle: "转速-功率散点",
+            xaxis: "转速",
+            yaxis: "功率",
+            data: [{ label: "散点", xData: [1], yData: [2] }],
+          },
+        ],
+      },
+    },
+  ],
+  "ctrl_op_state",
+  "全场运行状态",
+);
+assert(opFarm.length === 1, "运行状态总图只出一张");
+assert(/转速/.test(opFarm[0].title + opFarm[0].xaxis), "运行状态总图应为转速-功率");
+
+const pqFarm = buildFarmPlotJsons(
+  [
+    {
+      engineName: "101",
+      plotJson: {
+        panels: [
+          { panelTitle: "功率因数", xaxis: "x", yaxis: "y", data: [{ label: "点", xData: [1], yData: [1] }] },
+          { panelTitle: "不平衡度", xaxis: "x", yaxis: "y", data: [{ label: "点", xData: [1], yData: [1] }] },
+          { panelTitle: "频率", xaxis: "x", yaxis: "y", data: [{ label: "点", xData: [1], yData: [1] }] },
+        ],
+      },
+    },
+  ],
+  "ctrl_power_quality",
+  "全场电能质量",
+);
+assert(pqFarm.length === 3, "电能质量总图应为 3 张");
+console.log("farm chart rules ok");
+
+await initChartService();
+const imageBufferMap = {};
+registerImageBuffer(imageBufferMap, "anomaly_sensor_dist", EMPTY_PNG);
+
+const farmPlots = buildFarmPlotJsons(
+  [
+    { engineName: "111", plotJson: samplePlot("111") },
+    { engineName: "112", plotJson: samplePlot("112") },
+  ],
+  "yaw_static",
+  "全场静态偏航分析检测汇总",
+);
+const farmLabels = (farmPlots[0]?.data || []).map((row) => row.label);
+if (farmLabels.join(",") !== "111,112") {
+  throw new Error(`全场偏航分色失败: ${farmLabels.join(",")}`);
+}
+if ((farmPlots[0]?.data || []).some((row) => /均值/.test(row.originalLabel || ""))) {
+  throw new Error("全场偏航仍包含 2h/12h 均值");
+}
+const farmBuf = await renderEchartsOption(
+  buildDetectorPlotOption(farmPlots[0], farmPlots[0].title, "anomalyStaticyawPO"),
+  { width: 820, height: 420 },
+);
+registerImageBuffer(imageBufferMap, "anomaly_farm_yaw_static", farmBuf);
+
+const turbineBuf = await renderEchartsOption(
+  buildDetectorPlotOption(samplePlot("111"), "111 静态偏航分析"),
+  { width: 760, height: 400 },
+);
+registerImageBuffer(imageBufferMap, "anomaly_111_yaw_static", turbineBuf);
+
+const denseCount = 4500;
+const denseTimes = Array.from({ length: denseCount }, (_, i) => {
+  const t = new Date("2026-08-01T00:00:00").getTime() + i * 60000;
+  const pad = (n) => String(n).padStart(2, "0");
+  const d = new Date(t);
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
+    d.getHours(),
+  )}:${pad(d.getMinutes())}:00`;
+});
+const denseOption = buildDetectorPlotOption(
+  {
+    title: "DT01 电能质量分析",
+    xaxis: "时间",
+    yaxis: "电流不平衡度",
+    data: [
+      {
+        label: "电流不平衡度",
+        mode: "markers",
+        timeData: denseTimes,
+        yData: Array.from({ length: denseCount }, (_, i) => 0.08 + (i % 20) / 200),
+      },
+    ],
+  },
+  "DT01 电能质量分析",
+  "anomalyPowerqualityPO",
+);
+assert(denseOption.series[0].large === false, "密点单机图不应使用 large");
+assert(Number(denseOption.series[0].symbolSize) >= 8, "密点单机图 symbolSize 过小");
+const denseBuf = await renderEchartsOption(denseOption, { width: 760, height: 420 });
+assert(denseBuf.length > 20000, "密点单机图截图过小,可能未画出数据");
+
+const renderData = {
+  reportNo: "AD-SMOKE-20260824",
+  Province: "测试省",
+  Wind_farm: "烟雾风场",
+  Year_now: "2026",
+  Month_now: "08",
+  machineTypeCode: "WD",
+  turbine_count: "2",
+  anomaly_turbine_count: "1",
+  total_anomaly_points: "3",
+  anomaly_rate: "50.00",
+  Overview_of_the_Wind_Farm: "烟雾风场用于模板渲染校验。",
+  target_date: "2026-08-24",
+  anomaly_module_count: "1",
+  main_problem_modules: "偏航与扭缆",
+  top_problem_description: "静态偏航分析异常点数最多(3)",
+  sensorAnomalyRows: [
+    {
+      turbine_name: "111",
+      sensor_anomaly_type: "风速异常",
+      anomaly_points: "1",
+      ratio: "—",
+    },
+  ],
+  detectorSummaryRows: [
+    {
+      detector_name: "静态偏航分析",
+      module_name: "偏航与扭缆",
+      data_granularity: "秒级",
+      anomaly_turbines: "1",
+      anomaly_points: "3",
+      avg_anomaly_rate: "12.00%",
+    },
+  ],
+  anomalySummaryRows: [
+    {
+      turbine_name: "111",
+      anomaly_detector_count: "1",
+      anomaly_points: "3",
+      anomaly_rate: "12.00%",
+      main_anomaly_type: "静态偏航分析",
+    },
+  ],
+  priorityList: "111",
+  keyTurbineLoop: [
+    {
+      turbine_name: "111",
+      turbineDetailRows: [
+        {
+          detector_name: "静态偏航分析",
+          anomaly_points: "3",
+          anomaly_rate: "12.00%",
+          conclusion: "建议结合现场复核",
+        },
+      ],
+      "zn-techcn-replace-tags-key_turbine-generalFiles": [
+        { image: "anomaly_111_yaw_static" },
+      ],
+    },
+  ],
+  conclusionRows: [
+    {
+      index: "1",
+      problem_type: "静态偏航分析",
+      turbine_names: "111",
+      anomaly_points: "3",
+      risk_level: "P2",
+      suggestion: "核对偏航角越限情况。",
+    },
+  ],
+  "zn-techcn-replace-tags-data_sensor_anomaly-generalFiles": [
+    { image: "anomaly_sensor_dist" },
+  ],
+  "zn-techcn-replace-tags-key_turbine-generalFiles": [],
+};
+
+DETECTOR_TEMPLATE_CONFIG.forEach((cfg) => {
+  if (cfg.templateKey === "yaw_static") {
+    Object.assign(
+      renderData,
+      buildDetectorSectionPayload(cfg, {
+        farmImages: [{ image: "anomaly_farm_yaw_static" }],
+        turbineImages: [{ image: "anomaly_111_yaw_static" }],
+        rows: [
+          {
+            turbine_name: "111",
+            anomaly_points: "3",
+            anomaly_rate: "12.00%",
+            comment: "功能诊断异常",
+          },
+        ],
+        anomalyPoints: 3,
+        anomalyRate: 0.12,
+        anomalyTurbines: 1,
+      }),
+    );
+    return;
+  }
+  const farmTag = `zn-techcn-replace-tags-${cfg.templateKey}-farmSummary`;
+  const fileTag = `zn-techcn-replace-tags-${cfg.templateKey}-generalFiles`;
+  renderData[`show-${fileTag}`] = [];
+  renderData[farmTag] = [];
+  renderData[fileTag] = [];
+  renderData[`${cfg.templateKey}Rows`] = [];
+});
+
+const buffer = await renderDocxReport({
+  templateName: "异常检测数据分析报告模板(大唐版).docx",
+  renderData,
+  imageBufferMap,
+});
+
+const outDir = path.join(__dirname, "../templates");
+fs.mkdirSync(outDir, { recursive: true });
+const outPath = path.join(outDir, "_smoke_anomaly_report.docx");
+fs.writeFileSync(outPath, buffer);
+console.log("smoke anomaly report written:", outPath, "bytes=", buffer.length);
+
+await shutdownChartService();

+ 131 - 0
downLoadServer/scripts/smoke-health-report.mjs

@@ -0,0 +1,131 @@
+import fs from "fs";
+import {
+  registerImageBuffer,
+  renderDocxReport,
+} from "../src/server/reportService/docxReportBuilder.js";
+import {
+  buildFarmCategoryBarOption,
+  buildFarmLevelPieOption,
+  buildTurbineTrendChartOptions,
+} from "../src/server/reportService/healthChartBuilder.js";
+import { renderEchartsOption } from "../src/server/reportService/echartsRenderer.js";
+import {
+  initChartService,
+  shutdownChartService,
+} from "../src/server/utils/chartService/index.js";
+
+await initChartService();
+const map = {};
+registerImageBuffer(
+  map,
+  "health_fig1",
+  await renderEchartsOption(
+    buildFarmLevelPieOption({
+      overallScore: 80,
+      excellentCount: 2,
+      goodCount: 1,
+      fairCount: 1,
+      poorCount: 0,
+    }),
+    { width: 760, height: 420 },
+  ),
+);
+registerImageBuffer(
+  map,
+  "health_fig2",
+  await renderEchartsOption(
+    buildFarmCategoryBarOption({
+      overallScore: 80,
+      systemScore: 75,
+      componentScore: 70,
+      structureScore: 72,
+    }),
+    { width: 760, height: 360 },
+  ),
+);
+
+const trendPoints = [
+  {
+    birthday: "2026-08-01",
+    overallScore: 80,
+    rotorScore: 70,
+    towerScore: 75,
+    yawSystemScore: 72,
+    pitchSystemScore: 71,
+    hydraulicSystemScore: 70,
+    controlSystemScore: 69,
+    generatorScore: 68,
+    gearboxScore: 67,
+    mainShaftScore: 66,
+    converterScore: 65,
+  },
+  {
+    birthday: "2026-08-02",
+    overallScore: 82,
+    rotorScore: 71,
+    towerScore: 75,
+    yawSystemScore: 73,
+    pitchSystemScore: 72,
+    hydraulicSystemScore: 71,
+    controlSystemScore: 70,
+    generatorScore: 69,
+    gearboxScore: 68,
+    mainShaftScore: 67,
+    converterScore: 66,
+  },
+];
+const trendCharts = buildTurbineTrendChartOptions(trendPoints, trendPoints[1]);
+const renderedCharts = [];
+for (const chart of trendCharts) {
+  const imageKey = `health_trend_WT01_${chart.key}`;
+  registerImageBuffer(
+    map,
+    imageKey,
+    await renderEchartsOption(chart.option, {
+      width: 760,
+      height: chart.key === "overall" ? 260 : 300,
+    }),
+  );
+  renderedCharts.push({
+    chart_title: chart.title,
+    trend_image: imageKey,
+  });
+}
+
+const out = await renderDocxReport({
+  templateName: "health-report-template.docx",
+  skipPatch: false,
+  imageBufferMap: map,
+  renderData: {
+    风场名称: "测试风场",
+    farm_name: "测试风场",
+    field_id: "F1",
+    create_time: "2026-08-13",
+    source_datetime: "2026-08-12",
+    turbine_count: "1",
+    valid_turbine_count: "1",
+    turbine_types: "TEST",
+    overall_score: "80",
+    system_score: "75",
+    component_score: "70",
+    "structure_score/未评估": "72",
+    excellent_count: "2",
+    good_count: "1",
+    fair_count: "1",
+    poor_count: "0",
+    overall_level_text: "良",
+    main_level: "良",
+    level_distribution_text: "优2台",
+    overall_summary_text: "测试",
+    focus_engine_list: "WT01",
+    chart_fig1: "health_fig1",
+    chart_fig2: "health_fig2",
+    turbine_trends: [
+      { engine_title: "WT01 风机健康趋势图", charts: renderedCharts },
+    ],
+  },
+});
+fs.writeFileSync("templates/_smoke_health_report.docx", out);
+console.log("smoke ok, size=", out.length);
+await shutdownChartService();
+process.exit(0);

+ 116 - 0
downLoadServer/scripts/validate-health-template.mjs

@@ -0,0 +1,116 @@
+import fs from "fs";
+import PizZip from "pizzip";
+import Docxtemplater from "docxtemplater";
+import ImageModule from "docxtemplater-image-module-free";
+import {
+  HEALTH_IMAGE_MARKERS,
+  patchTemplateXml,
+} from "../src/server/reportService/docxReportBuilder.js";
+
+const zip = new PizZip(fs.readFileSync("templates/health-report-template.docx"));
+let xml = zip.file("word/document.xml").asText();
+console.log(
+  "p open/close",
+  (xml.match(/<w:p(?:\s|>)/g) || []).length,
+  (xml.match(/<\/w:p>/g) || []).length,
+);
+
+const wt = [];
+xml.replace(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/g, (_, t) => {
+  wt.push(t);
+  return _;
+});
+const bad = wt.filter(
+  (t) => (t.match(/{/g) || []).length !== (t.match(/}/g) || []).length,
+);
+console.log("unbalanced w:t", bad.length);
+bad.slice(0, 10).forEach((t) => console.log(JSON.stringify(t.slice(0, 120))));
+
+xml = patchTemplateXml(xml, HEALTH_IMAGE_MARKERS);
+zip.file("word/document.xml", xml);
+
+const empty = Buffer.from(
+  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+  "base64",
+);
+const imageModule = new ImageModule({
+  centered: true,
+  getImage: () => empty,
+  getSize: () => [100, 60],
+});
+
+const doc = new Docxtemplater(zip, {
+  paragraphLoop: true,
+  linebreaks: true,
+  modules: [imageModule],
+  nullGetter: () => "",
+});
+doc.render({
+  farm_name: "测试",
+  field_id: "F1",
+  create_time: "t",
+  source_datetime: "d",
+  turbine_count: "1",
+  valid_turbine_count: "1",
+  turbine_types: "x",
+  overall_score: "80",
+  system_score: "70",
+  component_score: "70",
+  "structure_score/未评估": "70",
+  excellent_count: "1",
+  good_count: "0",
+  fair_count: "0",
+  poor_count: "0",
+  overall_level_text: "良",
+  main_level: "良",
+  level_distribution_text: "x",
+  overall_summary_text: "x",
+  focus_engine_list: "A",
+  chart_fig1: "a",
+  chart_fig2: "b",
+  turbine_rows: [
+    {
+      index: 1,
+      engine_name: "A1",
+      machine_type: "T",
+      overall: "80",
+      level: "良",
+      system: "70",
+      component: "70",
+      structure: "70",
+      yaw: "1",
+      pitch: "1",
+      mcs: "1",
+      hpu: "1",
+      generator: "1",
+      converter: "1",
+      gearbox: "1",
+      shaft: "1",
+      rotor: "1",
+      tower: "1",
+    },
+  ],
+  focus_rows: [
+    {
+      index: 1,
+      engine_name: "A1",
+      machine_type: "T",
+      overall: "80",
+      level: "中",
+      system: "70",
+      component: "70",
+      structure: "70",
+      low_item: "系统健康",
+    },
+  ],
+  turbine_trends: [
+    {
+      engine_title: "A1 趋势",
+      charts: [
+        { chart_title: "综合健康评分趋势图", trend_image: "a" },
+        { chart_title: "结构健康趋势图", trend_image: "a" },
+      ],
+    },
+  ],
+});
+console.log("docxtemplater compile+render ok");

BIN
downLoadServer/src/public/file/异常检测数据分析报告模板(大唐版).docx


+ 87 - 0
downLoadServer/src/server/controllers/reportController.js

@@ -0,0 +1,87 @@
+import { generateAnomalyReport } from "../reportService/anomalyReportService.js";
+import { generateHealthReport } from "../reportService/healthReportService.js";
+import {
+  consumeReportTask,
+  createReportTask,
+  failReportTask,
+  finishReportTask,
+  getReportTask,
+} from "../reportService/reportTaskStore.js";
+
+function sendDocx(res, { buffer, fileName }) {
+  const encoded = encodeURIComponent(fileName);
+  res.setHeader(
+    "Content-Type",
+    "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+  );
+  res.setHeader(
+    "Content-Disposition",
+    `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`,
+  );
+  res.send(buffer);
+}
+
+function snapshotReq(req) {
+  return {
+    body: req.body || {},
+    headers: {
+      token: req.headers.token || req.headers.Token,
+      showIp: req.headers.showip || req.headers.showIp,
+      Token: req.headers.token || req.headers.Token,
+    },
+  };
+}
+
+function startReportTask(kind, req, res, generateFn, failLabel) {
+  const taskId = createReportTask();
+  const snapshot = snapshotReq(req);
+  res.status(202).json({
+    status: "accepted",
+    taskId,
+    message: "报告生成中",
+  });
+
+  Promise.resolve()
+    .then(() => generateFn(snapshot))
+    .then((result) => {
+      finishReportTask(taskId, result);
+      console.log(`[report-task] ${kind} 完成:`, taskId, result.fileName);
+    })
+    .catch((error) => {
+      console.error(`${failLabel}:`, error);
+      failReportTask(taskId, error.message || failLabel);
+    });
+}
+
+export async function createHealthReport(req, res) {
+  startReportTask("health", req, res, generateHealthReport, "健康报告生成失败");
+}
+
+export async function createAnomalyReport(req, res) {
+  startReportTask(
+    "anomaly",
+    req, res,
+    generateAnomalyReport,
+    "异常检测报告生成失败",
+  );
+}
+
+export async function getReportTaskStatus(req, res) {
+  const task = getReportTask(req.params.taskId);
+  if (!task) {
+    res.status(404).json({ status: "error", message: "报告任务不存在或已过期" });
+    return;
+  }
+  if (task.status === "running") {
+    res.status(202).json({ status: "running" });
+    return;
+  }
+  if (task.status === "error") {
+    res.status(500).json({
+      status: "error",
+      message: task.message || "报告生成失败",
+    });
+    return;
+  }
+  sendDocx(res, consumeReportTask(req.params.taskId));
+}

+ 244 - 0
downLoadServer/src/server/reportService/analyseApiClient.js

@@ -0,0 +1,244 @@
+import http from "http";
+import https from "https";
+import axios from "axios";
+
+const HEALTH_PREFIX = "/energy-manage-analyse-service/healthscores";
+const ANOMALY_PREFIX = "/energy-manage-analyse-service/anomaly";
+
+const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 8 });
+const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 8 });
+
+function createClient(headers = {}) {
+  const baseURL = process.env.ANALYSE_API_BASE_URL || "";
+  if (!baseURL) {
+    throw new Error("未配置 ANALYSE_API_BASE_URL");
+  }
+  return axios.create({
+    baseURL,
+    timeout: Number.parseInt(process.env.REPORT_API_TIMEOUT_MS || "120000", 10),
+    httpAgent,
+    httpsAgent,
+    decompress: true,
+    headers: {
+      Accept: "application/json",
+      "Content-Type": "application/json",
+      // 不要带 br:部分 Java/nginx 网关会直接 reset
+      "Accept-Encoding": "gzip, deflate",
+      ...headers,
+    },
+  });
+}
+
+function unwrapResult(res) {
+  const body = res?.data;
+  if (body && typeof body === "object" && "code" in body) {
+    if (body.code !== 200) {
+      throw new Error(body.msg || `分析服务错误(${body.code})`);
+    }
+    return body.data;
+  }
+  return body;
+}
+
+function isRetryable(error) {
+  const code = error?.code || error?.cause?.code;
+  return ["ECONNRESET", "ECONNABORTED", "ETIMEDOUT", "EPIPE", "EAI_AGAIN"].includes(
+    code,
+  );
+}
+
+async function withRetry(fn, label, times = 3) {
+  let lastError;
+  for (let i = 1; i <= times; i += 1) {
+    try {
+      return await fn();
+    } catch (error) {
+      lastError = error;
+      if (!isRetryable(error) || i === times) break;
+      console.warn(
+        `[analyse] ${label} 第 ${i} 次失败(${error.code || error.message}),重试…`,
+      );
+      await new Promise((resolve) => setTimeout(resolve, 400 * i));
+    }
+  }
+  const code = lastError?.code || lastError?.cause?.code || "";
+  throw new Error(
+    `${label} 失败${code ? `(${code})` : ""}: 无法连接分析服务 ${
+      process.env.ANALYSE_API_BASE_URL || ""
+    }`,
+  );
+}
+
+/**
+ * 健康接口与前端一致:query 传参 + POST body 用 {}
+ */
+async function postHealthQuery(client, url, params) {
+  const res = await client.post(url, {}, { params });
+  return unwrapResult(res);
+}
+
+export async function fetchHealthOverview({ fieldCode, datatime }, headers) {
+  const client = createClient(headers);
+  const data = await withRetry(
+    () =>
+      postHealthQuery(client, `${HEALTH_PREFIX}/getHealthOverview`, {
+        fieldCode,
+        datatime,
+      }),
+    "getHealthOverview",
+  );
+  return data || {};
+}
+
+export async function fetchLastDaysTrend(
+  { day, engineId, fieldId, dateTime },
+  headers,
+) {
+  const client = createClient(headers);
+  const data = await withRetry(
+    () =>
+      postHealthQuery(client, `${HEALTH_PREFIX}/getLastDaysTrend`, {
+        day,
+        engineId,
+        fieldId,
+        dateTime,
+      }),
+    `getLastDaysTrend(${engineId})`,
+  );
+  if (Array.isArray(data)) return data;
+  if (data && typeof data === "object") return [data];
+  return [];
+}
+
+export async function fetchAnomalyOverview({ fieldCode, datatime }, headers) {
+  const client = createClient(headers);
+  const data = await withRetry(
+    () =>
+      client
+        .post(`${ANOMALY_PREFIX}/getAnomalyOverview`, { fieldCode, datatime })
+        .then(unwrapResult),
+    "getAnomalyOverview",
+  );
+  return data || {};
+}
+
+export async function fetchAnomalyModel({ fieldCode, datatime }, headers) {
+  const client = createClient(headers);
+  const data = await withRetry(
+    () =>
+      client
+        .post(`${ANOMALY_PREFIX}/getAnomalyModel`, { fieldCode, datatime })
+        .then(unwrapResult),
+    "getAnomalyModel",
+  );
+  return Array.isArray(data) ? data : [];
+}
+
+export async function fetchAnomalyBarChartStats({ fieldCode, datatime }, headers) {
+  const client = createClient(headers);
+  const data = await withRetry(
+    () =>
+      client
+        .post(`${ANOMALY_PREFIX}/getBarChartStats`, { fieldCode, datatime })
+        .then(unwrapResult),
+    "getBarChartStats",
+  );
+  return data || {};
+}
+
+async function postAnomalyModule(url, payload, headers) {
+  const client = createClient(headers);
+  const res = await client.post(url, payload);
+  return unwrapResult(res) || {};
+}
+
+export async function fetchAnomalyTurbineModules(
+  { fieldId, engineId, datatime },
+  headers,
+) {
+  const payload = { fieldId, engineId, datatime };
+  const [wind, yaw, pitch, run, aero] = await Promise.all([
+    postAnomalyModule(`${ANOMALY_PREFIX}/getAnomalyWindpwr`, payload, headers).catch(
+      () => ({}),
+    ),
+    postAnomalyModule(`${ANOMALY_PREFIX}/geAnomalyYaw`, payload, headers).catch(
+      () => ({}),
+    ),
+    postAnomalyModule(`${ANOMALY_PREFIX}/getAnomalyPitch`, payload, headers).catch(
+      () => ({}),
+    ),
+    postAnomalyModule(
+      `${ANOMALY_PREFIX}/getAnomalyCtrlParam`,
+      payload,
+      headers,
+    ).catch(() => ({})),
+    postAnomalyModule(`${ANOMALY_PREFIX}/getAerodynamics`, payload, headers).catch(
+      () => ({}),
+    ),
+  ]);
+  return { wind, yaw, pitch, run, aero };
+}
+
+export async function fetchRemoteJson(url) {
+  const media = await fetchRemoteMedia(url);
+  if (media?.kind === "json") return media.data;
+  return null;
+}
+
+function looksLikeJsonBuffer(buffer) {
+  if (!buffer?.length) return false;
+  let i = 0;
+  if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) i = 3;
+  while (
+    i < buffer.length &&
+    (buffer[i] === 0x20 ||
+      buffer[i] === 0x09 ||
+      buffer[i] === 0x0a ||
+      buffer[i] === 0x0d)
+  ) {
+    i += 1;
+  }
+  const first = buffer[i];
+  return first === 0x7b || first === 0x5b;
+}
+
+function looksLikeImageBuffer(buffer) {
+  if (!buffer || buffer.length < 8) return false;
+  if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e) return true;
+  if (buffer[0] === 0xff && buffer[1] === 0xd8) return true;
+  if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) return true;
+  return false;
+}
+
+export async function fetchRemoteMedia(url) {
+  if (!url) return null;
+  const safeUrl = String(url).replace(/#/g, "%23");
+  const res = await axios.get(safeUrl, {
+    timeout: Number.parseInt(process.env.REPORT_FETCH_TIMEOUT_MS || "60000", 10),
+    responseType: "arraybuffer",
+    headers: { "Accept-Encoding": "gzip, deflate" },
+  });
+  const buffer = Buffer.from(res.data);
+  const contentType = String(res.headers["content-type"] || "").toLowerCase();
+  const tryJson = () => {
+    const text = buffer.toString("utf8");
+    return JSON.parse(text);
+  };
+
+  if (contentType.includes("json") || looksLikeJsonBuffer(buffer)) {
+    try {
+      return { kind: "json", data: tryJson() };
+    } catch (_error) {
+      if (looksLikeImageBuffer(buffer)) return { kind: "image", buffer };
+      throw _error;
+    }
+  }
+  if (contentType.includes("image") || looksLikeImageBuffer(buffer)) {
+    return { kind: "image", buffer };
+  }
+  try {
+    return { kind: "json", data: tryJson() };
+  } catch (_error) {
+    return { kind: "image", buffer };
+  }
+}

+ 806 - 0
downLoadServer/src/server/reportService/anomalyChartBuilder.js

@@ -0,0 +1,806 @@
+const SERIES_PALETTE = ["#1E7CF8", "#7C5CFF", "#16A34A", "#F59E0B", "#EF4444"];
+const MAX_STITCH_POINTS = 6000;
+
+export const DETECTOR_TEMPLATE_CONFIG = [
+  {
+    templateKey: "wind_power_curve",
+    poKey: "anomalyPowercurvePO",
+    module: "wind",
+    moduleName: "风速-功率",
+    title: "功率曲线分析",
+    granularity: "分钟级(近90天)",
+    suggestion: "核对风速仪/功率计精度,结合叶片状态与控制参数复核发电性能。",
+  },
+  {
+    templateKey: "wind_power_scatter",
+    poKey: "anomalyScatterPO",
+    module: "wind",
+    moduleName: "风速-功率",
+    title: "风功率散点分析",
+    granularity: "分钟级(近90天)",
+    suggestion: "排查风速-功率散点偏离原因,核对测点质量与限功率策略。",
+  },
+  {
+    templateKey: "yaw_static",
+    poKey: "anomalyStaticyawPO",
+    module: "yaw",
+    moduleName: "偏航与扭缆",
+    title: "静态偏航分析",
+    granularity: "秒级",
+    suggestion: "核对偏航角越限、长时间不动作等情况,结合对风测试复核。",
+  },
+  {
+    templateKey: "yaw_twist",
+    poKey: "anomalyCabletwistPO",
+    module: "yaw",
+    moduleName: "偏航与扭缆",
+    title: "扭缆分析",
+    granularity: "秒级",
+    suggestion: "检查扭缆角度与解缆策略,避免长期扭缆累积。",
+  },
+  {
+    templateKey: "yaw_error",
+    poKey: null,
+    module: "yaw",
+    moduleName: "偏航与扭缆",
+    title: "静态偏航误差分析",
+    granularity: "秒级(近7天)",
+    suggestion: "对静态偏航误差绝对值大于3°的机组进行偏航校正。",
+  },
+  {
+    templateKey: "yaw_count",
+    poKey: null,
+    module: "yaw",
+    moduleName: "偏航与扭缆",
+    title: "偏航次数分析",
+    granularity: "秒级",
+    suggestion: "推动偏航动作状态测点规范接入,提升偏航次数检测覆盖率。",
+  },
+  {
+    templateKey: "pitch_coord",
+    poKey: "anomalyPitchcoordPO",
+    module: "pitch",
+    moduleName: "变桨系统",
+    title: "变桨协调分析",
+    granularity: "分钟级(近90天)",
+    suggestion: "检查三叶片变桨协同与传感器一致性。",
+  },
+  {
+    templateKey: "pitch_regulation",
+    poKey: "anomalyPitchregulationPO",
+    module: "pitch",
+    moduleName: "变桨系统",
+    title: "变桨一致性分析",
+    granularity: "秒级",
+    suggestion: "核对变桨执行机构与控制逻辑,排查桨距角不一致。",
+  },
+  {
+    templateKey: "pitch_min",
+    poKey: "anomalyMinpitchPO",
+    module: "pitch",
+    moduleName: "变桨系统",
+    title: "最小桨距角分析",
+    granularity: "分钟级(近90天)",
+    suggestion: "对最小桨距角漂移超限机组开展开桨零位标定与传感器检查。",
+  },
+  {
+    templateKey: "ctrl_power_quality",
+    poKey: "anomalyPowerqualityPO",
+    module: "run",
+    moduleName: "控制参数",
+    title: "电能质量分析",
+    granularity: "分钟级(近90天)",
+    suggestion: "核查功率因数、三相不平衡度与频率等电能质量指标。",
+  },
+  {
+    templateKey: "ctrl_op_state",
+    poKey: "anomalyOperationPO",
+    module: "run",
+    moduleName: "控制参数",
+    title: "运行状态分析",
+    granularity: "分钟级(近90天)",
+    suggestion: "结合投影簇群偏离情况复核机组运行工况。",
+  },
+  {
+    templateKey: "ctrl_deload",
+    poKey: "anomalyDeloadPO",
+    module: "run",
+    moduleName: "控制参数",
+    title: "降载判定分析",
+    granularity: "分钟级(近90天)",
+    suggestion: "核对限功率/降载策略是否符合调度与保护要求。",
+  },
+  {
+    templateKey: "aero_cp",
+    poKey: "anomalyCpPO",
+    module: "aero",
+    moduleName: "气动性能",
+    title: "Cp功率系数分析",
+    granularity: "分钟级(近90天)",
+    suggestion: "核查叶片气动状态与控制参数对风能利用系数的影响。",
+  },
+  {
+    templateKey: "aero_tsr",
+    poKey: "anomalyTsrPO",
+    module: "aero",
+    moduleName: "气动性能",
+    title: "TSR叶尖速比分析",
+    granularity: "分钟级(近90天)",
+    suggestion: "核对叶尖速比偏离最优区间的控制与测点原因。",
+  },
+  {
+    templateKey: "aero_tsr_wind",
+    poKey: "anomalyCpTsrPO",
+    module: "aero",
+    moduleName: "气动性能",
+    title: "TSR-Cp联合分析",
+    granularity: "分钟级(近90天)",
+    suggestion: "结合 Cp 与 TSR 联合分布偏离情况开展气动性能复核。",
+  },
+];
+
+export const DETECTOR_CONFIG = DETECTOR_TEMPLATE_CONFIG.filter(
+  (item) => item.poKey,
+).map((item) => ({
+  key: item.poKey,
+  title: item.title,
+  templateKey: item.templateKey,
+}));
+
+const SCATTER_ONLY_PO_KEYS = new Set(["anomalyPowerqualityPO"]);
+const FARM_MAX_POINTS_PER_SERIES = 800;
+const FARM_PALETTE = [
+  "#1E7CF8", "#E11D48", "#16A34A", "#D97706", "#7C3AED", "#0D9488",
+  "#DB2777", "#2563EB", "#65A30D", "#EA580C", "#4F46E5", "#0891B2",
+  "#BE123C", "#15803D", "#C2410C", "#6D28D9", "#0F766E", "#9D174D",
+  "#1D4ED8", "#4D7C0F", "#B45309", "#5B21B6", "#155E75", "#A21CAF",
+];
+
+function farmTurbineColor(index) {
+  if (index >= 0 && index < FARM_PALETTE.length) return FARM_PALETTE[index];
+  const hue = Math.round(((Number(index) || 0) * 137.508) % 360);
+  return `hsl(${hue}, 68%, 44%)`;
+}
+
+function panelText(panel) {
+  return `${panel?.title || ""} ${panel?.xaxis || ""} ${panel?.yaxis || ""}`;
+}
+
+export function shouldExcludeFarmLabel(label, templateKey) {
+  const text = String(label || "");
+  if (templateKey === "wind_power_scatter") return /上限|下限/.test(text);
+  if (templateKey === "yaw_twist") return /平均|均值/.test(text);
+  if (templateKey === "yaw_static") {
+    return /(?:2|12)\s*(?:h|H|小时)?\s*均值/.test(text);
+  }
+  if (templateKey === "pitch_regulation") return /阈值/.test(text);
+  return false;
+}
+
+export function isSharedFarmSeries(label, templateKey) {
+  const text = String(label || "");
+  if (templateKey === "wind_power_scatter") {
+    return /参考功率|理论功率|合同功率|参考曲线/.test(text);
+  }
+  return false;
+}
+
+export function selectFarmPanels(panels, templateKey) {
+  const list = Array.isArray(panels) ? panels : [];
+  if (templateKey === "ctrl_deload") {
+    const power = list.filter((panel) => /有功功率/.test(panelText(panel)));
+    const timed = power.filter(
+      (panel) =>
+        /时间|时序/.test(panelText(panel)) ||
+        isTimeAxis(panel.xaxis, getRowXData(panel.data?.[0])[0]),
+    );
+    return (timed.length ? timed : power.length ? power : list).slice(0, 1);
+  }
+  if (templateKey === "ctrl_op_state") {
+    const matched = list.filter(
+      (panel) => /转速/.test(panelText(panel)) && /功率/.test(panelText(panel)),
+    );
+    return (matched.length ? matched : list).slice(0, 1);
+  }
+  if (templateKey === "pitch_regulation") return list.slice(0, 2);
+  if (templateKey === "ctrl_power_quality") return list.slice(0, 3);
+  return list.slice(0, 1);
+}
+
+function farmBladeKind(originalLabel) {
+  const text = String(originalLabel || "");
+  if (/桨叶\s*1/.test(text)) return "markers";
+  if (/桨叶\s*2/.test(text)) return "dashed";
+  if (/桨叶\s*3/.test(text)) return "solid";
+  return null;
+}
+
+function farmDeloadKind(originalLabel, label) {
+  const text = `${originalLabel || ""} ${label || ""}`;
+  if (/异常|降载/.test(text) && !/正常/.test(text)) return "anomaly";
+  if (/正常/.test(text)) return "normal";
+  return null;
+}
+
+function pickColor(label, index) {
+  const text = String(label || "");
+  if (/桨叶\s*1/.test(text)) return "#1E7CF8";
+  if (/桨叶\s*2/.test(text)) return "#7C5CFF";
+  if (/桨叶\s*3/.test(text)) return "#16A34A";
+  if (/异常/.test(text)) return "#EF4444";
+  if (/12h均值/.test(text)) return "#F59E0B";
+  if (/2h均值/.test(text)) return "#7C5CFF";
+  if (/上限|下限|阈值|参考|均值|平均|σ/.test(text)) return "#F59E0B";
+  if (/正常/.test(text)) return "#1E7CF8";
+  return SERIES_PALETTE[index % SERIES_PALETTE.length];
+}
+
+function isDashedLine(label) {
+  return /上限|下限|阈值|参考|均值|平均|σ/.test(String(label || ""));
+}
+
+export function isRemoteImageUrl(url) {
+  return /\.(png|jpe?g|gif|webp)(\?.*)?$/i.test(String(url || ""));
+}
+
+export function extractPanels(plotJson) {
+  if (!plotJson) return [];
+  if (Array.isArray(plotJson.panels) && plotJson.panels.length) {
+    return plotJson.panels.map((panel) => ({
+      title: panel.panelTitle || plotJson.title || "",
+      xaxis: panel.xaxis || "",
+      yaxis: panel.yaxis || "",
+      data: panel.data || [],
+    }));
+  }
+  return [
+    {
+      title: plotJson.title || "",
+      xaxis: plotJson.xaxis || "",
+      yaxis: plotJson.yaxis || "",
+      data: plotJson.data || [],
+    },
+  ];
+}
+
+function getRowXData(row) {
+  if (Array.isArray(row?.timeData) && row.timeData.length) {
+    return row.timeData;
+  }
+  if (Array.isArray(row?.xData) && row.xData.length) return row.xData;
+  if (Array.isArray(row?.x) && row.x.length) return row.x;
+  return [];
+}
+
+function getRowYData(row) {
+  if (Array.isArray(row?.yData) && row.yData.length) return row.yData;
+  if (Array.isArray(row?.y) && row.y.length) return row.y;
+  return [];
+}
+
+function toYNumber(v) {
+  if (v == null || v === "") return null;
+  const n = Number(v);
+  return Number.isFinite(n) ? n : null;
+}
+
+function scatterSymbolSize(pointCount, farmMode, deloadKind) {
+  if (deloadKind === "anomaly") return farmMode ? 14 : 12;
+  if (farmMode) return pointCount > 500 ? 10 : 12;
+  if (pointCount > 4000) return 8;
+  if (pointCount > 1500) return 9;
+  return 10;
+}
+
+function isTimeAxis(xaxis, sampleX) {
+  if (xaxis && String(xaxis).includes("时间")) return true;
+  if (typeof sampleX === "string" && /\d{4}-\d{2}-\d{2}/.test(sampleX)) {
+    return true;
+  }
+  const n = Number(sampleX);
+  return Number.isFinite(n) && n > 1e12;
+}
+
+function toMs(v) {
+  const n = Number(v);
+  if (!Number.isFinite(n)) return v;
+  return n > 1e15 ? Math.floor(n / 1e6) : n;
+}
+
+function toAxisValue(v, useTime) {
+  if (!useTime) return v;
+  const n = Number(v);
+  if (Number.isFinite(n)) return toMs(n);
+  if (typeof v === "string" && v) {
+    const parsed = Date.parse(v.replace(" ", "T"));
+    if (Number.isFinite(parsed)) return parsed;
+  }
+  return v;
+}
+
+function formatTimeAxisLabel(ms) {
+  const d = new Date(ms);
+  if (Number.isNaN(d.getTime())) return String(ms ?? "");
+  const pad = (n) => String(n).padStart(2, "0");
+  return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}\n${pad(d.getHours())}:${pad(
+    d.getMinutes(),
+  )}`;
+}
+
+function downsamplePairs(pairs, maxPoints = MAX_STITCH_POINTS) {
+  if (!Array.isArray(pairs) || pairs.length <= maxPoints) return pairs;
+  const step = Math.ceil(pairs.length / maxPoints);
+  const next = [];
+  for (let i = 0; i < pairs.length; i += step) {
+    next.push(pairs[i]);
+  }
+  if (next[next.length - 1] !== pairs[pairs.length - 1]) {
+    next.push(pairs[pairs.length - 1]);
+  }
+  return next;
+}
+
+function isCompositionPanel(panel) {
+  if (panel.xaxis || panel.yaxis) return false;
+  const rows = panel.data || [];
+  if (!rows.length) return false;
+  return rows.every((row) => {
+    const xArr = getRowXData(row);
+    const yArr = getRowYData(row);
+    return (
+      xArr.length === 1 &&
+      yArr.length === 1 &&
+      Number(xArr[0]) === Number(yArr[0])
+    );
+  });
+}
+
+function buildCompositionOption(panel, title) {
+  return {
+    backgroundColor: "#ffffff",
+    animation: false,
+    animationDuration: 0,
+    title: {
+      text: title || panel.title || "",
+      left: "center",
+      top: 8,
+      textStyle: { fontSize: 13, color: "#0f172a" },
+    },
+    tooltip: { trigger: "item" },
+    legend: {
+      bottom: 8,
+      left: "center",
+      textStyle: { fontSize: 10, color: "#334155" },
+    },
+    series: [
+      {
+        type: "pie",
+        radius: ["38%", "62%"],
+        center: ["50%", "52%"],
+        data: (panel.data || []).map((row, idx) => ({
+          name: row.label,
+          value: Number(getRowXData(row)[0]) || Number(getRowYData(row)[0]) || 0,
+          itemStyle: { color: pickColor(row.label, idx) },
+        })),
+        label: { fontSize: 11, formatter: "{b}\n{d}%" },
+      },
+    ],
+  };
+}
+
+function buildSeriesFromPanel(panel, poKey, options = {}) {
+  const templateKey = options.templateKey || "";
+  const farmMode = Boolean(options.farmMode);
+  const forceScatter =
+    SCATTER_ONLY_PO_KEYS.has(poKey) ||
+    (farmMode &&
+      ["wind_power_scatter", "ctrl_op_state", "aero_tsr_wind"].includes(
+        templateKey,
+      ));
+  const firstRow = panel.data?.[0];
+  const sampleX = getRowXData(firstRow)[0];
+  const hasTimeData = (panel.data || []).some(
+    (row) => Array.isArray(row.timeData) && row.timeData.length,
+  );
+  const useTime = hasTimeData || isTimeAxis(panel.xaxis, sampleX);
+  const series = [];
+
+  (panel.data || []).forEach((row, index) => {
+    const xArr = getRowXData(row);
+    const yArr = getRowYData(row);
+    const len = Math.min(xArr.length, yArr.length);
+    const rawPairs = [];
+    for (let i = 0; i < len; i += 1) {
+      const y = toYNumber(yArr[i]);
+      if (y == null) continue;
+      rawPairs.push([toAxisValue(xArr[i], useTime), y]);
+    }
+    const pairs = downsamplePairs(
+      rawPairs,
+      farmMode ? FARM_MAX_POINTS_PER_SERIES : MAX_STITCH_POINTS,
+    );
+    if (!pairs.length) return;
+    const originalLabel = row.originalLabel || row.label;
+    const bladeKind = farmMode ? farmBladeKind(originalLabel) : null;
+    const deloadKind = farmMode
+      ? farmDeloadKind(originalLabel, row.label)
+      : null;
+    let mode = forceScatter && !row.__shared ? "markers" : String(row.mode || "lines");
+    if (farmMode && templateKey === "pitch_regulation") {
+      if (bladeKind === "markers") mode = "markers";
+      if (bladeKind === "dashed" || bladeKind === "solid") mode = "lines";
+    }
+    if (row.__shared) mode = "lines";
+    const color = farmMode
+      ? row.__shared
+        ? "#64748B"
+        : farmTurbineColor(row.turbineIndex ?? index)
+      : pickColor(row.label, index);
+    const isMarker = mode.includes("markers");
+    const isLine = mode.includes("lines");
+
+    if (isMarker && !isLine) {
+      series.push({
+        name: row.label,
+        type: "scatter",
+        data: pairs,
+        symbol: deloadKind === "anomaly" ? "diamond" : "circle",
+        symbolSize: scatterSymbolSize(pairs.length, farmMode, deloadKind),
+        large: false,
+        progressive: 0,
+        animation: false,
+        itemStyle: {
+          color,
+          opacity: deloadKind === "anomaly" ? 0.95 : farmMode ? 0.88 : 0.85,
+        },
+      });
+      return;
+    }
+
+    const dashed =
+      bladeKind === "dashed" ||
+      Boolean(row.__shared) ||
+      isDashedLine(originalLabel) ||
+      isDashedLine(row.label);
+    series.push({
+      name: row.label,
+      type: "line",
+      data: pairs,
+      showSymbol: isMarker,
+      symbolSize: isMarker ? scatterSymbolSize(pairs.length, farmMode, deloadKind) : 4,
+      smooth: false,
+      animation: false,
+      progressive: 0,
+      lineStyle: {
+        width: row.__shared ? 2.5 : farmMode ? 1.8 : 2,
+        color,
+        type: dashed ? "dashed" : "solid",
+      },
+      itemStyle: { color },
+    });
+  });
+
+  return { series, useTime };
+}
+
+function buildOptionFromPanel(panel, title, poKey, extra = {}) {
+  if (isCompositionPanel(panel) && !extra.farmMode) {
+    return buildCompositionOption(panel, title);
+  }
+  const { series, useTime } = buildSeriesFromPanel(panel, poKey, extra);
+  const hasLegend = series.length > 1;
+  return {
+    backgroundColor: "#ffffff",
+    animation: false,
+    animationDuration: 0,
+    progressive: 0,
+    title: {
+      text: title || panel.title || "检测器图表",
+      left: "center",
+      top: 8,
+      textStyle: { fontSize: 13, color: "#0f172a" },
+    },
+    tooltip: { trigger: "axis" },
+    legend: hasLegend
+      ? {
+          type: "scroll",
+          bottom: 8,
+          left: "center",
+          itemWidth: 12,
+          itemHeight: 8,
+          textStyle: {
+            fontSize: series.length > 20 ? 9 : 10,
+            color: "#334155",
+          },
+        }
+      : undefined,
+    grid: {
+      top: 48,
+      left: 52,
+      right: 24,
+      bottom: hasLegend ? (useTime ? 72 : 56) : useTime ? 56 : 40,
+      containLabel: true,
+    },
+    xAxis: {
+      type: useTime ? "time" : "value",
+      name: panel.xaxis,
+      nameLocation: "middle",
+      nameGap: useTime ? 36 : 28,
+      nameTextStyle: { fontSize: 10, color: "#64748b" },
+      axisLabel: {
+        fontSize: 10,
+        color: "#64748b",
+        hideOverlap: true,
+        formatter: useTime ? (v) => formatTimeAxisLabel(v) : undefined,
+      },
+      splitLine: { lineStyle: { color: "#e2e8f0" } },
+    },
+    yAxis: {
+      type: "value",
+      name: panel.yaxis,
+      nameTextStyle: { fontSize: 10, color: "#64748b" },
+      axisLabel: { fontSize: 10, color: "#64748b" },
+      splitLine: { lineStyle: { color: "#e2e8f0" } },
+    },
+    series,
+  };
+}
+
+export function buildDetectorPlotOption(plotJson, title = "", poKey = "") {
+  const panels = extractPanels(plotJson);
+  const panel = panels[0] || { data: [], xaxis: "", yaxis: "", title: "" };
+  return buildOptionFromPanel(panel, title || panel.title, poKey, {
+    farmMode: Boolean(plotJson?.__farm),
+    templateKey: plotJson?.__templateKey || "",
+  });
+}
+
+/** 单机分图:每个 panel 单独出一张,避免只画第一面导致空白/错轴。 */
+export function listDetectorPlotOptions(plotJson, title = "", poKey = "") {
+  if (plotJson?.__farm) {
+    return [buildDetectorPlotOption(plotJson, title, poKey)];
+  }
+  const panels = extractPanels(plotJson);
+  if (!panels.length) return [];
+  return panels.map((panel) =>
+    buildOptionFromPanel(
+      panel,
+      panels.length > 1 && panel.title
+        ? `${title}(${panel.title})`
+        : title || panel.title,
+      poKey,
+      { farmMode: false, templateKey: "" },
+    ),
+  );
+}
+
+export function buildAnomalyOverviewBarOption(overview = {}, modelList = []) {
+  const categories = ["风功率", "偏航", "变桨", "运行状态", "气动性能"];
+  const values = [
+    Number(overview.model1Count) || 0,
+    Number(overview.model2Count) || 0,
+    Number(overview.model3Count) || 0,
+    Number(overview.model4Count) || 0,
+    Number(overview.model5Count) || 0,
+  ];
+  return {
+    backgroundColor: "#ffffff",
+    title: {
+      text: `异常风机 ${modelList.length} 台`,
+      left: "center",
+      top: 8,
+      textStyle: { fontSize: 13, color: "#0f172a" },
+    },
+    tooltip: { trigger: "axis" },
+    grid: { top: 48, left: 48, right: 24, bottom: 40, containLabel: true },
+    xAxis: { type: "category", data: categories },
+    yAxis: { type: "value", minInterval: 1 },
+    series: [
+      {
+        type: "bar",
+        data: values,
+        barWidth: 36,
+        itemStyle: { color: "#1E7CF8", borderRadius: [4, 4, 0, 0] },
+        label: { show: true, position: "top" },
+      },
+    ],
+  };
+}
+
+export function buildSensorAnomalyBarOption(rows = []) {
+  const list = rows.slice(0, 40);
+  return {
+    backgroundColor: "#ffffff",
+    title: {
+      text: "各机组数据感知异常分布",
+      left: "center",
+      top: 8,
+      textStyle: { fontSize: 13, color: "#0f172a" },
+    },
+    tooltip: { trigger: "axis" },
+    grid: { top: 48, left: 48, right: 24, bottom: 72, containLabel: true },
+    xAxis: {
+      type: "category",
+      data: list.map((row) => row.turbine_name),
+      axisLabel: { rotate: list.length > 12 ? 40 : 0, fontSize: 10 },
+    },
+    yAxis: { type: "value", minInterval: 1, name: "异常点数" },
+    series: [
+      {
+        type: "bar",
+        data: list.map((row) => Number(row.anomaly_points) || 0),
+        barMaxWidth: 28,
+        itemStyle: { color: "#1E7CF8", borderRadius: [4, 4, 0, 0] },
+      },
+    ],
+  };
+}
+
+function downsampleRow(row, maxPoints = MAX_STITCH_POINTS) {
+  const xArr = getRowXData(row);
+  const yArr = getRowYData(row);
+  const len = Math.min(xArr.length, yArr.length);
+  if (len <= maxPoints) {
+    return {
+      ...row,
+      xData: xArr.slice(0, len),
+      yData: yArr.slice(0, len),
+      timeData: Array.isArray(row.timeData)
+        ? row.timeData.slice(0, len)
+        : row.timeData,
+    };
+  }
+  const step = Math.ceil(len / maxPoints);
+  const xData = [];
+  const yData = [];
+  const timeData = Array.isArray(row.timeData) ? [] : undefined;
+  for (let i = 0; i < len; i += step) {
+    xData.push(xArr[i]);
+    yData.push(yArr[i]);
+    if (timeData) timeData.push(row.timeData[i]);
+  }
+  return { ...row, xData, yData, timeData };
+}
+
+/**
+ * 全场总图:按风机分色,并按检测器规则过滤系列/分面。
+ * items: [{ engineName, plotJson }]
+ */
+export function buildFarmPlotJsons(items = [], templateKey = "", title = "") {
+  const normalized = items
+    .map((item, index) => {
+      if (item && item.plotJson) {
+        return {
+          engineName: item.engineName || `风机${index + 1}`,
+          plotJson: item.plotJson,
+        };
+      }
+      return {
+        engineName: `风机${index + 1}`,
+        plotJson: item,
+      };
+    })
+    .filter((item) => item.plotJson);
+  if (!normalized.length) return [];
+
+  const panelSets = normalized.map((item) =>
+    selectFarmPanels(extractPanels(item.plotJson), templateKey),
+  );
+  const panelCount = Math.max(0, ...panelSets.map((list) => list.length));
+  const plots = [];
+
+  for (let panelIndex = 0; panelIndex < panelCount; panelIndex += 1) {
+    const data = [];
+    let xaxis = "";
+    let yaxis = "";
+    let panelTitle = "";
+    const sharedSeen = new Set();
+
+    normalized.forEach((item, turbineIndex) => {
+      const panel = panelSets[turbineIndex][panelIndex];
+      if (!panel) return;
+      xaxis = panel.xaxis || xaxis;
+      yaxis = panel.yaxis || yaxis;
+      panelTitle = panel.title || panelTitle;
+      const rows = (panel.data || []).filter(
+        (row) => !shouldExcludeFarmLabel(row.label, templateKey),
+      );
+      const turbineRows = rows.filter(
+        (row) => !isSharedFarmSeries(row.label, templateKey),
+      );
+      rows.forEach((row) => {
+        if (isSharedFarmSeries(row.label, templateKey)) {
+          const key = String(row.label || "shared");
+          if (sharedSeen.has(key)) return;
+          sharedSeen.add(key);
+          data.push({
+            ...downsampleRow(row, FARM_MAX_POINTS_PER_SERIES),
+            label: row.label,
+            originalLabel: row.label,
+            __shared: true,
+            __farm: true,
+          });
+          return;
+        }
+        const seriesLabel =
+          turbineRows.length <= 1
+            ? item.engineName
+            : `${item.engineName} ${row.label || ""}`.trim();
+        data.push({
+          ...downsampleRow(row, FARM_MAX_POINTS_PER_SERIES),
+          label: seriesLabel,
+          originalLabel: row.label,
+          engineName: item.engineName,
+          turbineIndex,
+          __farm: true,
+        });
+      });
+    });
+
+    if (!data.length) continue;
+    const chartTitle =
+      panelTitle && !String(title || "").includes(panelTitle)
+        ? `${title}(${panelTitle})`
+        : title || panelTitle;
+    plots.push({
+      title: chartTitle,
+      xaxis,
+      yaxis,
+      data,
+      panels: [{ panelTitle: chartTitle, xaxis, yaxis, data }],
+      __farm: true,
+      __templateKey: templateKey,
+    });
+  }
+  return plots;
+}
+
+/**
+ * 将同一检测器下多台风机的 Plot JSON 拼成全场图(默认第一张)。
+ */
+export function stitchFarmPlotJson(
+  plotJsonList = [],
+  title = "",
+  templateKey = "",
+) {
+  const items = plotJsonList.map((item, index) =>
+    item?.plotJson
+      ? item
+      : { engineName: `风机${index + 1}`, plotJson: item },
+  );
+  const plots = buildFarmPlotJsons(items, templateKey, title);
+  return plots[0] || null;
+}
+
+export function mergeModuleBundle(moduleBundle = {}) {
+  return {
+    ...(moduleBundle.wind || {}),
+    ...(moduleBundle.yaw || {}),
+    ...(moduleBundle.pitch || {}),
+    ...(moduleBundle.run || {}),
+    ...(moduleBundle.aero || {}),
+  };
+}
+
+export function collectDetectorCharts(moduleBundle = {}, engineId = "") {
+  const charts = [];
+  const merged = mergeModuleBundle(moduleBundle);
+  DETECTOR_TEMPLATE_CONFIG.forEach((cfg, index) => {
+    if (!cfg.poKey) return;
+    const po = merged[cfg.poKey];
+    const graphPath = po?.graphPath;
+    if (!graphPath) return;
+    charts.push({
+      detectorKey: cfg.poKey,
+      templateKey: cfg.templateKey,
+      detector_title: `${engineId} - ${cfg.title}`,
+      graphPath,
+      po,
+      imageKey: `anomaly_${engineId}_${cfg.poKey || index}`.replace(
+        /[^\w-]/g,
+        "_",
+      ),
+    });
+  });
+  return charts;
+}

+ 391 - 0
downLoadServer/src/server/reportService/anomalyReportMapper.js

@@ -0,0 +1,391 @@
+import { DETECTOR_TEMPLATE_CONFIG } from "./anomalyChartBuilder.js";
+
+const SENSOR_TYPE_LABELS = {
+  1: "功率异常",
+  2: "风速异常",
+  3: "变桨角度异常",
+  4: "转速异常",
+  5: "扭矩异常",
+  6: "风速-功率逻辑异常",
+  7: "转速-扭矩逻辑异常",
+};
+
+const MODEL_SENSOR_FIELDS = [
+  { field: "sensorAnomalyPower", label: "功率异常" },
+  { field: "sensorAnomalyWind", label: "风速异常" },
+  { field: "sensorAnomalyPitch", label: "变桨角度异常" },
+  { field: "sensorAnomalySpeed", label: "转速异常" },
+  { field: "sensorAnomalyTorque", label: "扭矩异常" },
+  { field: "sensorAnomalyWindPwr", label: "风速-功率逻辑异常" },
+  { field: "sensorAnomalySpdTrq", label: "转速-扭矩逻辑异常" },
+];
+
+const WATCHLIST_RATIO_FIELDS = [
+  "model1WindpwrScatterRatio",
+  "model2YawStaticyawRatio",
+  "model2YawCabletwistRatio",
+  "model3PitchPitchregulationRatio",
+  "model3PitchPitchcoordRatio",
+  "model4CtrlparamPowerqualityRatio",
+  "model4CtrlparamOperationstateRatio",
+  "model5AerodynamicsCpRatio",
+  "model5AerodynamicsTsrRatio",
+  "model5AerodynamicsCpTsrRatio",
+];
+
+export function displayTurbineName(item = {}) {
+  return item.engineName || item.engineId || "";
+}
+
+export function sortTurbines(list = []) {
+  return [...list].sort((a, b) =>
+    String(displayTurbineName(a)).localeCompare(String(displayTurbineName(b)), "zh-CN", {
+      numeric: true,
+    }),
+  );
+}
+
+export function toPercentNumber(value) {
+  const num = Number(value);
+  if (!Number.isFinite(num)) return 0;
+  return Number.parseFloat((num * 100).toPrecision(12));
+}
+
+export function formatPercent(value, digits = 2) {
+  return toPercentNumber(value).toFixed(digits);
+}
+
+function pad2(n) {
+  return String(n).padStart(2, "0");
+}
+
+function parseDateParts(raw) {
+  const text = String(raw || "").trim();
+  const match = text.match(/^(\d{4})[-/](\d{1,2})(?:[-/](\d{1,2}))?/);
+  if (match) {
+    return {
+      year: match[1],
+      month: pad2(match[2]),
+      day: pad2(match[3] || "1"),
+    };
+  }
+  const now = new Date();
+  return {
+    year: String(now.getFullYear()),
+    month: pad2(now.getMonth() + 1),
+    day: pad2(now.getDate()),
+  };
+}
+
+function formatSensorTypeText(item = {}) {
+  const fromFlags = MODEL_SENSOR_FIELDS.filter(
+    (cfg) => Number(item[cfg.field]) > 0,
+  ).map((cfg) => cfg.label);
+  if (fromFlags.length) return fromFlags.join("、");
+  const raw = item.sensorAnomalyType;
+  if (raw == null || raw === "") return "暂无异常";
+  const labels = String(raw)
+    .split(/[,|、\s]+/)
+    .map((code) => SENSOR_TYPE_LABELS[Number(code)] || "")
+    .filter(Boolean);
+  return labels.length ? labels.join("、") : "暂无异常";
+}
+
+function sensorAnomalyPoints(item = {}) {
+  const count = Number(item.sensorAnomalyCount);
+  if (Number.isFinite(count) && count > 0) return count;
+  return MODEL_SENSOR_FIELDS.reduce(
+    (sum, cfg) => sum + (Number(item[cfg.field]) > 0 ? 1 : 0),
+    0,
+  );
+}
+
+export function pickPo(moduleBundle, poKey) {
+  if (!poKey || !moduleBundle) return null;
+  return (
+    moduleBundle.wind?.[poKey] ||
+    moduleBundle.yaw?.[poKey] ||
+    moduleBundle.pitch?.[poKey] ||
+    moduleBundle.run?.[poKey] ||
+    moduleBundle.aero?.[poKey] ||
+    null
+  );
+}
+
+export function isPoAnomaly(po) {
+  return Number(po?.detectorIsAnomaly) === 1;
+}
+
+export function poAnomalyPoints(po) {
+  return Number(po?.detectorAnomalyCount) || 0;
+}
+
+export function poAnomalyRate(po) {
+  const raw = Number(po?.detectorAnomalyRate);
+  if (Number.isFinite(raw)) return raw;
+  const anomaly = poAnomalyPoints(po);
+  const normal = Number(po?.detectorNormallyCount) || 0;
+  const total = anomaly + normal;
+  return total ? anomaly / total : 0;
+}
+
+export function buildDetectorComment(po, templateKey) {
+  if (!po) return "暂无数据";
+  if (templateKey === "wind_power_curve") {
+    const ratio = Number(po.detectorAnomalyRate);
+    if (Number.isFinite(ratio) && ratio > 1.2) return "相对理论功率曲线超发";
+    if (Number.isFinite(ratio) && ratio > 0 && ratio < 0.8) {
+      return "相对理论功率曲线欠发";
+    }
+  }
+  if (templateKey === "ctrl_deload") {
+    return isPoAnomaly(po) ? "存在降载运行" : "未见明显降载";
+  }
+  return isPoAnomaly(po) ? "功能诊断异常" : "正常";
+}
+
+function isWatchlistUnit(item) {
+  return WATCHLIST_RATIO_FIELDS.some((key) => {
+    const num = Number(item?.[key]);
+    return Number.isFinite(num) && num > 0.5;
+  });
+}
+
+function collectMachineTypes(modelList = []) {
+  return [
+    ...new Set(
+      modelList
+        .map(
+          (item) =>
+            item.machineTypeCode ||
+            item.engineTypeName ||
+            item.engineTypeCode ||
+            item.modelName ||
+            "",
+        )
+        .filter(Boolean),
+    ),
+  ];
+}
+
+function buildOverviewText(fieldMeta = {}, fieldName, machineTypes, turbineCount) {
+  const province = fieldMeta.provinceName || fieldMeta.province || "";
+  const city = fieldMeta.cityName || fieldMeta.city || "";
+  const location = [province, city].filter(Boolean).join("");
+  const typeText = machineTypes.join("、") || "—";
+  if (location) {
+    return `${fieldName}位于${location},机型${typeText},共安装${turbineCount}台风机。`;
+  }
+  return `${fieldName}风电场,机型${typeText},接入机组${turbineCount}台。`;
+}
+
+function riskLevel(rate, turbines) {
+  if (turbines >= 8 || rate >= 0.2) return "P1";
+  if (turbines >= 3 || rate >= 0.05) return "P2";
+  return "P3";
+}
+
+export function buildEmptyDetectorSection(cfg) {
+  const farmTag = `zn-techcn-replace-tags-${cfg.templateKey}-farmSummary`;
+  const fileTag = `zn-techcn-replace-tags-${cfg.templateKey}-generalFiles`;
+  const rowsKey = `${cfg.templateKey}Rows`;
+  return {
+    [`show-${fileTag}`]: [],
+    [farmTag]: [],
+    [fileTag]: [],
+    [rowsKey]: [],
+  };
+}
+
+export function buildDetectorSectionPayload(cfg, {
+  farmImages = [],
+  turbineImages = [],
+  rows = [],
+  anomalyPoints = 0,
+  anomalyRate = 0,
+  anomalyTurbines = 0,
+}) {
+  const farmTag = `zn-techcn-replace-tags-${cfg.templateKey}-farmSummary`;
+  const fileTag = `zn-techcn-replace-tags-${cfg.templateKey}-generalFiles`;
+  const rowsKey = `${cfg.templateKey}Rows`;
+  const section = {
+    anomaly_points: String(anomalyPoints),
+    anomaly_rate: `${formatPercent(anomalyRate)}%`,
+    anomaly_turbines: String(anomalyTurbines),
+    [farmTag]: farmImages,
+    [fileTag]: turbineImages,
+    [rowsKey]: rows,
+  };
+  return {
+    [`show-${fileTag}`]: [section],
+    [farmTag]: farmImages,
+    [fileTag]: turbineImages,
+    [rowsKey]: rows,
+  };
+}
+
+export function mapAnomalyCoverFields({
+  fieldCode,
+  datatime,
+  fieldName,
+  fieldMeta = {},
+  overview = {},
+  modelList = [],
+}) {
+  const parts = parseDateParts(datatime || overview.sourceDatetime);
+  const turbines = sortTurbines(modelList);
+  const machineTypes = collectMachineTypes(turbines);
+  const farm =
+    fieldName ||
+    fieldMeta.fieldName ||
+    fieldMeta.companyName ||
+    overview.fieldName ||
+    fieldCode;
+  const turbineCount = turbines.length || Number(fieldMeta.engineCount) || 0;
+  return {
+    reportNo: `AD-${fieldCode || "FIELD"}-${parts.year}${parts.month}${parts.day}`,
+    Province: fieldMeta.provinceName || fieldMeta.province || "",
+    Wind_farm: farm,
+    Year_now: parts.year,
+    Month_now: parts.month,
+    machineTypeCode: machineTypes.join("、") || fieldMeta.machineTypeCode || "—",
+    turbine_count: String(turbineCount),
+    target_date: datatime || overview.sourceDatetime || `${parts.year}-${parts.month}-${parts.day}`,
+    Overview_of_the_Wind_Farm: buildOverviewText(
+      fieldMeta,
+      farm,
+      machineTypes,
+      turbineCount,
+    ),
+  };
+}
+
+export function buildSensorAnomalyRows(modelList = []) {
+  return sortTurbines(modelList)
+    .map((item) => {
+      const points = sensorAnomalyPoints(item);
+      if (!points) return null;
+      const typeText = formatSensorTypeText(item);
+      return {
+        turbine_name: displayTurbineName(item),
+        sensor_anomaly_type: typeText,
+        anomaly_points: String(points),
+        ratio: points ? "—" : "0%",
+      };
+    })
+    .filter(Boolean);
+}
+
+export function buildDetectorSummaryRows(aggregates = {}) {
+  return DETECTOR_TEMPLATE_CONFIG.filter((cfg) => cfg.poKey).map((cfg) => {
+    const item = aggregates[cfg.templateKey] || {};
+    return {
+      detector_name: cfg.title,
+      module_name: cfg.moduleName,
+      data_granularity: cfg.granularity,
+      anomaly_turbines: String(item.anomalyTurbines || 0),
+      anomaly_points: String(item.anomalyPoints || 0),
+      avg_anomaly_rate: `${formatPercent(item.avgRate || 0)}%`,
+    };
+  });
+}
+
+export function buildAnomalySummaryRows(turbineStats = []) {
+  return turbineStats
+    .filter((item) => item.anomalyDetectorCount > 0 || item.anomalyPoints > 0)
+    .map((item) => ({
+      turbine_name: item.turbineName,
+      anomaly_detector_count: String(item.anomalyDetectorCount),
+      anomaly_points: String(item.anomalyPoints),
+      anomaly_rate: `${formatPercent(item.anomalyRate || 0)}%`,
+      main_anomaly_type: item.mainAnomalyType || "—",
+    }));
+}
+
+export function pickKeyTurbines(turbineStats = [], modelList = [], limit = 5) {
+  const watchIds = new Set(
+    modelList.filter(isWatchlistUnit).map((item) => String(item.engineId || "")),
+  );
+  const ranked = [...turbineStats].sort((a, b) => {
+    const aWatch = watchIds.has(String(a.engineId)) ? 1 : 0;
+    const bWatch = watchIds.has(String(b.engineId)) ? 1 : 0;
+    if (aWatch !== bWatch) return bWatch - aWatch;
+    if (b.anomalyPoints !== a.anomalyPoints) {
+      return b.anomalyPoints - a.anomalyPoints;
+    }
+    return b.anomalyDetectorCount - a.anomalyDetectorCount;
+  });
+  const selected = ranked.filter(
+    (item) =>
+      watchIds.has(String(item.engineId)) ||
+      item.anomalyPoints > 0 ||
+      item.anomalyDetectorCount > 0,
+  );
+  return (selected.length ? selected : ranked).slice(0, limit);
+}
+
+export function buildConclusionRows(aggregates = {}) {
+  return DETECTOR_TEMPLATE_CONFIG.filter((cfg) => cfg.poKey)
+    .map((cfg, index) => {
+      const item = aggregates[cfg.templateKey] || {};
+      if (!item.anomalyTurbines && !item.anomalyPoints) return null;
+      const names = (item.turbineNames || []).slice(0, 8).join("、");
+      return {
+        index: String(index + 1),
+        problem_type: cfg.title,
+        turbine_names: names || "—",
+        anomaly_points: String(item.anomalyPoints || 0),
+        risk_level: riskLevel(item.avgRate || 0, item.anomalyTurbines || 0),
+        suggestion: cfg.suggestion,
+      };
+    })
+    .filter(Boolean)
+    .map((row, index) => ({ ...row, index: String(index + 1) }));
+}
+
+export function summarizeFarmStats({
+  overview = {},
+  modelList = [],
+  turbineStats = [],
+  aggregates = {},
+}) {
+  const turbineCount = modelList.length;
+  const anomalyTurbines = turbineStats.filter(
+    (item) => item.anomalyDetectorCount > 0,
+  ).length;
+  const totalPoints = turbineStats.reduce(
+    (sum, item) => sum + (item.anomalyPoints || 0),
+    0,
+  );
+  const overviewPoints =
+    Number(overview.detectorAnomalyCount) ||
+    Number(overview.totalAnomalyCount) ||
+    totalPoints;
+  const rate = turbineCount ? anomalyTurbines / turbineCount : 0;
+  const moduleKeys = new Set(
+    DETECTOR_TEMPLATE_CONFIG.filter(
+      (cfg) => (aggregates[cfg.templateKey]?.anomalyTurbines || 0) > 0,
+    ).map((cfg) => cfg.moduleName),
+  );
+  const topDetector = Object.entries(aggregates)
+    .map(([key, value]) => ({
+      key,
+      ...value,
+      title: DETECTOR_TEMPLATE_CONFIG.find((cfg) => cfg.templateKey === key)?.title,
+    }))
+    .sort((a, b) => (b.anomalyPoints || 0) - (a.anomalyPoints || 0))[0];
+
+  return {
+    turbine_count: String(turbineCount),
+    anomaly_turbine_count: String(
+      Number(overview.anomalyCount) || anomalyTurbines,
+    ),
+    total_anomaly_points: String(overviewPoints || totalPoints),
+    anomaly_rate: formatPercent(rate),
+    anomaly_module_count: String(moduleKeys.size),
+    main_problem_modules: [...moduleKeys].join("、") || "暂无突出模块",
+    top_problem_description: topDetector?.title
+      ? `${topDetector.title}异常点数最多(${topDetector.anomalyPoints || 0})`
+      : "各检测器未见集中异常",
+  };
+}

+ 438 - 0
downLoadServer/src/server/reportService/anomalyReportService.js

@@ -0,0 +1,438 @@
+import pLimit from "p-limit";
+import {
+  fetchAnomalyModel,
+  fetchAnomalyOverview,
+  fetchAnomalyTurbineModules,
+  fetchRemoteMedia,
+} from "./analyseApiClient.js";
+import {
+  DETECTOR_TEMPLATE_CONFIG,
+  buildDetectorPlotOption,
+  listDetectorPlotOptions,
+  buildSensorAnomalyBarOption,
+  buildFarmPlotJsons,
+} from "./anomalyChartBuilder.js";
+import {
+  buildAnomalySummaryRows,
+  buildConclusionRows,
+  buildDetectorComment,
+  buildDetectorSectionPayload,
+  buildDetectorSummaryRows,
+  buildEmptyDetectorSection,
+  buildSensorAnomalyRows,
+  displayTurbineName,
+  formatPercent,
+  isPoAnomaly,
+  mapAnomalyCoverFields,
+  pickKeyTurbines,
+  pickPo,
+  poAnomalyPoints,
+  poAnomalyRate,
+  sortTurbines,
+  summarizeFarmStats,
+} from "./anomalyReportMapper.js";
+import {
+  registerImageBuffer,
+  renderDocxReport,
+} from "./docxReportBuilder.js";
+import { renderEchartsOption } from "./echartsRenderer.js";
+
+const ANOMALY_TEMPLATE = "异常检测数据分析报告模板(大唐版).docx";
+
+const turbineLimit = pLimit(
+  Number.parseInt(process.env.REPORT_TREND_CONCURRENCY || "2", 10),
+);
+const mediaLimit = pLimit(
+  Number.parseInt(process.env.REPORT_FETCH_CONCURRENCY || "4", 10),
+);
+
+function buildAuthHeaders(req) {
+  const headers = {};
+  const token = req.headers.token || req.headers.Token;
+  if (token) headers.token = token;
+  const showIp = req.headers.showip || req.headers.showIp;
+  if (showIp) headers.showIp = showIp;
+  return headers;
+}
+
+function unwrapClientPayload(raw) {
+  if (!raw || typeof raw !== "object") return raw;
+  if ("data" in raw && (raw.code === 200 || raw.data != null)) {
+    return raw.data;
+  }
+  return raw;
+}
+
+function normalizeModelList(raw) {
+  const data = unwrapClientPayload(raw);
+  if (Array.isArray(data)) return data;
+  if (Array.isArray(data?.list)) return data.list;
+  if (Array.isArray(data?.records)) return data.records;
+  return [];
+}
+
+function normalizeOverview(raw) {
+  const data = unwrapClientPayload(raw) || {};
+  if (Array.isArray(data)) return data[0] || {};
+  return data;
+}
+
+function createDetectorState() {
+  const state = {};
+  DETECTOR_TEMPLATE_CONFIG.forEach((cfg) => {
+    state[cfg.templateKey] = {
+      cfg,
+      farmImages: [],
+      turbineImages: [],
+      rows: [],
+      plotJsons: [],
+      anomalyTurbines: 0,
+      anomalyPoints: 0,
+      rateSum: 0,
+      rateCount: 0,
+      turbineNames: [],
+    };
+  });
+  return state;
+}
+
+export async function generateAnomalyReport(req) {
+  const {
+    fieldCode,
+    datatime,
+    fieldName,
+    fieldMeta: fieldMetaFromClient,
+    overviewData: overviewFromClient,
+    modelList: modelFromClient,
+  } = req.body || {};
+  if (!fieldCode) {
+    throw new Error("缺少 fieldCode");
+  }
+
+  const authHeaders = buildAuthHeaders(req);
+  let overview = normalizeOverview(overviewFromClient);
+  let modelList = normalizeModelList(modelFromClient);
+  if (!modelList.length) {
+    const [overviewRes, modelRes] = await Promise.all([
+      Object.keys(overview || {}).length
+        ? Promise.resolve(overview)
+        : fetchAnomalyOverview({ fieldCode, datatime }, authHeaders),
+      fetchAnomalyModel({ fieldCode, datatime }, authHeaders),
+    ]);
+    overview = normalizeOverview(overviewRes);
+    modelList = normalizeModelList(modelRes);
+  } else if (!Object.keys(overview || {}).length) {
+    try {
+      overview = await fetchAnomalyOverview({ fieldCode, datatime }, authHeaders);
+    } catch (error) {
+      console.warn("[anomaly-report] 概览接口失败,使用已有风机列表继续:", error.message);
+      overview = {};
+    }
+  }
+
+  const turbines = sortTurbines(modelList);
+  const maxTurbines = Number.parseInt(
+    process.env.REPORT_ANOMALY_MAX_TURBINES || "0",
+    10,
+  );
+  const workList =
+    Number.isFinite(maxTurbines) && maxTurbines > 0
+      ? turbines.slice(0, maxTurbines)
+      : turbines;
+
+  console.log(
+    `[anomaly-report] 开始生成: field=${fieldCode} date=${datatime || ""} turbines=${workList.length}`,
+  );
+
+  const imageBufferMap = {};
+  const detectorState = createDetectorState();
+  const turbineStats = [];
+  const turbineImageMap = {};
+  const mediaCache = new Map();
+
+  const loadMedia = (url) => {
+    if (!url) return Promise.resolve(null);
+    if (mediaCache.has(url)) return mediaCache.get(url);
+    const pending = mediaLimit(async () => {
+      try {
+        return await fetchRemoteMedia(url);
+      } catch (error) {
+        console.warn("[anomaly-report] 资源下载失败:", url, error.message);
+        return null;
+      }
+    });
+    mediaCache.set(url, pending);
+    return pending;
+  };
+
+  await Promise.all(
+    workList.map((modelItem, index) =>
+      turbineLimit(async () => {
+        const engineId =
+          modelItem.engineId || modelItem.engineName || `t${index}`;
+        const engineName = displayTurbineName(modelItem);
+        const fieldId = modelItem.fieldId || fieldCode;
+        let moduleBundle = {};
+        try {
+          moduleBundle = await fetchAnomalyTurbineModules(
+            { fieldId, engineId, datatime },
+            authHeaders,
+          );
+        } catch (error) {
+          console.warn("[anomaly-report] 模块详情失败:", engineName, error.message);
+        }
+
+        const perTurbine = {
+          engineId,
+          engineName,
+          turbineName: engineName,
+          anomalyDetectorCount: 0,
+          anomalyPoints: 0,
+          anomalyRate: 0,
+          mainAnomalyType: "",
+          detectorHits: [],
+        };
+        turbineImageMap[engineId] = [];
+
+        for (const cfg of DETECTOR_TEMPLATE_CONFIG) {
+          if (!cfg.poKey) continue;
+          const po = pickPo(moduleBundle, cfg.poKey);
+          const state = detectorState[cfg.templateKey];
+          const points = poAnomalyPoints(po);
+          const rate = poAnomalyRate(po);
+          const abnormal = isPoAnomaly(po) || points > 0;
+          if (po) {
+            state.rateSum += rate;
+            state.rateCount += 1;
+          }
+          if (abnormal) {
+            state.anomalyTurbines += 1;
+            state.anomalyPoints += points;
+            state.turbineNames.push(engineName);
+            state.rows.push({
+              engineName,
+              turbine_name: engineName,
+              anomaly_points: String(points),
+              anomaly_rate: `${formatPercent(rate)}%`,
+              comment: buildDetectorComment(po, cfg.templateKey),
+            });
+            perTurbine.anomalyDetectorCount += 1;
+            perTurbine.anomalyPoints += points;
+            perTurbine.detectorHits.push({
+              title: cfg.title,
+              points,
+              rate,
+            });
+          }
+
+          const graphPath = po?.graphPath;
+          if (!graphPath) continue;
+          const media = await loadMedia(graphPath);
+          const imageKey = `anomaly_${String(engineId).replace(/[^\w-]/g, "_")}_${
+            cfg.templateKey
+          }`;
+          if (media?.kind === "image" && media.buffer?.length) {
+            registerImageBuffer(imageBufferMap, imageKey, media.buffer);
+            state.turbineImages.push({ engineName, image: imageKey });
+            turbineImageMap[engineId].push({
+              imageKey,
+              title: cfg.title,
+              points,
+            });
+          } else if (media?.kind === "json") {
+            state.plotJsons.push({ engineName, plotJson: media.data });
+            const plotOptions = listDetectorPlotOptions(
+              media.data,
+              `${engineName} ${cfg.title}`,
+              cfg.poKey,
+            );
+            for (let panelIndex = 0; panelIndex < plotOptions.length; panelIndex += 1) {
+              try {
+                const buffer = await renderEchartsOption(plotOptions[panelIndex], {
+                  width: 760,
+                  height: 420,
+                });
+                const panelKey = `${imageKey}_${panelIndex}`;
+                registerImageBuffer(imageBufferMap, panelKey, buffer);
+                state.turbineImages.push({ engineName, image: panelKey });
+                turbineImageMap[engineId].push({
+                  imageKey: panelKey,
+                  title: cfg.title,
+                  points,
+                });
+              } catch (error) {
+                console.warn(
+                  "[anomaly-report] 分图渲染失败:",
+                  engineName,
+                  cfg.title,
+                  error.message,
+                );
+              }
+            }
+          }
+        }
+
+        if (perTurbine.detectorHits.length) {
+          perTurbine.detectorHits.sort((a, b) => b.points - a.points);
+          perTurbine.mainAnomalyType = perTurbine.detectorHits[0].title;
+          perTurbine.anomalyRate =
+            perTurbine.detectorHits.reduce((sum, hit) => sum + hit.rate, 0) /
+            perTurbine.detectorHits.length;
+        }
+        turbineStats.push(perTurbine);
+      }),
+    ),
+  );
+
+  for (const cfg of DETECTOR_TEMPLATE_CONFIG) {
+    const state = detectorState[cfg.templateKey];
+    state.turbineImages.sort((a, b) =>
+      String(a.engineName).localeCompare(String(b.engineName), "zh-CN", {
+        numeric: true,
+      }),
+    );
+    state.rows.sort((a, b) =>
+      String(a.engineName).localeCompare(String(b.engineName), "zh-CN", {
+        numeric: true,
+      }),
+    );
+    if (!cfg.poKey) continue;
+    const farmPlots = buildFarmPlotJsons(
+      state.plotJsons,
+      cfg.templateKey,
+      `全场${cfg.title}检测汇总`,
+    );
+    if (!farmPlots.length) {
+      state.plotJsons = [];
+      continue;
+    }
+    try {
+      for (let i = 0; i < farmPlots.length; i += 1) {
+        const plot = farmPlots[i];
+        const buffer = await renderEchartsOption(
+          buildDetectorPlotOption(plot, plot.title, cfg.poKey),
+          { width: 820, height: 440 },
+        );
+        const imageKey = `anomaly_farm_${cfg.templateKey}_${i}`;
+        registerImageBuffer(imageBufferMap, imageKey, buffer);
+        state.farmImages.push({ image: imageKey });
+      }
+    } catch (error) {
+      console.warn("[anomaly-report] 全场图渲染失败:", cfg.title, error.message);
+    }
+    state.plotJsons = [];
+  }
+
+  const sensorRows = buildSensorAnomalyRows(workList);
+  if (sensorRows.length) {
+    try {
+      const buffer = await renderEchartsOption(
+        buildSensorAnomalyBarOption(sensorRows),
+        { width: 820, height: 400 },
+      );
+      registerImageBuffer(imageBufferMap, "anomaly_sensor_dist", buffer);
+    } catch (error) {
+      console.warn("[anomaly-report] 数据感知分布图失败:", error.message);
+    }
+  }
+
+  const aggregates = {};
+  DETECTOR_TEMPLATE_CONFIG.forEach((cfg) => {
+    const state = detectorState[cfg.templateKey];
+    aggregates[cfg.templateKey] = {
+      anomalyTurbines: state.anomalyTurbines,
+      anomalyPoints: state.anomalyPoints,
+      avgRate: state.rateCount ? state.rateSum / state.rateCount : 0,
+      turbineNames: state.turbineNames,
+    };
+  });
+
+  const fieldMeta = fieldMetaFromClient || {};
+  const cover = mapAnomalyCoverFields({
+    fieldCode,
+    datatime,
+    fieldName,
+    fieldMeta,
+    overview,
+    modelList: workList,
+  });
+  const farmStats = summarizeFarmStats({
+    overview,
+    modelList: workList,
+    turbineStats,
+    aggregates,
+  });
+  const keyTurbines = pickKeyTurbines(sortTurbines(turbineStats), workList, 5);
+  const keyTurbineLoop = keyTurbines.map((item) => ({
+    turbine_name: item.turbineName,
+    turbineDetailRows: (item.detectorHits || []).map((hit) => ({
+      detector_name: hit.title,
+      anomaly_points: String(hit.points),
+      anomaly_rate: `${formatPercent(hit.rate)}%`,
+      conclusion: hit.points > 0 ? "建议结合现场复核" : "正常",
+    })),
+    "zn-techcn-replace-tags-key_turbine-generalFiles": (
+      turbineImageMap[item.engineId] || []
+    )
+      .sort((a, b) => (b.points || 0) - (a.points || 0))
+      .slice(0, 6)
+      .map((chart) => ({ image: chart.imageKey })),
+  }));
+
+  const renderData = {
+    ...cover,
+    ...farmStats,
+    sensorAnomalyRows: sensorRows,
+    detectorSummaryRows: buildDetectorSummaryRows(aggregates),
+    anomalySummaryRows: buildAnomalySummaryRows(sortTurbines(turbineStats)),
+    priorityList: keyTurbines.map((item) => item.turbineName).join("、") || "暂无",
+    keyTurbineLoop,
+    conclusionRows: buildConclusionRows(aggregates),
+    "zn-techcn-replace-tags-data_sensor_anomaly-generalFiles":
+      imageBufferMap.anomaly_sensor_dist
+        ? [{ image: "anomaly_sensor_dist" }]
+        : [],
+    "zn-techcn-replace-tags-key_turbine-generalFiles": [],
+  };
+
+  DETECTOR_TEMPLATE_CONFIG.forEach((cfg) => {
+    const state = detectorState[cfg.templateKey];
+    const farmImages = state.farmImages.map((item) => ({ image: item.image }));
+    const turbineImages = state.turbineImages.map((item) => ({
+      image: item.image,
+    }));
+    if (!cfg.poKey) {
+      Object.assign(renderData, buildEmptyDetectorSection(cfg));
+      return;
+    }
+    Object.assign(
+      renderData,
+      buildDetectorSectionPayload(cfg, {
+        farmImages,
+        turbineImages,
+        rows: state.rows,
+        anomalyPoints: state.anomalyPoints,
+        anomalyRate: state.rateCount ? state.rateSum / state.rateCount : 0,
+        anomalyTurbines: state.anomalyTurbines,
+      }),
+    );
+  });
+
+  console.log(
+    `[anomaly-report] 图表完成,开始渲染 Word。图片 ${
+      Object.keys(imageBufferMap).length
+    } 张`,
+  );
+
+  const buffer = await renderDocxReport({
+    templateName: ANOMALY_TEMPLATE,
+    renderData,
+    imageBufferMap,
+  });
+
+  const farmName = cover.Wind_farm || fieldCode;
+  const datePart = cover.target_date || "报告";
+  const fileName = `${farmName}_异常检测报告_${datePart}.docx`;
+  console.log("[anomaly-report] 完成:", fileName, "bytes=", buffer.length);
+  return { buffer, fileName };
+}

+ 604 - 0
downLoadServer/src/server/reportService/docxReportBuilder.js

@@ -0,0 +1,604 @@
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+import PizZip from "pizzip";
+import Docxtemplater from "docxtemplater";
+import ImageModule from "docxtemplater-image-module-free";
+import sizeOf from "image-size";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+/** 健康报告:自动插入位 → 多段落占位(图片标签必须独占一段) */
+export const HEALTH_IMAGE_MARKERS = [
+  {
+    search: "【自动插入:图1 风场综合评分和风机等级分布】",
+    paragraphs: ["{%chart_fig1}"],
+  },
+  {
+    search: "【自动插入:图2风场分类健康评分对比柱状图】",
+    paragraphs: ["{%chart_fig2}"],
+  },
+  {
+    search: "【自动插入:图3风机健康趋势图】",
+    paragraphs: [
+      "{#turbine_trends}",
+      "{engine_title}",
+      "{#charts}",
+      "{chart_title}",
+      "{%trend_image}",
+      "{/charts}",
+      "{/turbine_trends}",
+    ],
+  },
+];
+
+/** 异常报告 */
+export const ANOMALY_IMAGE_MARKERS = [
+  {
+    search: "【自动插入:异常概览图】",
+    paragraphs: ["{%chart_overview}"],
+  },
+  {
+    search: "【自动插入:风机检测器图表】",
+    paragraphs: [
+      "{#turbine_sections}",
+      "{engine_title}",
+      "{#detector_charts}",
+      "{detector_title}",
+      "{%detector_image}",
+      "{/detector_charts}",
+      "{/turbine_sections}",
+    ],
+  },
+];
+
+const PARAGRAPH_RE = /<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/g;
+const WT_RE = /<w:t(?:\s[^>]*)?>[\s\S]*?<\/w:t>/g;
+const CELL_RE = /<w:tc[\s\S]*?<\/w:tc>/g;
+
+function escapeXml(text) {
+  return String(text)
+    .replace(/&/g, "&amp;")
+    .replace(/</g, "&lt;")
+    .replace(/>/g, "&gt;");
+}
+
+function buildParagraphXml(text) {
+  return `<w:p><w:r><w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p>`;
+}
+
+function paragraphPlain(paragraph) {
+  return paragraph.replace(/<[^>]+>/g, "");
+}
+
+const HEALTH_TREND_LOOP_PARAS = [
+  "{#turbine_trends}",
+  "{engine_title}",
+  "{#charts}",
+  "{chart_title}",
+  "{%trend_image}",
+  "{/charts}",
+  "{/turbine_trends}",
+];
+
+/**
+ * 旧模板把 4 张趋势图合成一张;升级为每张图独立段落
+ */
+export function upgradeHealthTrendLoop(xml) {
+  if (xml.includes("{#charts}") && xml.includes("{%trend_image}")) {
+    return xml;
+  }
+  if (!xml.includes("{#turbine_trends}")) return xml;
+
+  PARAGRAPH_RE.lastIndex = 0;
+  const matches = [];
+  xml.replace(PARAGRAPH_RE, (paragraph, offset) => {
+    matches.push({
+      start: offset,
+      end: offset + paragraph.length,
+      plain: paragraphPlain(paragraph).trim(),
+    });
+    return paragraph;
+  });
+
+  const startIdx = matches.findIndex((item) =>
+    item.plain.includes("{#turbine_trends}"),
+  );
+  if (startIdx < 0) return xml;
+  const endIdx = matches.findIndex(
+    (item, idx) => idx >= startIdx && item.plain.includes("{/turbine_trends}"),
+  );
+  if (endIdx < 0) return xml;
+
+  const before = xml.slice(0, matches[startIdx].start);
+  const after = xml.slice(matches[endIdx].end);
+  return `${before}${HEALTH_TREND_LOOP_PARAS.map(buildParagraphXml).join("")}${after}`;
+}
+
+/**
+ * 将含 searchText 的整段替换为多个独立段落
+ * 注意:不能用 /<w:p/ ,会误匹配 <w:pPr>
+ */
+function replaceParagraphContaining(xml, searchText, paragraphs) {
+  return xml.replace(PARAGRAPH_RE, (paragraph) => {
+    if (!paragraphPlain(paragraph).includes(searchText)) return paragraph;
+    return paragraphs.map(buildParagraphXml).join("");
+  });
+}
+
+function setCellPlainText(cellXml, text) {
+  WT_RE.lastIndex = 0;
+  if (!WT_RE.test(cellXml)) {
+    WT_RE.lastIndex = 0;
+    return cellXml.replace(
+      /<\/w:tc>/,
+      `<w:p><w:r><w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p></w:tc>`,
+    );
+  }
+  WT_RE.lastIndex = 0;
+  let used = false;
+  return cellXml.replace(WT_RE, () => {
+    if (used) return `<w:t xml:space="preserve"></w:t>`;
+    used = true;
+    return `<w:t xml:space="preserve">${escapeXml(text)}</w:t>`;
+  });
+}
+
+function buildLoopRow(sampleRow, cellTexts) {
+  const cells = sampleRow.match(CELL_RE) || [];
+  const nextCells = cells.map((cell, i) =>
+    setCellPlainText(cell, cellTexts[i] || ""),
+  );
+  let i = 0;
+  return sampleRow.replace(CELL_RE, () => nextCells[i++] || "");
+}
+
+function rebuildTable(tblXml, headerRow, loopRow) {
+  const open = tblXml.match(/^<w:tbl[^>]*>/)?.[0] || "<w:tbl>";
+  const tblPr = tblXml.match(/<w:tblPr[\s\S]*?<\/w:tblPr>/)?.[0] || "";
+  const tblGrid = tblXml.match(/<w:tblGrid[\s\S]*?<\/w:tblGrid>/)?.[0] || "";
+  return `${open}${tblPr}${tblGrid}${headerRow}${loopRow}</w:tbl>`;
+}
+
+/** 把同一段落里被拆开的 {tag} 合并到同一个 w:t,避免 Malformed xml */
+export function mergeSplitPlaceholders(xml) {
+  return xml.replace(PARAGRAPH_RE, (paragraph) => {
+    const texts = [];
+    paragraph.replace(WT_RE, (full) => {
+      texts.push(full.replace(/^<w:t(?:\s[^>]*)?>/, "").replace(/<\/w:t>$/, ""));
+      return full;
+    });
+    const split = texts.some((t) => {
+      const open = (t.match(/{/g) || []).length;
+      const close = (t.match(/}/g) || []).length;
+      return open !== close;
+    });
+    if (!split) return paragraph;
+    const joined = texts.join("");
+    let index = 0;
+    return paragraph.replace(WT_RE, () => {
+      const value = index === 0 ? joined : "";
+      index += 1;
+      return `<w:t xml:space="preserve">${value}</w:t>`;
+    });
+  });
+}
+
+/**
+ * 明细表/关注表改为循环行,表头「风机编号」改为「风机名称」
+ */
+export function patchHealthTablesXml(xml) {
+  let next = xml;
+  next = next.replace(/<w:tbl[\s\S]*?<\/w:tbl>/g, (tbl) => {
+    if (tbl.includes("{#turbine_rows}") || tbl.includes("{#focus_rows}")) {
+      return tbl.replace(/风机编号/g, "风机名称");
+    }
+    const rows = tbl.match(/<w:tr[\s\S]*?<\/w:tr>/g) || [];
+    if (rows.length < 2) return tbl;
+
+    if (tbl.includes("{engine_id_1}")) {
+      const header = rows[0].replace(/风机编号/g, "风机名称");
+      const loopRow = buildLoopRow(rows[1], [
+        "{#turbine_rows}{index}",
+        "{engine_name}",
+        "{machine_type}",
+        "{overall}",
+        "{level}",
+        "{system}",
+        "{component}",
+        "{structure}",
+        "{yaw}",
+        "{pitch}",
+        "{mcs}",
+        "{hpu}",
+        "{generator}",
+        "{converter}",
+        "{gearbox}",
+        "{shaft}",
+        "{rotor}",
+        "{tower}{/turbine_rows}",
+      ]);
+      return rebuildTable(tbl, header, loopRow);
+    }
+
+    if (tbl.includes("{focus_engine_1}")) {
+      const header = rows[0].replace(/风机编号/g, "风机名称");
+      const loopRow = buildLoopRow(rows[1], [
+        "{#focus_rows}{index}",
+        "{engine_name}",
+        "{machine_type}",
+        "{overall}",
+        "{level}",
+        "{system}",
+        "{component}",
+        "{structure}{/focus_rows}",
+      ]);
+      return rebuildTable(tbl, header, loopRow);
+    }
+    return tbl;
+  });
+
+  next = replaceParagraphContaining(
+    next,
+    "按综合评分由低到高或按风机编号排序",
+    [
+      "下表展示所选评估日期下风场内各台风机的综合、分类及单项健康评分。报告按风机名称排序列出全部机组。",
+    ],
+  );
+  next = replaceParagraphContaining(
+    next,
+    "{focus_engine_1}风机综合健康评分",
+    [
+      "{#focus_rows}{engine_name}风机综合健康评分为{overall}分,健康等级为{level}。其系统健康评分为{system}分,部件健康评分为{component}分,结构健康评分为{structure}。其中,{low_item}评分相对较低,是影响该机组综合健康结果的主要评价项,建议结合该评价项下的单项评分及后续周期结果持续跟踪。{/focus_rows}",
+    ],
+  );
+  next = next.replace(PARAGRAPH_RE, (paragraph) => {
+    const plain = paragraphPlain(paragraph).trim();
+    if (plain.includes("{focus_engine_2}风机综合健康评分")) {
+      return buildParagraphXml("");
+    }
+    if (/^\{focus_structure_/.test(plain) || /^/未评估\}/.test(plain)) {
+      return buildParagraphXml("");
+    }
+    return paragraph;
+  });
+  return mergeSplitPlaceholders(next);
+}
+
+const IMAGE_TABLE_MARKER =
+  /\{%(?:chart_fig1|chart_fig2|trend_image|chart_overview|detector_image|image)\}|\{#turbine_trends\}|\{#charts\}|\{#detector_charts\}|zn-techcn-replace-tags-/;
+
+const ANOMALY_LOOP_OPEN_RE =
+  /^\{#zn-techcn-replace-tags-([a-z0-9_]+)-(generalFiles|farmSummary)\}$/;
+
+function centerParagraph(paragraph) {
+  if (/<w:jc\b/.test(paragraph)) {
+    return paragraph
+      .replace(/<w:jc\b[^/]*\/>/g, '<w:jc w:val="center"/>')
+      .replace(/<w:jc\b[^>]*>[\s\S]*?<\/w:jc>/g, '<w:jc w:val="center"/>');
+  }
+  if (paragraph.includes("<w:pPr>")) {
+    return paragraph.replace("<w:pPr>", '<w:pPr><w:jc w:val="center"/>');
+  }
+  return paragraph.replace(
+    /<w:p((?:\s[^>]*)?)>/,
+    '<w:p$1><w:pPr><w:jc w:val="center"/></w:pPr>',
+  );
+}
+
+/** 风机标题、各趋势图标题居中 */
+export function centerTrendTitleParagraphs(xml) {
+  PARAGRAPH_RE.lastIndex = 0;
+  return xml.replace(PARAGRAPH_RE, (paragraph) => {
+    const text = paragraphPlain(paragraph).trim();
+    if (text.includes("{engine_title}") || text.includes("{chart_title}")) {
+      return centerParagraph(paragraph);
+    }
+    return paragraph;
+  });
+}
+
+/**
+ * 图1/图2/趋势图所在单元格行高是 exact,会把图片裁切。
+ * 把这些单列表格拆成正文段落,图片不再受表格遮挡。
+ */
+export function unwrapImagePlaceholderTables(xml) {
+  return xml.replace(/<w:tbl[\s\S]*?<\/w:tbl>/g, (tbl) => {
+    if (!IMAGE_TABLE_MARKER.test(tbl)) return tbl;
+    const paras = tbl.match(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/g) || [];
+    const kept = paras.filter((paragraph) => {
+      const text = paragraphPlain(paragraph).trim();
+      return Boolean(text) || paragraph.includes("w:drawing");
+    });
+    if (!kept.length) return tbl;
+    return kept
+      .map((paragraph) => {
+        const text = paragraphPlain(paragraph).trim();
+        if (
+          /^\{%[^}]+\}$/.test(text) ||
+          text.includes("{engine_title}") ||
+          text.includes("{chart_title}")
+        ) {
+          return centerParagraph(paragraph);
+        }
+        return paragraph;
+      })
+      .join("");
+  });
+}
+
+/**
+ * 去掉自动插入位所在单元格里的模版示例图,避免和生成图叠在一起
+ */
+export function stripSampleDrawingsNearPlaceholders(xml) {
+  const marker = /\{%(?:chart_fig1|chart_fig2|trend_image|chart_overview|detector_image)\}|\{#turbine_trends\}|\{#charts\}/;
+  return xml.replace(CELL_RE, (cell) => {
+    if (!marker.test(cell)) return cell;
+    return cell.replace(PARAGRAPH_RE, (paragraph) => {
+      const text = paragraphPlain(paragraph).trim();
+      if (!text && paragraph.includes("w:drawing")) return "";
+      return paragraph;
+    });
+  });
+}
+
+function collectParagraphs(xml) {
+  const matches = [];
+  xml.replace(PARAGRAPH_RE, (paragraph, offset) => {
+    matches.push({
+      start: offset,
+      end: offset + paragraph.length,
+      plain: paragraphPlain(paragraph).trim(),
+      hasDrawing:
+        paragraph.includes("w:drawing") || paragraph.includes("v:imagedata"),
+    });
+    return paragraph;
+  });
+  return matches;
+}
+
+/**
+ * 大唐异常模版:分图循环常缺 {%image},且循环内残留示例截图;
+ * {#priorityList} 无闭合标签。只改结构,不改中文正文。
+ */
+export function patchAnomalyTemplateXml(xml) {
+  let next = mergeSplitPlaceholders(xml);
+  next = next.replace(/\{#priorityList\}/g, "{priorityList}");
+
+  const matches = collectParagraphs(next);
+  const edits = [];
+  for (let i = 0; i < matches.length; i += 1) {
+    const opened = matches[i].plain.match(ANOMALY_LOOP_OPEN_RE);
+    if (!opened) continue;
+    const closeText = `{/zn-techcn-replace-tags-${opened[1]}-${opened[2]}}`;
+    let endIdx = -1;
+    let hasImage = false;
+    for (let j = i + 1; j < matches.length; j += 1) {
+      if (matches[j].plain.includes(closeText)) {
+        endIdx = j;
+        break;
+      }
+      if (matches[j].plain.includes("{%image}")) hasImage = true;
+      if (matches[j].hasDrawing && !matches[j].plain.includes("{")) {
+        edits.push({ start: matches[j].start, end: matches[j].end, xml: "" });
+      }
+    }
+    if (endIdx < 0) continue;
+    if (!hasImage) {
+      edits.push({
+        start: matches[endIdx].start,
+        end: matches[endIdx].start,
+        xml: centerParagraph(buildParagraphXml("{%image}")),
+      });
+    }
+  }
+  edits
+    .sort((a, b) => b.start - a.start || b.end - a.end)
+    .forEach((item) => {
+      next = `${next.slice(0, item.start)}${item.xml}${next.slice(item.end)}`;
+    });
+
+  next = unwrapImagePlaceholderTables(next);
+  return mergeSplitPlaceholders(next);
+}
+
+export function patchTemplateXml(xml, markers = HEALTH_IMAGE_MARKERS) {
+  let next = mergeSplitPlaceholders(xml);
+  const alreadyPatched =
+    next.includes("{#turbine_rows}") && next.includes("{%chart_fig1}");
+  if (alreadyPatched) {
+    next = upgradeHealthTrendLoop(next);
+    next = stripSampleDrawingsNearPlaceholders(next);
+    next = unwrapImagePlaceholderTables(next);
+    next = centerTrendTitleParagraphs(next);
+    return mergeSplitPlaceholders(next);
+  }
+  markers.forEach(({ search, paragraphs, replacement }) => {
+    const paras =
+      paragraphs ||
+      (replacement
+        ? String(replacement)
+            .split(/\n+/)
+            .map((s) => s.trim())
+            .filter(Boolean)
+        : []);
+    if (!paras.length) return;
+    next = replaceParagraphContaining(next, search, paras);
+  });
+  if (
+    markers === HEALTH_IMAGE_MARKERS ||
+    next.includes("{engine_id_1}") ||
+    next.includes("{focus_engine_1}")
+  ) {
+    next = patchHealthTablesXml(next);
+  }
+  next = stripSampleDrawingsNearPlaceholders(next);
+  next = unwrapImagePlaceholderTables(next);
+  next = centerTrendTitleParagraphs(next);
+  return mergeSplitPlaceholders(next);
+}
+
+export function resolveTemplatePath(templateName) {
+  const candidates = [
+    path.join(process.cwd(), "templates", templateName),
+    path.join(__dirname, "../../../templates", templateName),
+    path.join(process.cwd(), "..", "public", "files", templateName),
+    path.join(process.cwd(), "src/public/file", templateName),
+    path.join(__dirname, "../../../src/public/file", templateName),
+  ];
+  const found = candidates.find((item) => fs.existsSync(item));
+  if (!found) {
+    throw new Error(`未找到报告模板: ${templateName}`);
+  }
+  return found;
+}
+
+function buildImageModule(imageBufferMap) {
+  const emptyPng = Buffer.from(
+    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+    "base64",
+  );
+
+  return new ImageModule({
+    centered: true,
+    getImage: (tagValue) => {
+      if (!tagValue) return emptyPng;
+      const buffer = imageBufferMap[tagValue];
+      if (!buffer || !buffer.length) {
+        console.warn("报告图片缺失,使用占位图:", tagValue);
+        return emptyPng;
+      }
+      return buffer;
+    },
+    getSize: (imgBuffer, _tagValue, tagName) => {
+      try {
+        const dimensions = sizeOf(imgBuffer);
+        const maxWidth =
+          tagName === "image"
+            ? 500
+            : tagName === "trend_image" || tagName === "detector_image"
+              ? 560
+              : 520;
+        const ratio = maxWidth / Math.max(1, dimensions.width);
+        return [maxWidth, Math.max(80, Math.round(dimensions.height * ratio))];
+      } catch (_error) {
+        return [480, 280];
+      }
+    },
+  });
+}
+
+function normalizeImageRenderData(renderData) {
+  const next = { ...renderData };
+
+  if (Array.isArray(next.turbine_trends)) {
+    next.turbine_trends = next.turbine_trends.map((item) => ({
+      ...item,
+      charts: (item.charts || []).map((chart) => ({
+        ...chart,
+        trend_image: chart.trend_image || chart.trendImageKey || "",
+      })),
+    }));
+  }
+
+  if (Array.isArray(next.turbine_sections)) {
+    next.turbine_sections = next.turbine_sections.map((section) => ({
+      ...section,
+      detector_charts: (section.detector_charts || []).map((chart) => ({
+        ...chart,
+        detector_image: chart.detector_image || chart.detectorImageKey || "",
+      })),
+    }));
+  }
+
+  return next;
+}
+
+/**
+ * 读取模板 →(可选)打补丁插入 {%image} 占位 → 渲染
+ * @param {{ templateName: string, renderData: object, imageBufferMap: object, patchMarkers?: array, skipPatch?: boolean }}
+ */
+export async function renderDocxReport({
+  templateName,
+  renderData,
+  imageBufferMap,
+  patchMarkers = HEALTH_IMAGE_MARKERS,
+  skipPatch = false,
+}) {
+  const templatePath = resolveTemplatePath(templateName);
+  console.log("[report] 使用模板:", templatePath);
+
+  const content = fs.readFileSync(templatePath, "binary");
+  const zip = new PizZip(content);
+  const documentFile = zip.file("word/document.xml");
+  if (!documentFile) {
+    throw new Error("模板缺少 word/document.xml");
+  }
+
+  let xml = mergeSplitPlaceholders(documentFile.asText());
+  const isAnomalyTemplate =
+    templateName.includes("anomaly") ||
+    xml.includes("zn-techcn-replace-tags-");
+  if (isAnomalyTemplate) {
+    xml = patchAnomalyTemplateXml(xml);
+  } else if (!skipPatch && patchMarkers?.length) {
+    xml = patchTemplateXml(xml, patchMarkers);
+  } else if (xml.includes("{engine_id_1}") || xml.includes("{focus_engine_1}")) {
+    xml = patchHealthTablesXml(xml);
+  }
+  xml = mergeSplitPlaceholders(xml);
+  zip.file("word/document.xml", xml);
+
+  const imageModule = buildImageModule(imageBufferMap || {});
+  const doc = new Docxtemplater(zip, {
+    paragraphLoop: true,
+    linebreaks: true,
+    modules: [imageModule],
+    nullGetter: () => "",
+  });
+
+  const payload = normalizeImageRenderData(renderData || {});
+  try {
+    doc.render(payload);
+  } catch (error) {
+    if (error?.properties?.errors) {
+      console.error(
+        "[report] 模板渲染错误:",
+        JSON.stringify(error.properties.errors, null, 2),
+      );
+    }
+    throw error;
+  }
+
+  return doc.getZip().generate({ type: "nodebuffer" });
+}
+
+export function registerImageBuffer(imageBufferMap, key, buffer) {
+  if (!key || !buffer?.length) return;
+  imageBufferMap[key] = buffer;
+}
+
+/**
+ * 将源模板打上图片占位符后写回 templates(便于人工打开核对)
+ */
+export function preparePatchedTemplate({
+  sourceName,
+  targetName,
+  markers = HEALTH_IMAGE_MARKERS,
+}) {
+  const sourcePath = resolveTemplatePath(sourceName);
+  const content = fs.readFileSync(sourcePath, "binary");
+  const zip = new PizZip(content);
+  const documentFile = zip.file("word/document.xml");
+  if (!documentFile) throw new Error("模板缺少 word/document.xml");
+
+  const patched = patchTemplateXml(documentFile.asText(), markers);
+  zip.file("word/document.xml", patched);
+
+  const outDir = path.join(process.cwd(), "templates");
+  fs.mkdirSync(outDir, { recursive: true });
+  const outPath = path.join(outDir, targetName || sourceName);
+  fs.writeFileSync(outPath, zip.generate({ type: "nodebuffer" }));
+  return outPath;
+}

+ 166 - 0
downLoadServer/src/server/reportService/echartsRenderer.js

@@ -0,0 +1,166 @@
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+import {
+  discardPage,
+  getPage,
+  releasePage,
+} from "../utils/chartService/browserPool.js";
+import { chartLimit } from "../utils/chartService/limiter.js";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const PAGE_RECYCLE_AFTER = 60;
+
+let echartsScriptCache = "";
+
+function getEchartsScript() {
+  if (echartsScriptCache) return echartsScriptCache;
+  const candidates = [
+    path.join(process.cwd(), "node_modules/echarts/dist/echarts.min.js"),
+    path.join(__dirname, "../../../node_modules/echarts/dist/echarts.min.js"),
+    path.join(process.cwd(), "src/public/js/echarts.min.js"),
+    path.join(__dirname, "../../../src/public/js/echarts.min.js"),
+  ];
+  const scriptPath = candidates.find((item) => fs.existsSync(item));
+  if (!scriptPath) {
+    throw new Error("未找到 echarts.min.js");
+  }
+  echartsScriptCache = fs.readFileSync(scriptPath, "utf8");
+  return echartsScriptCache;
+}
+
+function buildShellHtml() {
+  const echartsScript = getEchartsScript();
+  return `<!DOCTYPE html>
+<html>
+<head><meta charset="UTF-8" />
+<style>
+  html, body { margin: 0; padding: 0; background: #ffffff; overflow: hidden; }
+  #chart { width: 100px; height: 100px; }
+</style>
+<script>${echartsScript}</script>
+</head>
+<body>
+  <div id="chart"></div>
+  <script>window.__echartsReady = true;</script>
+</body>
+</html>`;
+}
+
+async function ensureEchartsPage(page, width, height, timeoutMs) {
+  const ready = await page
+    .evaluate(() => window.__echartsReady === true)
+    .catch(() => false);
+  const viewport = page.viewport();
+  const needViewport =
+    !viewport || viewport.width !== width || viewport.height !== height;
+  if (needViewport) {
+    await page.setViewport({
+      width,
+      height,
+      deviceScaleFactor: 1,
+    });
+  }
+  if (ready) return;
+  await page.setContent(buildShellHtml(), {
+    waitUntil: "domcontentloaded",
+    timeout: timeoutMs,
+  });
+  await page.waitForFunction(() => window.__echartsReady === true, {
+    timeout: Math.min(timeoutMs, 20000),
+  });
+}
+
+async function paintChart(page, option, width, height) {
+  await page.evaluate(
+    async (chartOption, chartWidth, chartHeight) => {
+      const el = document.getElementById("chart");
+      if (!el) throw new Error("chart element not found");
+      el.style.width = `${chartWidth}px`;
+      el.style.height = `${chartHeight}px`;
+      if (window.__chart) {
+        try {
+          window.__chart.dispose();
+        } catch (_error) {
+          // ignore
+        }
+        window.__chart = null;
+      }
+      window.__chart = echarts.init(el, null, {
+        renderer: "canvas",
+        width: chartWidth,
+        height: chartHeight,
+      });
+      window.__chart.setOption(chartOption, {
+        notMerge: true,
+        lazyUpdate: false,
+      });
+      await new Promise((resolve) => {
+        const chart = window.__chart;
+        let settled = false;
+        const done = () => {
+          if (settled) return;
+          settled = true;
+          try {
+            chart.off("finished", done);
+          } catch (_error) {
+            // ignore
+          }
+          resolve();
+        };
+        chart.on("finished", done);
+        requestAnimationFrame(() => requestAnimationFrame(done));
+        setTimeout(done, 1200);
+      });
+    },
+    option,
+    width,
+    height,
+  );
+}
+
+export async function renderEchartsOption(option, options = {}) {
+  const width = options.width || 820;
+  const height = options.height || 460;
+  const timeoutMs = Number.parseInt(
+    process.env.CHART_RENDER_TIMEOUT_MS || "120000",
+    10,
+  );
+
+  return chartLimit(async () => {
+    let attempt = 0;
+    const retry = 1;
+
+    while (attempt <= retry) {
+      const page = await getPage();
+      let shouldDiscard = false;
+      try {
+        await ensureEchartsPage(page, width, height, timeoutMs);
+        await paintChart(page, option, width, height);
+        const buffer = await page.screenshot({
+          type: "png",
+          clip: { x: 0, y: 0, width, height },
+          captureBeyondViewport: true,
+        });
+        page.__echartsRenderCount = (page.__echartsRenderCount || 0) + 1;
+        if (page.__echartsRenderCount >= PAGE_RECYCLE_AFTER) {
+          shouldDiscard = true;
+        }
+        return buffer;
+      } catch (error) {
+        shouldDiscard = true;
+        attempt += 1;
+        if (attempt > retry) throw error;
+        await new Promise((resolve) => setTimeout(resolve, 400));
+      } finally {
+        if (shouldDiscard) {
+          await discardPage(page);
+        } else if (page && !page.isClosed()) {
+          await releasePage(page);
+        }
+      }
+    }
+    throw new Error("renderEchartsOption failed");
+  });
+}

+ 481 - 0
downLoadServer/src/server/reportService/healthChartBuilder.js

@@ -0,0 +1,481 @@
+const LEVEL_LABELS = {
+  excellent: "优",
+  good: "良",
+  fair: "中",
+  poor: "差",
+  优: "优",
+  良: "良",
+  中: "中",
+  差: "差",
+};
+
+const TREND_SERIES = {
+  overall: [{ key: "overallScore", name: "综合健康", color: "#22d3ee" }],
+  structural: [
+    { key: "rotorScore", name: "叶轮", color: "#22d3ee" },
+    { key: "towerScore", name: "塔筒", color: "#1E7CF8" },
+  ],
+  system: [
+    { key: "yawSystemScore", name: "偏航系统", color: "#22d3ee" },
+    { key: "pitchSystemScore", name: "变桨系统", color: "#1E7CF8" },
+    { key: "hydraulicSystemScore", name: "液压系统", color: "#14F39A" },
+    { key: "controlSystemScore", name: "主控系统", color: "#F7C43B" },
+  ],
+  component: [
+    { key: "generatorScore", name: "发电机", color: "#22d3ee" },
+    { key: "gearboxScore", name: "齿轮箱", color: "#1E7CF8" },
+    { key: "mainShaftScore", name: "主轴", color: "#14F39A" },
+    { key: "converterScore", name: "变流器", color: "#C58CFF" },
+  ],
+};
+
+const TURBINE_TREND_CHARTS = [
+  { key: "overall", title: "综合健康评分趋势图", showLegend: false },
+  { key: "structural", title: "结构健康趋势图", showLegend: true },
+  { key: "system", title: "系统健康趋势图", showLegend: true },
+  { key: "component", title: "部件健康趋势图", showLegend: true },
+];
+
+const LEVEL_PIE_COLORS = {
+  优: "#14F39A",
+  良: "#1E7CF8",
+  中: "#F7C43B",
+  差: "#FF4A4A",
+};
+
+const LEVEL_SUMMARY_COLORS = {
+  优: "#00FF88",
+  良: "#0077FF",
+  中: "#FAAD14",
+  差: "#FF3131",
+};
+
+const SUBSYSTEM_BAR_COLORS = [
+  ["#22e8ff", "#1f7fff"],
+  ["#c58cff", "#7b3dff"],
+  ["#1df3a0", "#14a67a"],
+];
+
+const REPORT_CHART_THEME = {
+  axisColor: "#64748b",
+  splitColor: "#e2e8f0",
+  legendColor: "#475569",
+  pieBorder: "#ffffff",
+};
+
+function scoreToLevel(score) {
+  const value = Number(score);
+  if (!Number.isFinite(value) || value <= 0) return null;
+  if (value >= 90) return "优";
+  if (value >= 70) return "良";
+  if (value >= 50) return "中";
+  return "差";
+}
+
+function normalizeLevel(level, score) {
+  if (level && LEVEL_LABELS[level]) return LEVEL_LABELS[level];
+  return scoreToLevel(score) || "";
+}
+
+function isNoScore(score) {
+  if (score == null || score === "") return true;
+  const value = Number(score);
+  return Number.isNaN(value) || value === -1;
+}
+
+function formatScore(score) {
+  if (isNoScore(score)) return "/未评估";
+  return String(Number(score));
+}
+
+function formatDateLabel(value) {
+  if (value == null || value === "") return "";
+  const text = String(value).trim();
+  const match = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
+  if (match) {
+    return `${match[2].padStart(2, "0")}/${match[3].padStart(2, "0")}`;
+  }
+  const time = new Date(text).getTime();
+  if (!Number.isNaN(time)) {
+    const date = new Date(time);
+    return `${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")}`;
+  }
+  return text;
+}
+
+function clampScore(value) {
+  if (isNoScore(value)) return null;
+  const num = Number(value);
+  return Math.max(0, Math.min(100, num));
+}
+
+function hexToRgba(hex, alpha) {
+  const normalized = String(hex || "").replace("#", "");
+  if (normalized.length !== 6) return `rgba(34, 211, 238, ${alpha})`;
+  const value = Number.parseInt(normalized, 16);
+  const r = (value >> 16) & 255;
+  const g = (value >> 8) & 255;
+  const b = value & 255;
+  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+}
+
+function linearGradient(topColor, bottomColor) {
+  return {
+    type: "linear",
+    x: 0,
+    y: 0,
+    x2: 0,
+    y2: 1,
+    colorStops: [
+      { offset: 0, color: topColor },
+      { offset: 1, color: bottomColor },
+    ],
+  };
+}
+
+/** 当前评分为 -1 / 空的评价项不进折线,与前端 getExcludedTrendKeys 一致 */
+function getExcludedTrendKeys(turbine, chartKey) {
+  if (!turbine || chartKey === "overall") return [];
+  return (TREND_SERIES[chartKey] || [])
+    .filter((cfg) => isNoScore(turbine[cfg.key]))
+    .map((cfg) => cfg.key);
+}
+
+function mapTrendSeries(list, chartKey, excludedKeys = []) {
+  const configs = (TREND_SERIES[chartKey] || []).filter(
+    (cfg) => !excludedKeys.includes(cfg.key),
+  );
+  const sorted = [...list].sort((a, b) => {
+    const ta = new Date(a.birthday || a.sourceDatetime || 0).getTime();
+    const tb = new Date(b.birthday || b.sourceDatetime || 0).getTime();
+    return ta - tb;
+  });
+  const labels = sorted.map((item) =>
+    formatDateLabel(item.birthday || item.sourceDatetime),
+  );
+  const series = configs
+    .map((cfg) => ({
+      name: cfg.name,
+      color: cfg.color,
+      data: sorted.map((item) => clampScore(item[cfg.key])),
+    }))
+    .filter((item) => item.data.some((val) => val != null));
+  return { labels, series };
+}
+
+/** 对齐前端 HealthUnitDetailDrawer.buildLineOption */
+function buildLineOption(trendData, showLegend = false) {
+  const pointCount = trendData.labels.length;
+  const validPointCount = trendData.labels.reduce((count, _, index) => {
+    const hasValue = trendData.series.some(
+      (series) => series.data[index] != null,
+    );
+    return hasValue ? count + 1 : count;
+  }, 0);
+  const singlePointMode = validPointCount <= 1;
+  const showPointLabel = !showLegend && (pointCount <= 12 || singlePointMode);
+  const theme = REPORT_CHART_THEME;
+
+  return {
+    backgroundColor: "#ffffff",
+    animation: false,
+    tooltip: { trigger: "axis" },
+    legend: showLegend
+      ? {
+          top: 0,
+          left: "center",
+          itemWidth: 10,
+          itemHeight: 10,
+          textStyle: { color: theme.legendColor, fontSize: 10 },
+        }
+      : { show: false },
+    grid: {
+      top: showLegend ? "20%" : showPointLabel ? "14%" : "8%",
+      left: "3%",
+      right: "4%",
+      bottom: pointCount > 8 ? "14%" : "8%",
+      containLabel: true,
+    },
+    xAxis: {
+      type: "category",
+      boundaryGap: false,
+      data: trendData.labels,
+      axisLine: { lineStyle: { color: theme.splitColor } },
+      axisTick: { show: false },
+      axisLabel: {
+        color: theme.axisColor,
+        fontSize: 10,
+        hideOverlap: true,
+        margin: 8,
+        interval: pointCount > 15 ? Math.floor(pointCount / 8) : 0,
+      },
+    },
+    yAxis: {
+      type: "value",
+      min: 0,
+      max: 100,
+      splitNumber: 4,
+      splitLine: { lineStyle: { color: theme.splitColor } },
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: { color: theme.axisColor, fontSize: 10 },
+    },
+    series: trendData.series.map((item, index) => {
+      const seriesValidCount = item.data.filter((val) => val != null).length;
+      const showSymbol =
+        pointCount <= 15 || seriesValidCount <= 1 || singlePointMode;
+
+      return {
+        name: item.name,
+        type: "line",
+        smooth: !singlePointMode,
+        symbol: "circle",
+        symbolSize: singlePointMode ? 8 : 6,
+        showSymbol,
+        showAllSymbol: seriesValidCount <= 1 || singlePointMode,
+        connectNulls: false,
+        lineStyle: { width: 2, color: item.color },
+        itemStyle: {
+          color: item.color,
+          borderColor: "#ffffff",
+          borderWidth: 1,
+        },
+        label:
+          showPointLabel && index === 0
+            ? {
+                show: true,
+                position: "top",
+                distance: 6,
+                color: theme.legendColor,
+                fontSize: 10,
+              }
+            : { show: false },
+        areaStyle: showLegend
+          ? undefined
+          : {
+              color: linearGradient(
+                hexToRgba(item.color, 0.33),
+                hexToRgba(item.color, 0.03),
+              ),
+            },
+        data: item.data,
+      };
+    }),
+  };
+}
+
+/** 对齐前端 HealthScorePanel:环形图 + 中心综合评分 + 底部四档台数 */
+export function buildFarmLevelPieOption(windVo = {}) {
+  const levels = [
+    {
+      name: "优",
+      value: Number(windVo.excellentCount) || 0,
+      color: LEVEL_PIE_COLORS.优,
+      summaryColor: LEVEL_SUMMARY_COLORS.优,
+    },
+    {
+      name: "良",
+      value: Number(windVo.goodCount) || 0,
+      color: LEVEL_PIE_COLORS.良,
+      summaryColor: LEVEL_SUMMARY_COLORS.良,
+    },
+    {
+      name: "中",
+      value: Number(windVo.fairCount) || 0,
+      color: LEVEL_PIE_COLORS.中,
+      summaryColor: LEVEL_SUMMARY_COLORS.中,
+    },
+    {
+      name: "差",
+      value: Number(windVo.poorCount) || 0,
+      color: LEVEL_PIE_COLORS.差,
+      summaryColor: LEVEL_SUMMARY_COLORS.差,
+    },
+  ];
+  const pieData = levels
+    .filter((item) => item.value > 0)
+    .map((item) => ({
+      name: item.name,
+      value: item.value,
+      itemStyle: { color: item.color },
+    }));
+  const displayScore = isNoScore(windVo.overallScore)
+    ? "/"
+    : String(windVo.overallScore);
+  const scoreColor =
+    LEVEL_SUMMARY_COLORS[normalizeLevel(null, windVo.overallScore)] ||
+    "#64748b";
+
+  return {
+    backgroundColor: "#ffffff",
+    animation: false,
+    animationDuration: 0,
+    tooltip: { show: false },
+    legend: { show: false },
+    graphic: levels.map((item, index) => ({
+      type: "group",
+      left: `${12.5 + index * 25}%`,
+      bottom: 18,
+      children: [
+        {
+          type: "text",
+          style: {
+            text: String(item.value),
+            fill: item.summaryColor,
+            font: "bold 18px sans-serif",
+            textAlign: "center",
+            align: "center",
+          },
+        },
+        {
+          type: "text",
+          top: 22,
+          style: {
+            text: `${item.name} (台)`,
+            fill: REPORT_CHART_THEME.axisColor,
+            font: "10px sans-serif",
+            textAlign: "center",
+            align: "center",
+          },
+        },
+      ],
+    })),
+    series: [
+      {
+        type: "pie",
+        silent: true,
+        stillShowZeroSum: false,
+        radius: [92, 128],
+        center: ["50%", "42%"],
+        startAngle: 90,
+        avoidLabelOverlap: false,
+        itemStyle: {
+          borderColor: "#ffffff",
+          borderWidth: 4,
+        },
+        label: {
+          show: true,
+          position: "center",
+          formatter: `{score|${displayScore}}\n{label|综合评分}`,
+          rich: {
+            score: {
+              fontSize: 36,
+              fontWeight: 700,
+              color: scoreColor,
+              padding: [8, 0, 2, 0],
+            },
+            label: {
+              fontSize: 12,
+              color: REPORT_CHART_THEME.axisColor,
+            },
+          },
+        },
+        labelLine: { show: false },
+        data: pieData.length
+          ? pieData
+          : [
+              {
+                name: "暂无数据",
+                value: 1,
+                itemStyle: { color: "#e2e8f0" },
+              },
+            ],
+      },
+    ],
+  };
+}
+
+/** 对齐前端 HealthSubsystemPanel:结构 / 系统 / 部件 三根渐变柱 */
+export function buildFarmCategoryBarOption(windVo = {}) {
+  const theme = REPORT_CHART_THEME;
+  const items = [
+    { name: "结构健康", value: windVo.structureScore, colors: SUBSYSTEM_BAR_COLORS[0] },
+    { name: "系统健康", value: windVo.systemScore, colors: SUBSYSTEM_BAR_COLORS[1] },
+    { name: "部件健康", value: windVo.componentScore, colors: SUBSYSTEM_BAR_COLORS[2] },
+  ];
+
+  return {
+    backgroundColor: "#ffffff",
+    animation: false,
+    tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
+    grid: {
+      top: "10%",
+      left: "3%",
+      right: "4%",
+      bottom: "6%",
+      containLabel: true,
+    },
+    xAxis: {
+      type: "category",
+      data: items.map((item) => item.name),
+      axisLine: { lineStyle: { color: theme.splitColor } },
+      axisTick: { show: false },
+      axisLabel: {
+        color: theme.axisColor,
+        fontSize: 13,
+        fontWeight: 600,
+      },
+    },
+    yAxis: {
+      type: "value",
+      max: 100,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      splitLine: {
+        lineStyle: { color: theme.splitColor, type: "dashed" },
+      },
+      axisLabel: { color: theme.axisColor, fontSize: 12 },
+    },
+    series: [
+      {
+        type: "bar",
+        barWidth: "42%",
+        barCategoryGap: "42%",
+        showBackground: true,
+        backgroundStyle: {
+          color: "rgba(119, 143, 199, 0.12)",
+          borderRadius: [12, 12, 0, 0],
+        },
+        itemStyle: {
+          borderRadius: [8, 8, 0, 0],
+        },
+        data: items.map((item) => ({
+          value: isNoScore(item.value) ? 0 : Number(item.value),
+          itemStyle: {
+            color: linearGradient(item.colors[0], item.colors[1]),
+          },
+          label: {
+            show: true,
+            position: "top",
+            color: "#334155",
+            formatter: isNoScore(item.value) ? "/" : String(item.value),
+          },
+        })),
+      },
+    ],
+  };
+}
+
+/**
+ * 一台风机拆成 4 张独立趋势图,与前端抽屉一致:
+ * 综合(面积)、结构、系统、部件
+ */
+export function buildTurbineTrendChartOptions(trendList = [], turbine = {}) {
+  const list = Array.isArray(trendList) ? trendList : [];
+  return TURBINE_TREND_CHARTS.map((meta) => {
+    const excludedKeys = getExcludedTrendKeys(turbine, meta.key);
+    const trendData = mapTrendSeries(list, meta.key, excludedKeys);
+    return {
+      key: meta.key,
+      title: meta.title,
+      option: buildLineOption(trendData, meta.showLegend),
+    };
+  });
+}
+
+export {
+  formatScore,
+  normalizeLevel,
+  isNoScore,
+  LEVEL_LABELS,
+};

+ 190 - 0
downLoadServer/src/server/reportService/healthReportMapper.js

@@ -0,0 +1,190 @@
+import {
+  formatScore,
+  isNoScore,
+  normalizeLevel,
+} from "./healthChartBuilder.js";
+
+function formatNow() {
+  const now = new Date();
+  const pad = (n) => String(n).padStart(2, "0");
+  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
+}
+
+export function displayTurbineName(item = {}) {
+  return item.engineName || item.engineId || "";
+}
+
+function pickLowestMetric(item) {
+  const metrics = [
+    { label: "系统健康", value: item.systemScore },
+    { label: "部件健康", value: item.componentScore },
+    { label: "结构健康", value: item.structureScore },
+    { label: "偏航系统", value: item.yawSystemScore },
+    { label: "变桨系统", value: item.pitchSystemScore },
+    { label: "主控系统", value: item.controlSystemScore },
+    { label: "液压系统", value: item.hydraulicSystemScore },
+    { label: "发电机", value: item.generatorScore },
+    { label: "变流器", value: item.converterScore },
+    { label: "齿轮箱", value: item.gearboxScore },
+    { label: "主轴", value: item.mainShaftScore },
+    { label: "叶轮", value: item.rotorScore },
+    { label: "塔筒", value: item.towerScore },
+  ];
+  const valid = metrics.filter((m) => !isNoScore(m.value));
+  if (!valid.length) return "暂无有效评分项";
+  valid.sort((a, b) => Number(a.value) - Number(b.value));
+  return valid[0].label;
+}
+
+function sortTurbines(list = []) {
+  return [...list].sort((a, b) =>
+    String(displayTurbineName(a)).localeCompare(String(displayTurbineName(b)), "zh-CN", {
+      numeric: true,
+    }),
+  );
+}
+
+function mapTurbineRow(item, index) {
+  return {
+    index: index + 1,
+    engine_id: item.engineId || "",
+    engine_name: displayTurbineName(item),
+    machine_type: item.machineTypeCode || "",
+    overall: formatScore(item.overallScore),
+    level: normalizeLevel(item.overallLevel, item.overallScore),
+    system: formatScore(item.systemScore),
+    component: formatScore(item.componentScore),
+    structure: formatScore(item.structureScore),
+    yaw: formatScore(item.yawSystemScore),
+    pitch: formatScore(item.pitchSystemScore),
+    mcs: formatScore(item.controlSystemScore),
+    hpu: formatScore(item.hydraulicSystemScore),
+    generator: formatScore(item.generatorScore),
+    converter: formatScore(item.converterScore),
+    gearbox: formatScore(item.gearboxScore),
+    shaft: formatScore(item.mainShaftScore),
+    rotor: formatScore(item.rotorScore),
+    tower: formatScore(item.towerScore),
+    low_item: pickLowestMetric(item),
+  };
+}
+
+/** 中/差全部列出;不足 5 台时用综合分最低的机组补齐 */
+function pickFocusTurbines(list = []) {
+  const named = sortTurbines(list);
+  const byScore = named
+    .filter((item) => !isNoScore(item.overallScore))
+    .sort((a, b) => Number(a.overallScore) - Number(b.overallScore));
+  const midPoor = byScore.filter((item) => {
+    const level = normalizeLevel(item.overallLevel, item.overallScore);
+    return level === "中" || level === "差";
+  });
+  const selected = [...midPoor];
+  byScore.forEach((item) => {
+    if (selected.length >= 5) return;
+    if (!selected.includes(item)) selected.push(item);
+  });
+  return selected;
+}
+
+function fillLegacySlots(rows, prefixMap, max) {
+  const slots = {};
+  for (let i = 0; i < max; i += 1) {
+    const idx = i + 1;
+    const row = rows[i];
+    Object.entries(prefixMap).forEach(([slotKey, rowKey]) => {
+      slots[`${slotKey}_${idx}`] = row ? row[rowKey] || "" : "";
+    });
+  }
+  return slots;
+}
+
+export function mapHealthReportContext(overviewData = {}, options = {}) {
+  const windVo = overviewData.healthscoresWindVO || {};
+  const list = overviewData.healthOverviewListVOList || [];
+  const sorted = sortTurbines(list);
+  const turbineRows = sorted.map((item, index) => mapTurbineRow(item, index));
+  const focusTurbines = pickFocusTurbines(sorted);
+  const focusRows = focusTurbines.map((item, index) => mapTurbineRow(item, index));
+  const validCount = sorted.filter((item) => !isNoScore(item.overallScore)).length;
+  const machineTypes = [
+    ...new Set(sorted.map((item) => item.machineTypeCode).filter(Boolean)),
+  ];
+  const focusNames = focusRows.map((row) => row.engine_name).filter(Boolean);
+
+  const farmName =
+    options.fieldName || windVo.fieldName || windVo.fieldCode || "";
+  const sourceDatetime = windVo.sourceDatetime || options.datatime || "";
+  const createTime = windVo.createTime || formatNow();
+
+  return {
+    风场名称: farmName,
+    farm_name: farmName,
+    field_id: windVo.fieldCode || windVo.fieldId || options.fieldCode || "",
+    create_time: createTime,
+    source_datetime: sourceDatetime,
+    turbine_count: String(windVo.totalCount || sorted.length || 0),
+    valid_turbine_count: String(validCount),
+    turbine_types: machineTypes.join("、"),
+    overall_score: formatScore(windVo.overallScore),
+    system_score: formatScore(windVo.systemScore),
+    component_score: formatScore(windVo.componentScore),
+    "structure_score/未评估": formatScore(windVo.structureScore),
+    structure_score: formatScore(windVo.structureScore),
+    excellent_count: String(windVo.excellentCount ?? 0),
+    good_count: String(windVo.goodCount ?? 0),
+    fair_count: String(windVo.fairCount ?? 0),
+    poor_count: String(windVo.poorCount ?? 0),
+    overall_level_text: normalizeLevel(null, windVo.overallScore) || "无",
+    main_level: normalizeLevel(null, windVo.overallScore) || "无",
+    level_distribution_text: `优${windVo.excellentCount ?? 0}台、良${windVo.goodCount ?? 0}台、中${windVo.fairCount ?? 0}台、差${windVo.poorCount ?? 0}台`,
+    overall_summary_text: `${farmName}共${windVo.totalCount || sorted.length}台风机,有效评估${validCount}台,综合健康评分${formatScore(windVo.overallScore)}。`,
+    focus_engine_list: focusNames.join("、"),
+    turbine_rows: turbineRows,
+    focus_rows: focusRows,
+    ...fillLegacySlots(
+      turbineRows,
+      {
+        engine_id: "engine_name",
+        machine_type: "machine_type",
+        overall: "overall",
+        level: "level",
+        system: "system",
+        component: "component",
+        structure: "structure",
+        yaw: "yaw",
+        pitch: "pitch",
+        mcs: "mcs",
+        hpu: "hpu",
+        generator: "generator",
+        converter: "converter",
+        gearbox: "gearbox",
+        shaft: "shaft",
+        rotor: "rotor",
+        tower: "tower",
+      },
+      6,
+    ),
+    ...fillLegacySlots(
+      focusRows,
+      {
+        focus_engine: "engine_name",
+        focus_type: "machine_type",
+        focus_overall: "overall",
+        focus_level: "level",
+        focus_system: "system",
+        focus_component: "component",
+        focus_structure: "structure",
+        focus_low_item: "low_item",
+      },
+      2,
+    ),
+    turbine_trends: sorted.map((item) => ({
+      engine_id: item.engineId || "",
+      engine_name: displayTurbineName(item),
+      engine_title: `${displayTurbineName(item) || "风机"} 风机健康趋势图`,
+    })),
+  };
+}
+
+export { sortTurbines };

+ 149 - 0
downLoadServer/src/server/reportService/healthReportService.js

@@ -0,0 +1,149 @@
+import pLimit from "p-limit";
+import {
+  fetchHealthOverview,
+  fetchLastDaysTrend,
+} from "./analyseApiClient.js";
+import {
+  buildFarmCategoryBarOption,
+  buildFarmLevelPieOption,
+  buildTurbineTrendChartOptions,
+} from "./healthChartBuilder.js";
+import { mapHealthReportContext, sortTurbines } from "./healthReportMapper.js";
+import {
+  registerImageBuffer,
+  renderDocxReport,
+} from "./docxReportBuilder.js";
+import { renderEchartsOption } from "./echartsRenderer.js";
+
+const HEALTH_TEMPLATE = "health-report-template.docx";
+const trendLimit = pLimit(
+  Number.parseInt(process.env.REPORT_TREND_CONCURRENCY || "2", 10),
+);
+
+function buildAuthHeaders(req) {
+  const headers = {};
+  const token = req.headers.token || req.headers.Token;
+  if (token) headers.token = token;
+  const showIp = req.headers.showip || req.headers.showIp;
+  if (showIp) headers.showIp = showIp;
+  return headers;
+}
+
+async function renderNamedChart(imageBufferMap, key, option, size) {
+  const buffer = await renderEchartsOption(option, size);
+  registerImageBuffer(imageBufferMap, key, buffer);
+  return key;
+}
+
+export async function generateHealthReport(req) {
+  const {
+    fieldCode,
+    datatime,
+    fieldName,
+    trendDays = 30,
+    overviewData: overviewFromClient,
+  } = req.body || {};
+  if (!fieldCode) {
+    throw new Error("缺少 fieldCode");
+  }
+
+  const authHeaders = buildAuthHeaders(req);
+  let overviewData = overviewFromClient;
+  if (
+    !overviewData ||
+    (!overviewData.healthscoresWindVO &&
+      !overviewData.healthOverviewListVOList)
+  ) {
+    overviewData = await fetchHealthOverview(
+      { fieldCode, datatime },
+      authHeaders,
+    );
+  }
+  const renderData = mapHealthReportContext(overviewData, {
+    fieldCode,
+    datatime,
+    fieldName,
+  });
+  const turbines = sortTurbines(overviewData.healthOverviewListVOList || []);
+  const windVo = overviewData.healthscoresWindVO || {};
+  const imageBufferMap = {};
+  const dateTime = datatime || windVo.sourceDatetime || "";
+
+  const fig1Key = await renderNamedChart(
+    imageBufferMap,
+    "health_fig1",
+    buildFarmLevelPieOption(windVo),
+    { width: 760, height: 420 },
+  );
+  const fig2Key = await renderNamedChart(
+    imageBufferMap,
+    "health_fig2",
+    buildFarmCategoryBarOption(windVo),
+    { width: 760, height: 360 },
+  );
+  renderData.chart_fig1 = fig1Key;
+  renderData.chart_fig2 = fig2Key;
+
+  const trendItems = await Promise.all(
+    renderData.turbine_trends.map((item, index) =>
+      trendLimit(async () => {
+        const turbine = turbines[index];
+        if (!turbine) return { ...item, charts: [] };
+        const fieldId = turbine.fieldId || windVo.fieldId || fieldCode;
+        const engineId = turbine.engineId || turbine.engineName;
+        const engineName = item.engine_name || engineId;
+        let trendList = [];
+        try {
+          trendList = await fetchLastDaysTrend(
+            {
+              day: trendDays,
+              engineId,
+              fieldId,
+              dateTime,
+            },
+            authHeaders,
+          );
+        } catch (error) {
+          console.warn("趋势数据获取失败:", engineName, error.message);
+        }
+        const safeId = String(engineId || index).replace(/[^\w-]/g, "_");
+        const chartDefs = buildTurbineTrendChartOptions(trendList, turbine);
+        const charts = [];
+        for (const chart of chartDefs) {
+          const imageKey = `health_trend_${safeId}_${chart.key}`;
+          await renderNamedChart(imageBufferMap, imageKey, chart.option, {
+            width: 760,
+            height: chart.key === "overall" ? 260 : 300,
+          });
+          charts.push({
+            chart_title: chart.title,
+            trend_image: imageKey,
+          });
+        }
+        return {
+          ...item,
+          charts,
+        };
+      }),
+    ),
+  );
+  renderData.turbine_trends = trendItems.filter(Boolean);
+  console.log(
+    `[health-report] 图表完成: fig1/fig2 + ${renderData.turbine_trends.length} 台 × 4 张趋势图,开始渲染 Word`,
+  );
+
+  const buffer = await renderDocxReport({
+    templateName: HEALTH_TEMPLATE,
+    renderData,
+    imageBufferMap,
+    // templates/health-report-template.docx 已预打补丁;若仍含「自动插入」文案则再补丁一次
+  });
+
+  const farmName =
+    fieldName || windVo.fieldName || windVo.fieldCode || fieldCode;
+  const datePart = datatime || windVo.sourceDatetime || "报告";
+  const fileName = `${farmName}_健康评估报告_${datePart}.docx`;
+  console.log("[health-report] 完成:", fileName, "bytes=", buffer.length);
+
+  return { buffer, fileName };
+}

+ 55 - 0
downLoadServer/src/server/reportService/reportTaskStore.js

@@ -0,0 +1,55 @@
+const TASK_TTL_MS = 30 * 60 * 1000;
+const tasks = new Map();
+
+function purgeExpired() {
+  const now = Date.now();
+  [...tasks.entries()].forEach(([id, task]) => {
+    const ts = task.finishedAt || task.createdAt || 0;
+    if (now - ts > TASK_TTL_MS) {
+      tasks.delete(id);
+    }
+  });
+}
+
+export function createReportTask() {
+  purgeExpired();
+  const taskId = `rpt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+  tasks.set(taskId, {
+    status: "running",
+    createdAt: Date.now(),
+    buffer: null,
+    fileName: "",
+    message: "",
+  });
+  return taskId;
+}
+
+export function finishReportTask(taskId, { buffer, fileName }) {
+  const task = tasks.get(taskId);
+  if (!task) return;
+  task.status = "done";
+  task.buffer = buffer;
+  task.fileName = fileName;
+  task.finishedAt = Date.now();
+}
+
+export function failReportTask(taskId, message) {
+  const task = tasks.get(taskId);
+  if (!task) return;
+  task.status = "error";
+  task.message = message || "报告生成失败";
+  task.finishedAt = Date.now();
+}
+
+export function getReportTask(taskId) {
+  purgeExpired();
+  return tasks.get(taskId) || null;
+}
+
+export function consumeReportTask(taskId) {
+  const task = getReportTask(taskId);
+  if (task?.status === "done") {
+    tasks.delete(taskId);
+  }
+  return task;
+}

+ 14 - 0
downLoadServer/src/server/routes/reportRoutes.js

@@ -0,0 +1,14 @@
+import express from "express";
+import {
+  createAnomalyReport,
+  createHealthReport,
+  getReportTaskStatus,
+} from "../controllers/reportController.js";
+
+const router = express.Router();
+
+router.post("/health", createHealthReport);
+router.post("/anomaly", createAnomalyReport);
+router.get("/tasks/:taskId", getReportTaskStatus);
+
+export default router;

+ 10 - 1
downLoadServer/src/server/server.js

@@ -12,6 +12,7 @@ import { errorHandler } from "./middleware/errorHandler.js";
 
 import exampleRoutes from "./routes/exampleRoutes.js";
 import chartRoutes from "./routes/chartRoutes.js";
+import reportRoutes from "./routes/reportRoutes.js";
 
 import {
   initChartService,
@@ -46,6 +47,10 @@ app.use("/js", express.static(path.join(process.cwd(), "src/public/js")));
  */
 app.use("/examples", exampleRoutes);
 app.use("/chartServer/charts", chartRoutes);
+app.use("/chartServer/reports", reportRoutes);
+// 兼容:前端代理未剥掉 /downLoadChart 前缀时(直连 3001 或 pathRewrite 未生效)
+app.use("/downLoadChart/chartServer/charts", chartRoutes);
+app.use("/downLoadChart/chartServer/reports", reportRoutes);
 
 /**
  * =========================
@@ -63,7 +68,11 @@ export const startServer = async () => {
   try {
     await initChartService(); // ✅统一入口
 
-    app.listen(serverConfig.port, serverConfig.host, () => {
+    app.listen(serverConfig.port, serverConfig.host, function onListen() {
+      const httpServer = this;
+      httpServer.timeout = 0;
+      httpServer.keepAliveTimeout = 65000;
+      httpServer.headersTimeout = 66000;
       console.log(
         `🚀 Server running at http://${serverConfig.host}:${serverConfig.port}`,
       );

BIN
downLoadServer/templates/_smoke_anomaly_report.docx


BIN
downLoadServer/templates/_smoke_health_report.docx


BIN
downLoadServer/templates/anomaly-report-template.docx


BIN
downLoadServer/templates/health-report-template.docx