2
0

3 Commits 0ac8d537b7 ... a10cbda78f

Autor SHA1 Mensagem Data
  Jerry Liang a10cbda78f fix:修复MQTT报文列表行key重复导致的Duplicate keys告警 há 1 semana atrás
  Jerry Liang 0efddf7b65 feat:暂存调度配置功能的设计稿,需要暂缓实现 há 1 semana atrás
  Jerry Liang 4e52dae82b feat:故障录波增加MPPT和aHub设备支持 há 2 semanas atrás
30 ficheiros alterados com 757 adições e 39 exclusões
  1. 21 0
      docs/despatch-config/README.md
  2. 86 0
      docs/despatch-config/current-state.md
  3. 62 0
      docs/despatch-config/protocol-observations.md
  4. 164 0
      docs/despatch-config/requirements.md
  5. 1 0
      docs/fault-record/protocol-specification.md
  6. 1 1
      docs/fault-record/requirements.md
  7. 28 4
      docs/fault-record/technical-design.md
  8. 1 1
      service/domain/batchDownloadDomain.go
  9. 3 0
      service/domain/esMqttDomain.go
  10. 37 2
      service/domain/fwhProDomain.go
  11. 8 2
      service/dto/faultRecordDTO.go
  12. 34 0
      service/function/FaultRecordProtocol/dict.go
  13. 11 2
      service/function/FaultRecordProtocol/protocol.go
  14. 6 1
      service/function/FaultRecordProtocol/protocol_test.go
  15. 4 1
      service/repositories/api/fwh_es_mqtt/fwh_es_mqtt.go
  16. 38 0
      service/repositories/api/fwh_pro/fwh_deviceInfo.go
  17. 1 1
      service/repositories/api/fwh_pro/fwh_faultRecord.go
  18. 38 0
      service/repositories/api/fwh_test/fwh_deviceInfo.go
  19. 1 1
      service/repositories/api/fwh_test/fwh_faultRecord.go
  20. 84 0
      service/services/faultRecordDevices_test.go
  21. 65 5
      service/services/faultRecordServices.go
  22. 12 3
      src/service/faultRecord/interface.ts
  23. 1 0
      src/service/mqttDataManage/interface.ts
  24. 19 8
      src/views/fault-record/common/DeviceSelector.vue
  25. 2 2
      src/views/fault-record/common/OperationHistory.vue
  26. 2 2
      src/views/fault-record/common/OperationProgress.vue
  27. 21 0
      src/views/fault-record/common/constants.ts
  28. 2 1
      src/views/fault-record/function/index.vue
  29. 2 1
      src/views/fault-record/param-write/index.vue
  30. 2 1
      src/views/mqtt-data-manage/list/index.vue

+ 21 - 0
docs/despatch-config/README.md

@@ -0,0 +1,21 @@
+# 调度配置文档导航
+
+本目录整理“调度配置”模块重构的需求与设计。该模块通过 FWH 云平台接口管理 TOU 调度模版,覆盖生产服(只读、导出、对比)和测试服(完整管理),目标是替代外部“配置编辑器”。当前状态:需求梳理初稿,尚未进入设计和编码。
+
+建议阅读顺序:current-state → protocol-observations → requirements;technical-design 和 ui-design 待编辑方式讨论后补充。
+
+## 文档职责
+
+| 文档 | 内容 | 何时读取 |
+| --- | --- | --- |
+| [current-state.md](current-state.md) | 重构前的代码入口、云端接口、数据模型和问题清单 | 了解现有实现、核对重构要解决什么时 |
+| [protocol-observations.md](protocol-observations.md) | 测试服抓包得到的云端接口行为、字段规范化和待验证项 | 设计写入格式、更新语义时 |
+| [requirements.md](requirements.md) | 功能目标、环境能力划分、待确认事项、第二阶段规划 | 确定要做什么、讨论验收范围时 |
+| technical-design.md(待写) | 统一类型、环境参数化、调度模式识别、校验、对比算法、编辑分层 | 讨论如何实现时 |
+| ui-design.md(待写) | 菜单、列表、详情、编辑器、对比页面的布局和交互 | 实现或评审前端页面时 |
+
+字段的权威定义暂以 `service/dto/fwhTestTouTemplateDTO.go` 和 `deploy/kiro/skills/fwh-toolbox-workflow/references/tou-template.md` 为准;协议原文为 wiki《ScriptingForSchedulingConfiguration》。
+
+## 维护方式
+
+产品范围变化更新 `requirements.md`,并在其待确认表中关闭对应条目;设计决策确认后写入 `technical-design.md`;页面调整写入 `ui-design.md`;`current-state.md` 只描述重构前的状态,不随新代码更新,编码开始后另建 `implementation-status.md`。

+ 86 - 0
docs/despatch-config/current-state.md

@@ -0,0 +1,86 @@
+
+# 调度配置现状梳理
+
+> 状态:2026-10-19 重构前梳理,基于 master `87182c7`。本文只记录现有代码的行为与问题,不代表新需求;新需求见 [requirements.md](requirements.md)。
+
+## 1. 功能概览
+
+现有功能是**云端 TOU 调度模版的只读浏览器**:后端代理 FWH 云平台的模版接口,前端展示列表、详情并导出 JSON。写入(新增/更新)只在 MCP 工具中提供,且仅支持测试服。
+
+| 层              | 位置                                                                                             | 现状                                                                                                      |
+| --------------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
+| 路由            | `src/router/record.ts`(`despatch-config`)                                                  | 菜单“调度配置”,子页“调度模版(生产)”“调度模版(测试)”                                            |
+| 生产页          | `src/views/despatch-config/touTemplatePro.vue`                                                 | 列表:模版类型/发布状态/关键字过滤、分页;右键或按钮“查看”弹窗显示详情                                  |
+| 测试页          | `src/views/despatch-config/touTemplateTest.vue`                                                | 空页面,只有 NCard                                                                                        |
+| 详情组件        | `src/views/despatch-config/component/templateTable.vue`                                        | 按季节 × 日类型分组展示时段表;由优先级反推调度类型 A~H;下载 JSON(外部配置编辑器格式);xlsx 按钮禁用 |
+| 前端 API        | `src/service/despatchConfig/`                                                                  | 仅 `list`、`info`,仅生产服                                                                           |
+| 后端路由/控制器 | `service/routes/despatchConfigRoutes.go`、`service/controllers/despatchConfigControllers.go` | `GET /despatchConfig/touTemplatePro/list`、`/info`                                                    |
+| Service         | `service/services/despatchConfigServices.go`                                                   | 生产服:list、detail;测试服:list、detail、save、cites                                                   |
+| Repo(生产)    | `service/repositories/api/fwh_pro/fwh_touTemplate.go`                                          | `selectIotTouTemplateList`、`getIotTouTemplateDetailById`,结果为 `interface{}` 透传                |
+| Repo(测试)    | `service/repositories/api/fwh_test/fwh_touTemplate.go`                                         | 另有 `updateOrSaveIotTouTemplate`、`selectCitesList`                                                  |
+| DTO             | `service/dto/fwhTestTouTemplateDTO.go`、`service/dto/touTemplateDTO.go`                      | 测试服写入 DTO 完整;`TouTemplateItemInfo` 未被使用                                                     |
+| MCP             | `service/cmd/mcp/tools/service/tou_template.go`                                                | `service_tou_template_list/detail/save/cites`;save、cites 仅测试服                                     |
+| 知识文档        | `deploy/kiro/skills/fwh-toolbox-workflow/references/tou-template.md`                           | 字段字典、日类型配对、校验规则,是目前最完整的字段说明                                                    |
+| 相关模块        | `src/views/local-utils/log-357-parser/func/parseScheduling.ts`                                 | 357 日志解析中另一套“优先级 → 调度类型”映射                                                            |
+
+## 2. 云端接口(hes-gateway)
+
+| 接口                                                | 方法      | 用途                                                                                                                    | 已接入环境 |
+| --------------------------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------- | ---------- |
+| `/hes-gateway/manage/selectIotTouTemplateList`    | POST form | 模版列表;支持 `sourceType`、`status`、`search`、分页,另有 `countryId`、`provinceId`、更新时间范围(未使用) | 生产、测试 |
+| `/hes-gateway/manage/getIotTouTemplateDetailById` | POST form | 模版详情                                                                                                                | 生产、测试 |
+| `/hes-gateway/manage/updateOrSaveIotTouTemplate`  | POST JSON | 新增/更新,返回模版 ID;`templateId` 有值为更新                                                                       | 测试       |
+| `/hes-gateway/manage/selectCitesList`             | GET       | 国家/省/市列表;查省市需 `countryId` 且 `type=1`                                                                    | 测试       |
+
+**尚未找到**:发布、取消发布、删除模版的接口。
+
+## 3. 数据模型
+
+```
+Template(模版主体)
+  国家/省、电力公司(简称/全称)、DER 计划、资费套餐、电价类型 electricityType、
+  名称、mookRunCount、priority、env(位域)、sourceType、status
+ └─ strategyList[](策略)
+      seasonName、month("1,2,…")、日类型、可选购电/卖电电价
+      日类型配对:每天(ccociateType=1, dayType=3, ccociateSn="")
+                或 工作日(2,1) + 周末/节假日(3,2),同组共用 ccociateSn("0" 起递增)
+     └─ detailVoList[](时段,00:00→24:00 首尾衔接全覆盖)
+          waveType 峰平谷、name、briefDescribe、solarPriority/loadPriority(3 值字符串)、
+          各功率上限/固定功率(kW,单台)、SOC、offGrid/solarCutoff/heatEnable/powerOffApower/rampTime…
+```
+
+字段完整定义见 `tou-template.md` 与 `fwhTestTouTemplateDTO.go`。
+
+调度类型由 `solarPriority` + `loadPriority` 组合反推:
+
+| 光伏优先级 | 负载优先级 | 类型                                                                           |
+| ---------- | ---------- | ------------------------------------------------------------------------------ |
+| `1,2,0`  | `1,3,2`  | A 光伏余电馈网                                                                 |
+| `1,2,0`  | `1,2,0`  | B 电池待机                                                                     |
+| `1,3,2`  | `1,2,0`  | C 光伏余电充电                                                                 |
+| `1,3,2`  | `1,3,2`  | D 自发自用                                                                     |
+| `1,2,3`  | `1,2,0`  | E/G 光伏优先充电(G 为使能电网充电)                                           |
+| `1,2,0`  | `1,2,3`  | F/H 电池优先供载(H 为使能电池馈网)                                           |
+| 其他       | —         | 有 `dischargePower` 视为强制放电,有 `chargePower` 视为充电/备电,否则未知 |
+
+## 4. 问题清单
+
+### 4.1 功能缺失
+
+1. 测试服页面为空,测试服 API 未暴露给前端。
+2. 页面不能新增、编辑、复制、发布;写入只能走 MCP,且仅测试服。
+3. xlsx 导出未实现;JSON 导出为外部“配置编辑器”格式(新需求中不再支持)。
+4. 无国家/地区下拉,未使用云端已支持的国家、省、更新时间过滤。
+
+### 4.2 逻辑分散、不一致
+
+5. “优先级 → 调度类型”映射存在三份(详情展示、JSON 导出、`parseScheduling.ts`),覆盖范围不同:导出遇 A 直接抛“未实现”;`parseScheduling` 多 `noPriority`;`9,9,9` 等自定义组合都无法识别。
+6. 详情按 `seasonName` 分组而非 `ccociateSn` 配对,同季节名多组“工作日 + 周末”会互相覆盖;导出按 `seasonName + month` 分组,口径与展示不同。
+7. 数据异常直接 `throw`,详情弹窗整体渲染失败,无容错展示。
+8. 导出中 `id`/`key` 用 `Date.now()`,同一循环内重复。
+
+### 4.3 类型与结构
+
+9. 生产服接口透传 `interface{}`,前端 `interface.ts` 与测试服写入 DTO 是两套结构,存在差异。
+10. 双环境通过复制函数区分(`FwhTestXxx`),未像 `fwh_mqtt` 那样做成环境参数化的通用模块。
+11. 命名不统一:`despatch`/`dispatch` 混用;云端字段自带拼写问题(`derSchdule`、`eletic*`、`ccociate*`、`mookRunCount`),需决定是否在 DTO 层映射。

+ 62 - 0
docs/despatch-config/protocol-observations.md

@@ -0,0 +1,62 @@
+# 云端 TOU 模版接口实测记录
+
+> 状态:2026-10-21,基于测试服后台网页操作抓包(`TOU模版.test.franklinwh.com.har`,含登录 token,不入库)。本文只记录观察到的事实;结论被设计采纳后写入 technical-design。
+
+## 1. 抓包范围
+
+操作顺序:查询省/州 → 新建模版 → 列表 → 查看详情 → 打开编辑(未保存)→ 发布 → 删除。
+
+| # | 接口 | 方法 / 格式 | 用途 |
+| --- | --- | --- | --- |
+| 0 | `/hes-gateway/manage/selectCitesList?countryId=2&type=1&lang=ZH_CN` | GET | 查询美国的省/州 |
+| 28 | `/hes-gateway/manage/updateOrSaveIotTouTemplate` | POST JSON | 新建,返回 `result: 4576`(模版 ID) |
+| 29/34/37 | `/hes-gateway/manage/selectIotTouTemplateList` | POST form | 列表 |
+| 30/32 | `/hes-gateway/manage/getIotTouTemplateDetailById` | POST form `id=4576&lang=ZH_CN` | 详情(两次返回完全相同) |
+| 33 | `/hes-gateway/manage/setStatusIotTouTemplate` | POST form `templateId=4576&status=2&lang=ZH_CN` | **发布** |
+| 36 | `/hes-gateway/manage/setStatusIotTouTemplate` | POST form `templateId=4576&status=1&lang=ZH_CN` | **删除** |
+
+**未覆盖**:编辑后保存(更新)。
+
+## 2. 状态接口 `setStatusIotTouTemplate`
+
+- 发布和删除共用一个接口,只是 `status` 不同:`2` 发布,`1` 删除。响应 `{"code":200,"result":null}`。
+- 删除是软删除:之后列表总数从 1126 降到 1125,模版不再出现在列表中。
+- `status` 取值:`0` 未发布、`1` 已删除、`2` 已发布。没有取消发布的操作(目前只能改数据库)。
+- 发布后 `updateTime` 不变(列表中仍为 `03:02:33`)。
+
+## 3. 新建:网页提交的内容与云端存储的差异
+
+网页提交的请求体与 `fwhTestTouTemplateDTO.go` / `tou-template.md` 的约定有出入,云端会做规范化:
+
+| 字段 | 网页提交 | 详情读回 | 说明 |
+| --- | --- | --- | --- |
+| 未设置的可选数值(功率、SOC 等) | `""` | `null` | 空串可接受;我们的 DTO 用省略,两种都应可行(省略方式待验证) |
+| `solarCutoff` | `""` | `0` | 空串被存为 0 |
+| `name`(时段名) | `""` | `"mid-peak"` | 云端按 `waveType` 自动生成 |
+| `powerOffApower` | `""` | `""` | 原样 |
+| `template.mookRunCount` | `"0"`(字符串) | `0` | |
+| `template.priority` / `env` | `""` | `null` | |
+| `template.sourceType` | 未提交 | `0` | 默认系统模版 |
+| `dayName` | `All day` / `Workday` / `Holidays` | `Every day` / `Weekdays` / `Weekends,Holidays` | **云端按 `dayType` 改写**,提交什么不重要 |
+| `ccociateSn` | 数字 `2`、`4` | 字符串 `"2"`、`"4"` | 不要求从 `"0"` 连续递增,只要求同组相同 |
+| 策略顺序 | JanToJun, JulToSep×2, OctToDec×2 | JanToJun, OctToDec×2, JulToSep×2 | 读回顺序与提交不同,按 `id` 也不是提交顺序 |
+
+## 4. 详情中云端生成或只读的字段
+
+写入 DTO 没有、由云端生成的字段:
+
+- 模版:`id`、`templateId`(等于 `id`)、`gatewayId`、`templateFromType`、`eletricCompanyId`(本例 10100,按电力公司生成或匹配)、`sdcpCompanyFlag`、`status`、`countryZh/En`、`provinceZh/En`、`workMode`(本例 1)、`updateTime`、`displayFlag`、`templateType`、`ptoDate`、`nbtType`、`classification`、`remark`、`tariffName`、`templateName`、`installerTpl`、`solarChargeMin`。
+- 策略:`id`、`templateId`、`fromType`。
+- 时段:`id`、`strategyId`、`useModeFlag`(本例 0)、`dispatchId`(本例 6,优先级为 `1,3,2`/`1,3,2`,**云端似乎按优先级匹配了自己的调度模式表**)、`dispatch`、`buyRate`、`sellRate`。
+- 顶层另有 `bbDefaultVo`(本例 `null`)。
+
+## 5. 待补充验证
+
+| 编号 | 验证内容 | 影响 |
+| --- | --- | --- |
+| V-01 | 编辑后保存的请求体:网页是否回传策略/时段 `id`,是否回传第 4 节中的只读字段 | 更新语义、保持原值 |
+| V-02 | 更新后策略/时段 `id` 是否变化(整体删除重建还是按 `id` 更新) | 对比对齐、操作记录 |
+| V-03 | 对已有 `remark`、`displayFlag`、`tariffName` 等非空的模版,更新时不传这些字段,是否会被清空 | 保持原值 |
+| V-04 | `dispatchId` 的完整取值表。已知:`6`=D(`1,3,2`/`1,3,2`),`3`=E/G(`1,2,3`/`1,2,0`),`9`=Zero Import(`9,9,9`),见 `service/test/tou/main.go`;抓包中网页新建时未提交 `dispatchId`,读回为 `6`,说明云端按优先级自动匹配;完整取值表和未匹配时的取值待确认 | 调度模式定义表 |
+| V-05 | 对已发布模版执行更新后,`status` 是否保持 2 | 编辑流程 |
+| V-06 | 可选数值字段“省略”与“传空串”在云端是否等价 | 写入格式 |

+ 164 - 0
docs/despatch-config/requirements.md

@@ -0,0 +1,164 @@
+# 调度配置功能需求
+
+> 状态:需求梳理初稿,2026-10-19;2026-10-21 根据测试服抓包补充发布/删除接口,关闭 Q-03、Q-09、Q-11;确定调度模式定义表与高级修改范围,关闭 Q-01、Q-12、Q-13、Q-14。本文记录重构后的目标能力和待确认的产品口径;实现方式写入 technical-design 和 ui-design。现状与问题见 [current-state.md](current-state.md)。
+
+## 1. 目标与范围
+
+重构“调度配置”模块,使其成为**云端 TOU 调度模版的完整管理工具**,并替代外部“配置编辑器”:
+
+1. 查看模版列表与详情(生产服、测试服);
+2. 在测试服新增、编辑模版(发布、删除放到后续迭代);
+3. 导出模版(生产服、测试服);
+4. 生产服模版与指定测试服模版对比,显示差异。
+
+**范围内**:仅通过 FWH 云平台(hes-gateway)接口管理模版。
+
+**范围外**:
+
+- 不直接通过 MQTT 向设备下发或读取调度(355/357 协议),后续也不考虑绕过 FWH Server;wiki《ScriptingForSchedulingConfiguration》是 FWH Server 与设备之间的协议,仅作设计参考,字段格式不照搬;
+- 不再兼容外部“配置编辑器”的 JSON 格式;
+- 生产服不提供任何写操作;
+- AI/MCP 对接放到第二阶段(见第 5 节)。
+
+## 2. 功能需求
+
+### FR-01 菜单与环境
+
+- 主菜单“调度配置”,生产服与测试服使用**独立子菜单**。
+- 每个页面只操作所属环境,页面上明确标出环境;一个环境不可用(未配置、登录失败)时不影响另一个环境。
+
+| 能力 | 生产服 | 测试服 |
+| --- | :---: | :---: |
+| 列表 / 详情 | ✓ | ✓ |
+| 导出 | ✓ | ✓ |
+| 新增 / 编辑 | — | ✓ |
+| 发布 / 删除(后续迭代) | — | ✓ |
+| 对比(生产 ↔ 测试) | ✓ | 待定(见 Q-06) |
+
+### FR-02 模版列表
+
+- 分页列表,支持按模版类型、发布状态、关键字过滤。
+- 增加国家、地区过滤(云端接口已支持 `countryId`、`provinceId`),选项来自 `selectCitesList`。
+- 列表至少显示:ID、类型、国家、地区、DER 计划、电力公司、资费套餐、电价类型、发布状态、更新时间。
+
+### FR-03 模版详情
+
+- 展示模版主体信息和全部策略。策略按**季节 × 日类型**组织,工作日与周末/节假日按 `ccociateSn` 配对,不按季节名合并。
+- 每个时段显示:时间段、峰平谷、调度类型(由优先级按第 3 节“解析规则”识别)、设置了的扩展参数;可查看该时段的原始字段。
+- 策略级电价(购电/卖电)按峰平谷显示。
+- 提供 24 小时时间轴等直观视图(具体形式在 ui-design 中确定)。
+- **容错**:数据不符合规则(时段未覆盖整天、配对缺失、未知 `dayType`、无法识别的优先级组合等)时仍应展示,并标出具体问题,不能让整个详情渲染失败。
+
+### FR-04 新增与编辑(仅测试服)
+
+- 支持新建空白模版、编辑已有模版;“基于已有模版复制新建”的范围见 Q-02。
+- **不提供原始字段的直接编辑**。编辑以“选择”为主:
+  - 模版主体:国家/地区下拉、电价类型等枚举选项;
+  - 策略:选择月份、日类型(每天 / 工作日 + 周末节假日),系统自动生成 `ccociateType`、`ccociateSn`、`dayType`、`dayName`;
+  - 时段:编辑时间段与峰平谷,选择调度模式(A~H、强制充电、强制放电等),系统按调度模式定义表写入对应的**标准** `solarPriority`/`loadPriority` 及参数(见第 3 节“写入规则”)。
+- 在模式选择之外提供**高级修改**:除优先级外,时段的其他参数(功率上限/固定功率、SOC、`gcaoMax`、`heatEnable`、`rampTime`、`offGrid`、`solarCutoff`、`powerOffApower` 等)都可以修改,也可以清空为“未设置”。
+- **优先级不开放自定义输入**,只能通过选择调度模式确定,或设置为空(不下发优先级)。设为空时 `solarPriority` 和 `loadPriority` 同时写为空字符串 `""`,不允许只有一个为空。
+- **模式参数约束**:部分模式对参数有约束(见第 3 节扩展模式参数表),分两种:
+  - **固定**:值由模式决定,界面只读,不能修改也不能清空(如 Zero Import 的 `gridFeedMax`);
+  - **必填**:选择模式时填入默认值,用户可以改数值,但不能清空;必填参数为空时禁止保存。
+  - 优先级对所有模式都相当于“固定”。高级修改中,除优先级和当前模式的固定参数外,其他参数都可以修改或清空。
+- **切换模式**:切换调度模式时,替换优先级,清除原模式的固定/必填参数(新模式也需要的除外)并填入新模式的固定值和默认值;其他高级参数保留。切换前提示将被清除的参数。
+- **保持原值**:编辑已有模版时,用户没有改动的内容保存时必须原样写回,包括非标准的优先级数值(如 `9,9,9`)和其他字段,不做规范化。优先级只在用户对该时段重新选择调度模式(或设为空)时才被替换。保存前的确认弹窗只列出用户实际改动的内容。
+- 保存前校验,校验不通过时禁止保存并定位到具体问题:
+  - 每个策略的时段 00:00→24:00 首尾衔接,无空档、无重叠,格式 `HH:mm`;
+  - 工作日与周末/节假日必须成对,`ccociateSn` 正确;
+  - 月份在各策略间不重叠且覆盖全年(是否强制覆盖全年见 Q-04);
+  - 数值在合法范围内(功率 kW 精度 0.1,SOC 0~100,`rampTime` 0~300 等);
+  - 写入时 `sourceType` 只能为 0(系统模版)。
+- 保存前显示确认弹窗,列出本次写入的内容或与原版本的差异;保存后重新读取详情,确认云端结果与提交内容一致。
+
+### FR-05 发布与删除(仅测试服,后续迭代)
+
+- 本期不实现,后续迭代再加。
+- 云端接口已确认:`setStatusIotTouTemplate`,`status=2` 发布、`status=1` 删除(软删除,从列表消失),见 [protocol-observations.md](protocol-observations.md) 第 2 节。
+- 云端没有取消发布的功能(目前只能改数据库),本工具也不提供。
+- 实现时:操作前显示确认弹窗,删除需二次确认。
+
+### FR-06 导出(生产服、测试服)
+
+- 导出单个模版。格式不再沿用外部配置编辑器,具体格式见 Q-05。
+- 文件名包含电力公司简称、DER 计划、模版名称、环境和时间戳。
+
+### FR-07 对比(生产服 ↔ 测试服)
+
+- 在生产服模版上选择一个测试服模版(按 ID 或通过列表搜索)进行对比。
+- 对比覆盖三层:模版主体字段、策略(季节、月份、日类型、电价)、时段(时间段、峰平谷、调度类型、全部参数)。
+- 差异分为:新增、删除、修改;修改项同时显示两边的值。可只看差异,也可显示全部。
+- 策略和时段的对齐规则(按季节名 + 日类型,或按月份 + 日类型;时间段切分不同时如何对比)在技术设计中确定。
+- 两边都只读取,不修改任何一方。
+
+## 3. 共同需求
+
+- **调度模式定义表**:统一定义基础工作模式 A~H(以及强制充电、强制放电等),每个模式包含名称、语义说明、`solarPriority`/`loadPriority` 写入值和必需参数。定义表需可扩展,新增模式只修改这一处;也可以加入使用非标准数值的特殊模式(如 `9,9,9` 的 Zero Import),加入后即可在编辑时选择。详情、编辑、对比、导出共用此表;与 357 日志解析(`parseScheduling.ts`)的关系在技术设计中确定。
+- **解析规则(只用于展示、对比、导出等读取场景)**:优先级每位取值 0~9,`0` 表示不涉及;非 0 值只看相对大小(越大优先级越高,相同表示按比例/功率分配),不要求是 1/2/3。因此 `9,9,9`、`2,5,1` 等任意数值都应显示出正确的调度逻辑。归不到已定义模式的组合显示为“自定义”并给出语义说明,不报错。优先级为空时显示为“未设置优先级”。
+- 解析时先把每组优先级做**稠密排名归一化**:`0` 保持为 `0`,非 0 值按从小到大依次映射为 1、2、3,相同值映射为同一个数。例如 `9,9,9` → `1,1,1`,`2,5,1` → `2,3,1`,`0,5,9` → `0,1,2`。然后用归一化后的结果去匹配 A~H。**特殊模式(如 Zero Import)不参与归一化**,按原始数值精确匹配;例如 `5,5,5` 归一化后是 `1,1,1`,不会被识别为 Zero Import,而是“自定义”。
+
+**调度模式定义表(初版,2026-10-21 确认)**
+
+优先级顺序:光伏 `[市电, 负载, aPower]`,负载 `[市电, 光伏, aPower]`。
+
+| 模式 | 名称 | 光伏优先级 | 负载优先级 | 语义 |
+| --- | --- | --- | --- | --- |
+| A | Excess solar export to grid(光伏余电馈网) | `1,2,0` | `1,3,2` | 光伏优先供载,剩余馈网;不足由 aPower 补,再不足由市电补;aPower 不充电 |
+| B | aPower standby(电池待机) | `1,2,0` | `1,2,0` | 光伏优先供载,剩余馈网;不足由市电补;aPower 不充不放 |
+| C | Excess solar charge aPower(光伏余电充电) | `1,3,2` | `1,2,0` | 光伏优先供载,剩余给 aPower 充电,再剩余馈网;不足由市电补;aPower 不放电 |
+| D | Self-consumption(自发自用) | `1,3,2` | `1,3,2` | 光伏优先供载,剩余充电,再剩余馈网;不足由 aPower 补,再不足由市电补 |
+| E/G | aPower charge from solar / grid(优先充电) | `1,2,3` | `1,2,0` | 光伏优先给 aPower 充电,剩余供载,再剩余馈网;不足由市电补;aPower 不放电。用户在 PCS 设置中使能电网充电时表现为 G |
+| F/H | aPower supply home / export to grid(优先供载) | `1,2,0` | `1,2,3` | aPower 优先供载,光伏馈网;不足由光伏补,再不足由市电补;aPower 不充电。用户在 PCS 设置中使能电池馈网时表现为 H |
+| — | 未设置优先级 | `""` | `""` | 两个优先级同时为空字符串 |
+| 强制充电 | aPower charge from grid / Backup only(强制充电 / 仅备电) | `""` | `""` | 电网不限充电,aPower 以固定功率充电并保持满电 |
+| 强制放电 | aPower forced discharge(强制放电) | `""` | `""` | 不限 aPower 馈网,aPower 以固定功率放电至预留 SOC |
+| Zero Import | Zero Import(零取电,特殊模式) | `9,9,9` | `9,9,9` | 时段内尽量不从电网取电,由 aPower 放电供载,馈网限制在极小值;按原始值精确匹配,不归一化 |
+
+写入时使用表中的数值。语义说明参考 wiki《ScriptingForSchedulingConfiguration》的常用优先级组合,界面文案在 ui-design 中确定。E/G、F/H 在模版中各是一个模式,不拆分:E 变成 G、F 变成 H 取决于模版之外用户的 PCS 设置(电网充电使能、电池馈网使能),模版无法也不需要区分,界面统一显示为 E/G、F/H;扩展模式(强制充电/仅备电、强制放电、Zero Import)已确认加入,参数见下方扩展模式参数表。
+
+> **补充信息(仅用于理解 Zero Import,不作为需求)**:Zero Import 来自澳洲 GloBird 的 ZEROHERO 套餐(参考模版 4477)。套餐条款:晚高峰(当地时间 18:00–21:00,每天)每小时从电网取电少于 0.03 kWh 即视为“Zero”;另有 ZEROCHARGE 功能,按 28 天账单周期每天限 50 kWh(属于套餐规则,由普通时段参数实现,不是调度模式)。
+
+**扩展模式的参数(2026-10-21 确认)**
+
+选择模式时填入下表的值:“固定”参数只读;“必填”参数可以修改但不能清空;“初始值”参数可以在高级修改中调整或清空。
+
+| 模式 | 参数(单位 kW,按单台 aPower) | 来源 |
+| --- | --- | --- |
+| 强制充电 | 固定:优先级 `""`/`""`;必填:`chargePower=8`(单台最大充电功率)、`gridChargeMax=-1`(默认不限) | wiki《常规调度配置·仅备电》(原文 8000 W)、`tou-template.md` |
+| 强制放电 | 固定:优先级 `""`/`""`;必填:`dischargePower=10`(单台最大放电功率)、`gridDischargeMax=-1`(默认不限) | wiki《常规调度配置·强制aPower放电》(原文 10000 W) |
+| Zero Import | 固定:优先级 `9,9,9`/`9,9,9`、`gridDischargeMax=0.1`、`gridFeedMax=0.1`;必填:`dischargePower=10`;初始值:`gridMax=0`、`gcaoMax=0`、`heatEnable=1` | `service/test/tou/main.go` 测试数据(云端 `dispatchId=9`) |
+
+识别顺序(解析时):
+
+1. 按原始值精确匹配特殊模式(Zero Import:`9,9,9`/`9,9,9`);
+2. 两个优先级都为空时:只设置了 `chargePower` → 强制充电;只设置了 `dischargePower` → 强制放电;两者都没设置 → 未设置优先级;两者都设置了 → 自定义并标出异常;
+3. 归一化后匹配 A~H;
+4. 都不匹配 → 自定义(只有一个优先级为空也归为自定义并标出异常)。
+
+“设置了”指字段有值且不为 `-1`(未设置的哨兵值)。Zero Import 只按优先级识别;如果已有数据的固定参数与定义不一致(如 `gridFeedMax` 不是 0.1),仍识别为 Zero Import,但标出不一致,且在用户未改动该时段时原样保留。识别结果只看优先级和上述字段,A~H 时段上额外设置的功率参数作为高级参数显示,不改变模式。
+
+- **写入规则**:用户新设置的优先级只能取自调度模式定义表,或者为空;不允许手工输入其他数值,避免下层应用无法识别。已有的非标准数值在用户没有改动时原样保留(见 FR-04“保持原值”)。
+- 前后端使用统一的模版类型定义,生产服与测试服共用同一套结构和代码,通过环境参数区分。
+- 云端字段拼写问题(`derSchdule`、`eletic*`、`ccociate*`、`mookRunCount`)在界面上显示规范名称;是否在 DTO 层映射由技术设计决定。
+- 模版主体中的 `priority`、`env`、`mookRunCount` 目前很少使用,不做语义解释:编辑时原样保留,允许按原始值填写,不做枚举或位域拆解。
+- 写操作记录操作人、环境、模版 ID 和提交内容,便于追溯(存储方式见 Q-07)。
+
+## 4. 待确认事项
+
+已关闭:Q-03(发布/删除接口已确认,功能放到后续迭代,见 FR-05)、Q-09(`priority`/`env`/`mookRunCount` 原样保留,见第 3 节)、Q-11(优先级为空时两者都写空字符串,见 FR-04)、Q-12(E/G、F/H 不拆分,由用户 PCS 设置决定,见调度模式定义表)、Q-01(高级修改:除优先级外全部参数可改,见 FR-04)、Q-13(扩展模式的优先级和参数,见模式参数表)、Q-14(强制充放电的 `gridChargeMax`/`gridDischargeMax` 为必填,预设 -1,可修改)。
+
+| 编号 | 问题 | 影响 |
+| --- | --- | --- |
+| Q-02 | 是否支持“复制已有模版新建”,是否允许以生产服模版为蓝本在测试服新建 | FR-04、FR-07 |
+| Q-04 | 月份是否必须覆盖全年;一个模版最多几组策略 | FR-04 校验 |
+| Q-05 | 导出格式:云端原始 JSON、规范化 JSON、xlsx,还是多选 | FR-06 |
+| Q-06 | 测试服页面是否也提供对比入口(反向对比) | FR-01、FR-07 |
+| Q-07 | 写操作记录是否入库,是否需要在工具内保留历史版本 | 共同需求 |
+| Q-08 | API 模版(1)和用户推荐(2)是否保留在列表中供查看和过滤(写入已限定为系统模版 0) | FR-02、FR-04 |
+| Q-10 | 权限:编辑和发布是否全部开放,是否需要区分角色 | FR-04、FR-05 |
+
+## 5. 第二阶段(暂不实现)
+
+- 模块接入 MCP,让 AI 完成模版编辑,复用本阶段的统一类型、校验规则和调度模式识别。
+- 现有 MCP 工具 `service_tou_template_*` 在第二阶段改为调用重构后的服务层。

+ 1 - 0
docs/fault-record/protocol-specification.md

@@ -431,6 +431,7 @@ https://<环境域名>/hes-upload/v1/file/download/logger/<equipNo>/<609.fileNam
 | 位置/来源                       | 已出现的值                             | 说明                                                                       |
 | -------------------------------- | -------------------------------------- | -------------------------------------------------------------------------- |
 | FaultFilter 文档                 | `pe`、`aGate`                      | 业务层仅定义这两种;`pe` 按 SN 寻址(最多 15 台),`aGate` 系统内唯一 |
+| 用户确认(2026-10-20)           | `pe`、`aGate`、`mppt`、`apbox`、`dif` | 377 `type` 全集:`mppt`=aPower-MPPT(与 aPower 同 SN)、`apbox`=aHub(aPbox2.0)、`dif`=Digital Interface(前端暂未开放) |
 | `377/379.list[].type` 样本     | `aGate`                              | `cmd=0/2`                                                                |
 | `377.list[].type` 已验证代码   | `pe`、`aGate`                      | 参数设置                                                                   |
 | `377.list[].type` 旧描述       | `pe`、`agate`、`mppt`、`apbox` | FaultFilter 文档未定义`agate`(小写)、`mppt`、`apbox`,不应使用  |

+ 1 - 1
docs/fault-record/requirements.md

@@ -68,7 +68,7 @@
 - **多条获取**:一次可获取多条录波记录,设备打成一个包(2026-10-19 用户确认,替换原“每次 1 条”)。
 - **设备忙**:本工具内对同一网关串行;设备因其他来源(如 FWH 平台网页)回复 `busy` 时,明确提示用户,不自动重试。
 - **取消**:进行中的操作可取消,必要时向设备发送 `cmd=4`(退出)结束当前处理。
-- **设备列表**:pe 子设备 SN 即 aPower SN,从站点信息中获取;生产服和测试服调用各自的接口。
+- **设备列表**:支持 aGate/MCA、aPower-PE、aPower-MPPT、aHub(即 aPbox)四种设备,来源与判断规则见 [technical-design.md](technical-design.md#5-设备与站点信息);生产服和测试服调用各自的接口。377 `type` 分别为 `aGate`、`pe`、`mppt`、`apbox`;另有 `dif`(Digital Interface)暂未开放。
 - **时间**:目录和录波中的时间暂按设备时间原样显示,测试有问题再改。
 - **记录**:操作过程记录在数据库中,原始文件存 MinIO;操作历史所有人共享,并记录操作人。暂不实现数据清理。
 - **权限**:功能全部开放;参数设置提交前必须弹窗确认,危险操作醒目标注。

+ 28 - 4
docs/fault-record/technical-design.md

@@ -158,10 +158,34 @@ asynq 会把服务重启时未完成的任务重新投递。执行器先读 Mong
 
 ## 5. 设备与站点信息
 
-- 用户输入网关 SN,Service 按环境调用 `fwh_pro.QuerySiteInfoList` 或 `fwh_test.QuerySiteInfoList` 获取站点信息。
-- 可选设备为:`aGate`(网关 SN)+ `fhpSn` 中逗号分隔的每个 aPower SN。**aPower SN 就是 `pe` SN**(用户确认,2026-10-16)。
-- 生产服和测试服是两个不同的接口(`fwh_pro` 与 `fwh_test`),必须按操作所属环境调用,不能用生产服站点信息去操作测试服设备,反之亦然。
-- 只调用 Repository 层接口,不复用 `services.GetSiteInfoData`:后者会把结果写入站点信息表,测试服数据不能写入。
+设备列表按操作所属环境(`fwh_pro` 或 `fwh_test`)查询,不复用 `services.GetSiteInfoData`(会写入站点信息表)。设备种类与平台设备类型对应(用户确认,2026-10-20):
+
+| 平台设备类型 | 名称 | 种类(代码) | SN 来源 | 377 `type` |
+| --- | --- | --- | --- | --- |
+| 1 | aGate/MCA | `aGate` | 网关 SN | `aGate` |
+| 0 | aPower-PE | `aPowerPE` | `/hes-gateway/manage/getDeviceInfo` 的 `fhpSn`(逗号分隔) | `pe` |
+| 2 | aPower-MPPT | `aPowerMPPT` | 与 aPower-PE 同一 SN;仅 PE 硬件版本为 21、24 的 aPower 支持 | `mppt` |
+| 3 | aHub(即 aPbox2.0) | `aHub` | `/hes-gateway/manage/selectGatewayPower` 的 `apbox20List[].apbox20Sn` | `apbox` |
+| — | DIF(Digital Interface) | — | 暂未知 | `dif`(后端协议层支持,前端暂未开放) |
+
+- `getDeviceInfo` 中 `peHwVerList`、`apowerList` 与 `fhpSn` 的 aPower 顺序一一对应;PE 硬件版本 21(aPower S)、24(aPower 2.1S)带 MPPT,后续可能扩展(集中在 `FaultRecordProtocol` 的 `SupportsMPPT`)。
+- 站点名称和在线状态仍取自站点信息接口(`QuerySiteInfoList`)。
+- `getDeviceInfo` 失败时退回站点信息的 `fhpSn`,无法判断 aPower-MPPT;`selectGatewayPower` 失败时不列出 aHub。两种情况都在页面提示。
+- 377 中的设备类型共 5 种:`pe`、`aGate`、`mppt`、`apbox`、`dif`(用户确认,2026-10-20)。
+- 样本:[设备信息.test.franklinwh.com.har](设备信息.test.franklinwh.com.har)(测试服网关 `10070052S00X25150083`:2 台 aPower,第 2 台为 aPower S,1 台 aHub)。
+
+PE 硬件版本对照(格式:版本值 - 版本名称 - 对外名称;代码中使用版本名称):
+
+| 值 | 版本名称 | 对外名称 |
+| --- | --- | --- |
+| 0 | aPower 1.0 | aPower X |
+| 2 | aPower 1.2(澳洲)/aPower 1.3(北美) | aPower X |
+| 4 | aPower 1.4 | aPower X |
+| 20 | aPower 2/aPower 2(LR) | aPower 2/aPower 2(LR) |
+| 21 | aPower S | aPower S |
+| 22 | aPower 2(SH) | aPower 2(SH) |
+| 23 | aPower 2.1 | aPower 2.1 |
+| 24 | aPower 2.1S | aPower 2.1S |
 
 ## 6. 测试服与生产服
 

+ 1 - 1
service/domain/batchDownloadDomain.go

@@ -79,7 +79,7 @@ type BatchDownloadData struct {
 
 // 下载任务数据 (保存于redis数据库)
 type BatchDownloadFileTaskInfo struct {
-	DevType     int    `bson:"devType" json:"devType"` // 设备类型:-1-忽略 0-aPower 1-aGate 2-MPPT 3-aHub
+	DevType     int    `bson:"devType" json:"devType"` // 设备类型:-1-忽略 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub
 	Sn          string `bson:"sn" json:"sn"`
 	Date        string `bson:"date" json:"date"`
 	FileType    string `bson:"fileType" json:"fileType"`

+ 3 - 0
service/domain/esMqttDomain.go

@@ -122,6 +122,7 @@ type MqttMessageItem struct {
 	EquipNo   string         `json:"equipNo"`
 	CmdType   int64          `json:"cmdType"`
 	Event     map[string]any `json:"event,omitempty"`
+	DocID     string         `json:"docId"` // ES 文档唯一标识 "_index/_id"(非 _source 字段,查询时填充)
 }
 
 // MessagePage 逐条报文查询的分页结果
@@ -142,6 +143,8 @@ type EsHitsTotal struct {
 
 // EsMessageHit 单条命中
 type EsMessageHit struct {
+	Index  string          `json:"_index"`
+	ID     string          `json:"_id"`
 	Source MqttMessageItem `json:"_source"`
 }
 

+ 37 - 2
service/domain/fwhProDomain.go

@@ -92,7 +92,7 @@ type EmsLogRecordInfo struct {
 
 type FaultRecordInfo struct {
 	Id                int64   `json:"id"`
-	DeviceType        int     `json:"deviceType"`        // 0-aPower 1-aGate 2-MPPT 3-aHub
+	DeviceType        int     `json:"deviceType"`        // 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub
 	ApowerId          *string `json:"apowerId"`          // aPower SN, deviceType不为0或2时为null
 	GatewayId         string  `json:"gatewayId"`         // aGate SN
 	PowerlinkId       *string `json:"powerlinkId"`       // aHub SN, deviceType不为3时为null
@@ -122,7 +122,7 @@ type FaultRecordChannelInfo struct {
 }
 
 type FaultRecordSubInfo struct {
-	DeviceType             int                      `json:"deviceType"` // 0-aPower 1-aGate 2-MPPT 3-aHub
+	DeviceType             int                      `json:"deviceType"` // 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub
 	ApowerId               *string                  `json:"apowerId"`   // aPower SN
 	GatewayId              string                   `json:"gatewayId"`  // aGate SN
 	FileName               string                   `json:"fileName"`   // 不含后缀
@@ -333,3 +333,38 @@ type FwhSiteInfo struct {
 	SiteId  *uint   `json:"siteId"`  // 站点ID号
 	JobCode *string `json:"jobCode"` // Job Code (Sunrun相关)
 }
+
+/*************** 设备信息 /hes-gateway/manage/getDeviceInfo ***************/
+
+// FwhDeviceInfoAPower getDeviceInfo.apowerList 项
+type FwhDeviceInfoAPower struct {
+	Id         string `json:"id"`         // aPower SN
+	RatedPwr   *int   `json:"ratedPwr"`   // 额定功率
+	RateBatCap *int   `json:"rateBatCap"` // 额定电池容量
+	ModelName  string `json:"modelName"`  // 型号,如 aPower 2、aPower S
+}
+
+// FwhDeviceInfo 网关设备信息(只解析故障录波需要的字段)
+type FwhDeviceInfo struct {
+	FhpSn       string                `json:"fhpSn"`       // aPower SN,多设备用 , 分割
+	PeHwVerList []int                 `json:"peHwVerList"` // 各 aPower 的 PE 硬件版本,顺序与 fhpSn 一致;21、24 支持 MPPT
+	ApowerList  []FwhDeviceInfoAPower `json:"apowerList"`  // 各 aPower 信息
+	MpptEnFlag  *bool                 `json:"mpptEnFlag"`  // 站点是否启用 MPPT
+	Apbox20Num  *int                  `json:"apbox20Num"`  // aHub(aPbox2.0) 数量
+	EquipType   *int                  `json:"equipType"`
+}
+
+/*************** 网关功率 /hes-gateway/manage/selectGatewayPower ***************/
+
+// FwhApbox20Info selectGatewayPower.apbox20List 项(aHub 即 aPbox2.0)
+type FwhApbox20Info struct {
+	Apbox20Sn          string `json:"apbox20Sn"`          // aHub SN
+	Apbox20HardwareVer *int   `json:"apbox20HardwareVer"` // 硬件版本
+	Apbox20AppVer      string `json:"apbox20AppVer"`      // 软件版本
+}
+
+// FwhGatewayPower 网关功率信息(只解析故障录波需要的字段)
+type FwhGatewayPower struct {
+	Apbox20List []FwhApbox20Info `json:"apbox20List"`
+	MpptSta     []int            `json:"mpptSta"` // 各 aPower 的 MPPT 状态,顺序与 aPower 一致
+}

+ 8 - 2
service/dto/faultRecordDTO.go

@@ -23,8 +23,13 @@ type FaultRecordCreateOpReq struct {
 
 // FaultRecordDevice 可操作的设备
 type FaultRecordDevice struct {
-	Type string `json:"type"` // aGate / pe
-	Sn   string `json:"sn"`
+	Kind      string `json:"kind"`            // aGate / aPowerPE / aPowerMPPT / aHub
+	Type      string `json:"type"`            // 377 报文中的 type(aGate / pe);为空表示协议类型待确认
+	Sn        string `json:"sn"`              // aPower-MPPT 与 aPower-PE 使用同一 SN
+	Model     string `json:"model,omitempty"` // 型号,如 aPower S
+	HwVer     *int   `json:"hwVer,omitempty"` // aPower PE 硬件版本
+	Supported bool   `json:"supported"`       // 是否可发起操作
+	Note      string `json:"note,omitempty"`  // 不可用原因
 }
 
 // FaultRecordSiteDevices 网关及其设备
@@ -35,6 +40,7 @@ type FaultRecordSiteDevices struct {
 	Online     bool                `json:"online"`
 	OnlineTime string              `json:"onlineTime,omitempty"`
 	Devices    []FaultRecordDevice `json:"devices"`
+	Warnings   []string            `json:"warnings,omitempty"` // 部分设备信息获取失败时的提示
 }
 
 // FaultRecordOpSummary 操作历史列表项

+ 34 - 0
service/function/FaultRecordProtocol/dict.go

@@ -24,6 +24,40 @@ var DangerousFunctionCodes = map[int]string{
 	4: "修改设备时间",
 }
 
+// 设备种类(界面与平台使用;协议 type 见 DeviceKindProtocolType)
+const (
+	DeviceKindAGate      = "aGate"      // aGate/MCA,平台设备类型 1
+	DeviceKindAPowerPE   = "aPowerPE"   // aPower-PE,平台设备类型 0
+	DeviceKindAPowerMPPT = "aPowerMPPT" // aPower-MPPT,平台设备类型 2,SN 与 aPower 相同
+	DeviceKindAHub       = "aHub"       // aHub(即 aPbox2.0),平台设备类型 3
+)
+
+// DeviceKindProtocolType 设备种类对应 377 报文中的 type(用户确认,2026-10-20);空字符串表示暂不支持
+var DeviceKindProtocolType = map[string]string{
+	DeviceKindAGate:      DeviceTypeAGate,
+	DeviceKindAPowerPE:   DeviceTypePE,
+	DeviceKindAPowerMPPT: DeviceTypeMPPT,
+	DeviceKindAHub:       DeviceTypeAPbox,
+}
+
+// PeHwVerNames aPower PE 硬件版本 → 版本名称(内部名称)
+var PeHwVerNames = map[int]string{
+	0:  "aPower 1.0",
+	2:  "aPower 1.2(澳洲)/aPower 1.3(北美)",
+	4:  "aPower 1.4",
+	20: "aPower 2/aPower 2(LR)",
+	21: "aPower S",
+	22: "aPower 2(SH)",
+	23: "aPower 2.1",
+	24: "aPower 2.1S",
+}
+
+// mpptPeHwVers 支持 aPower-MPPT 的 PE 硬件版本(目前只有 aPower S、aPower 2.1S,后续可能扩展)
+var mpptPeHwVers = map[int]bool{21: true, 24: true}
+
+// SupportsMPPT 该 PE 硬件版本的 aPower 是否带 MPPT
+func SupportsMPPT(peHwVer int) bool { return mpptPeHwVers[peHwVer] }
+
 // RealtimeCodeNames 实时数据类别,FaultFilter 文档 5.1
 var RealtimeCodeNames = map[int]string{
 	1: "故障总体信息",

+ 11 - 2
service/function/FaultRecordProtocol/protocol.go

@@ -35,8 +35,11 @@ const (
 
 // 业务层设备类型
 const (
-	DeviceTypePE    = "pe"
-	DeviceTypeAGate = "aGate"
+	DeviceTypePE    = "pe"    // aPower-PE
+	DeviceTypeAGate = "aGate" // aGate/MCA
+	DeviceTypeMPPT  = "mppt"  // aPower-MPPT
+	DeviceTypeAPbox = "apbox" // aHub(aPbox2.0)
+	DeviceTypeDIF   = "dif"   // Digital Interface,前端暂未开放
 )
 
 // 整体结果
@@ -53,6 +56,12 @@ func NormalizeDeviceType(t string) (string, error) {
 		return DeviceTypePE, nil
 	case "agate":
 		return DeviceTypeAGate, nil
+	case "mppt":
+		return DeviceTypeMPPT, nil
+	case "apbox":
+		return DeviceTypeAPbox, nil
+	case "dif":
+		return DeviceTypeDIF, nil
 	default:
 		return "", fmt.Errorf("unknown device type: %s", t)
 	}

+ 6 - 1
service/function/FaultRecordProtocol/protocol_test.go

@@ -116,9 +116,14 @@ func TestBuildRequests(t *testing.T) {
 	if _, err := NewRealtimeRequest("pe", "X", []int{4}); err == nil {
 		t.Error("code=4 should be rejected for cmd=0")
 	}
-	if _, err := NewGetRecordRequest("mppt", "X", []int{1}); err == nil {
+	if _, err := NewGetRecordRequest("mpt", "X", []int{1}); err == nil {
 		t.Error("unknown device type should be rejected")
 	}
+	for in, want := range map[string]string{"pe": "pe", "AGATE": "aGate", "MPPT": "mppt", "apbox": "apbox", "dif": "dif"} {
+		if got, err := NormalizeDeviceType(in); err != nil || got != want {
+			t.Errorf("NormalizeDeviceType(%q) = %q, %v", in, got, err)
+		}
+	}
 	req, _ = NewGetRecordRequest("aGate", "X", []int{7, 9, 3})
 	if b, _ = json.Marshal(req); !strings.Contains(string(b), `"index":[7,9,3]`) {
 		t.Errorf("multi-record request: %s", b)

+ 4 - 1
service/repositories/api/fwh_es_mqtt/fwh_es_mqtt.go

@@ -454,7 +454,10 @@ func QueryMessages(param QueryMessageParam) (*domain.MessagePage, error) {
 		out.Total = res.Hits.Total.Value
 		out.Items = make([]domain.MqttMessageItem, 0, len(res.Hits.Hits))
 		for _, h := range res.Hits.Hits {
-			out.Items = append(out.Items, h.Source)
+			item := h.Source
+			// _id 仅在单个索引内唯一,跨日索引查询时需带上索引名
+			item.DocID = h.Index + "/" + h.ID
+			out.Items = append(out.Items, item)
 		}
 	}
 	return out, nil

+ 38 - 0
service/repositories/api/fwh_pro/fwh_deviceInfo.go

@@ -0,0 +1,38 @@
+package fwh_pro
+
+import (
+	"fmt"
+	"net/url"
+
+	"fwh-toolbox-server/domain"
+)
+
+// QueryDeviceInfo 查询网关设备信息(/hes-gateway/manage/getDeviceInfo),用于获取 aPower 列表及各自的 PE 硬件版本
+func QueryDeviceInfo(gatewayId string) (*domain.FwhDeviceInfo, error) {
+	params := url.Values{}
+	params.Add("gatewayId", gatewayId)
+	params.Add("lang", "ZH_CN")
+	res, err := FwhHttpGetUnmarshal[domain.FwhDeviceInfo](getFullEnergyUrl("/hes-gateway/manage/getDeviceInfo"), params)
+	if err != nil {
+		return nil, err
+	}
+	if res.Code != 200 || res.Result == nil {
+		return nil, fmt.Errorf("[QueryDeviceInfo] 查询失败 code: %d, message: %s", res.Code, res.Message)
+	}
+	return res.Result, nil
+}
+
+// QueryGatewayPower 查询网关功率信息(/hes-gateway/manage/selectGatewayPower),用于获取 aHub(aPbox2.0) 列表
+func QueryGatewayPower(gatewayId string) (*domain.FwhGatewayPower, error) {
+	params := url.Values{}
+	params.Add("gatewayId", gatewayId)
+	params.Add("lang", "ZH_CN")
+	res, err := FwhHttpGetUnmarshal[domain.FwhGatewayPower](getFullEnergyUrl("/hes-gateway/manage/selectGatewayPower"), params)
+	if err != nil {
+		return nil, err
+	}
+	if res.Code != 200 || res.Result == nil {
+		return nil, fmt.Errorf("[QueryGatewayPower] 查询失败 code: %d, message: %s", res.Code, res.Message)
+	}
+	return res.Result, nil
+}

+ 1 - 1
service/repositories/api/fwh_pro/fwh_faultRecord.go

@@ -99,7 +99,7 @@ func queryFaultRecordCallableList(devType int, sn string) (*domain.FwhResponse[[
 }
 
 // 查询设备可召唤数据列表 - 仅自动录播
-// devType: 0-aPower 1-aGate 2-MPPT 3-aHub
+// devType: 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub
 func QueryFaultRecordCallableList(devType int, sn string) ([]domain.FaultRecordCallableInfo, error) {
 	faultRecordCallableResp, err := queryFaultRecordCallableList(devType, sn)
 	if err != nil {

+ 38 - 0
service/repositories/api/fwh_test/fwh_deviceInfo.go

@@ -0,0 +1,38 @@
+package fwh_test
+
+import (
+	"fmt"
+	"net/url"
+
+	"fwh-toolbox-server/domain"
+)
+
+// QueryDeviceInfo 查询网关设备信息(/hes-gateway/manage/getDeviceInfo),用于获取 aPower 列表及各自的 PE 硬件版本
+func QueryDeviceInfo(gatewayId string) (*domain.FwhDeviceInfo, error) {
+	params := url.Values{}
+	params.Add("gatewayId", gatewayId)
+	params.Add("lang", "ZH_CN")
+	res, err := FwhHttpGetUnmarshal[domain.FwhDeviceInfo](getFullEnergyUrl("/hes-gateway/manage/getDeviceInfo"), params)
+	if err != nil {
+		return nil, err
+	}
+	if res.Code != 200 || res.Result == nil {
+		return nil, fmt.Errorf("[QueryDeviceInfo] 查询失败 code: %d, message: %s", res.Code, res.Message)
+	}
+	return res.Result, nil
+}
+
+// QueryGatewayPower 查询网关功率信息(/hes-gateway/manage/selectGatewayPower),用于获取 aHub(aPbox2.0) 列表
+func QueryGatewayPower(gatewayId string) (*domain.FwhGatewayPower, error) {
+	params := url.Values{}
+	params.Add("gatewayId", gatewayId)
+	params.Add("lang", "ZH_CN")
+	res, err := FwhHttpGetUnmarshal[domain.FwhGatewayPower](getFullEnergyUrl("/hes-gateway/manage/selectGatewayPower"), params)
+	if err != nil {
+		return nil, err
+	}
+	if res.Code != 200 || res.Result == nil {
+		return nil, fmt.Errorf("[QueryGatewayPower] 查询失败 code: %d, message: %s", res.Code, res.Message)
+	}
+	return res.Result, nil
+}

+ 1 - 1
service/repositories/api/fwh_test/fwh_faultRecord.go

@@ -86,7 +86,7 @@ func queryFaultRecordCallableList(devType int, sn string) (*domain.FwhResponse[[
 }
 
 // 查询设备可召唤数据列表 - 仅自动录播
-// devType: 0-aPower 1-aGate 2-MPPT 3-aHub
+// devType: 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub
 func QueryFaultRecordCallableList(devType int, sn string) ([]domain.FaultRecordCallableInfo, error) {
 	faultRecordCallableResp, err := queryFaultRecordCallableList(devType, sn)
 	if err != nil {

+ 84 - 0
service/services/faultRecordDevices_test.go

@@ -0,0 +1,84 @@
+package services
+
+import (
+	"encoding/json"
+	"os"
+	"strings"
+	"testing"
+
+	"fwh-toolbox-server/domain"
+)
+
+const deviceInfoHar = "../../docs/fault-record/设备信息.test.franklinwh.com.har"
+
+// 从 HAR 中取出指定接口的 result
+func harResult[T any](t *testing.T, api string) *T {
+	t.Helper()
+	b, err := os.ReadFile(deviceInfoHar)
+	if err != nil {
+		t.Skipf("har not found: %v", err)
+	}
+	var har struct {
+		Log struct {
+			Entries []struct {
+				Request  struct{ URL string } `json:"request"`
+				Response struct {
+					Content struct{ Text string } `json:"content"`
+				} `json:"response"`
+			} `json:"entries"`
+		} `json:"log"`
+	}
+	if err := json.Unmarshal(b, &har); err != nil {
+		t.Fatal(err)
+	}
+	for _, e := range har.Log.Entries {
+		if strings.Contains(e.Request.URL, "/manage/"+api) {
+			var res domain.FwhResponse[T]
+			if err := json.Unmarshal([]byte(e.Response.Content.Text), &res); err != nil {
+				t.Fatal(err)
+			}
+			return res.Result
+		}
+	}
+	t.Fatalf("%s not in har", api)
+	return nil
+}
+
+func TestBuildFaultRecordDevicesFromHar(t *testing.T) {
+	devInfo := harResult[domain.FwhDeviceInfo](t, "getDeviceInfo")
+	power := harResult[domain.FwhGatewayPower](t, "selectGatewayPower")
+
+	got := BuildFaultRecordDevices("10070052S00X25150083", devInfo, power)
+	var lines []string
+	for _, d := range got {
+		ver := -1
+		if d.HwVer != nil {
+			ver = *d.HwVer
+		}
+		lines = append(lines, d.Kind+"|"+d.Sn+"|"+d.Model+"|"+map[bool]string{true: "ok", false: "pending"}[d.Supported]+"|"+string(rune('0'+ver%10)))
+		t.Logf("%-10s %-22s %-9s type=%-5q supported=%v hwVer=%d", d.Kind, d.Sn, d.Model, d.Type, d.Supported, ver)
+	}
+	want := []string{
+		"aGate|10070052S00X25150083||ok",
+		"aPowerPE|10050015S00W24240002|aPower 2|ok",
+		"aPowerPE|10050019S01X25350643|aPower S|ok",
+		"aPowerMPPT|10050019S01X25350643|aPower S|ok", // 版本 21 支持 MPPT
+		"aHub|10070053S00X25270092||ok",
+	}
+	if len(got) != len(want) {
+		t.Fatalf("devices = %d, want %d", len(got), len(want))
+	}
+	for i, w := range want {
+		if !strings.HasPrefix(lines[i], w) {
+			t.Errorf("device %d = %s, want prefix %s", i, lines[i], w)
+		}
+	}
+}
+
+func TestBuildFaultRecordDevicesFallback(t *testing.T) {
+	// getDeviceInfo 失败时退回站点信息的 fhpSn:没有硬件版本,不生成 aPower-MPPT
+	got := BuildFaultRecordDevices("GW", &domain.FwhDeviceInfo{FhpSn: "A1, A2"}, nil)
+	if len(got) != 3 || got[1].Sn != "A1" || got[2].Sn != "A2" || got[2].HwVer != nil {
+		t.Fatalf("devices = %+v", got)
+	}
+}

+ 65 - 5
service/services/faultRecordServices.go

@@ -70,7 +70,6 @@ func GetFaultRecordSiteDevices(envStr, gatewaySn string) (*dto.FaultRecordSiteDe
 		Env:       string(env),
 		GatewaySn: site.Id,
 		Online:    site.Status == 1,
-		Devices:   []dto.FaultRecordDevice{{Type: proto.DeviceTypeAGate, Sn: site.Id}},
 	}
 	if site.Name != nil {
 		out.SiteName = *site.Name
@@ -78,14 +77,75 @@ func GetFaultRecordSiteDevices(envStr, gatewaySn string) (*dto.FaultRecordSiteDe
 	if site.OnLineTime != nil {
 		out.OnlineTime = *site.OnLineTime
 	}
-	for _, sn := range strings.Split(site.FhpSn, ",") {
-		if sn = strings.TrimSpace(sn); sn != "" {
-			out.Devices = append(out.Devices, dto.FaultRecordDevice{Type: proto.DeviceTypePE, Sn: sn}) // aPower SN 即 pe SN
-		}
+
+	// aPower 及 PE 硬件版本来自 getDeviceInfo;aHub 来自 selectGatewayPower
+	var devInfo *domain.FwhDeviceInfo
+	var power *domain.FwhGatewayPower
+	var devErr, powerErr error
+	if env == fwh_mqtt.EnvTest {
+		devInfo, devErr = fwh_test.QueryDeviceInfo(site.Id)
+		power, powerErr = fwh_test.QueryGatewayPower(site.Id)
+	} else {
+		devInfo, devErr = fwh_pro.QueryDeviceInfo(site.Id)
+		power, powerErr = fwh_pro.QueryGatewayPower(site.Id)
+	}
+	if devErr != nil {
+		out.Warnings = append(out.Warnings, "获取 aPower 硬件版本失败,无法判断 aPower-MPPT:"+devErr.Error())
+		devInfo = &domain.FwhDeviceInfo{FhpSn: site.FhpSn} // 退回站点信息中的 aPower 列表
 	}
+	if powerErr != nil {
+		out.Warnings = append(out.Warnings, "获取 aHub 列表失败:"+powerErr.Error())
+	}
+	out.Devices = BuildFaultRecordDevices(site.Id, devInfo, power)
 	return out, nil
 }
 
+// BuildFaultRecordDevices 生成可操作的设备列表:aGate/MCA、各 aPower-PE、支持 MPPT 的 aPower-MPPT、各 aHub。
+// peHwVerList、apowerList 与 fhpSn 中的 aPower 顺序一一对应;PE 硬件版本为 21、24 的 aPower 带 MPPT。
+func BuildFaultRecordDevices(gatewaySn string, devInfo *domain.FwhDeviceInfo, power *domain.FwhGatewayPower) []dto.FaultRecordDevice {
+	newDevice := func(kind, sn string) dto.FaultRecordDevice {
+		d := dto.FaultRecordDevice{Kind: kind, Type: proto.DeviceKindProtocolType[kind], Sn: sn}
+		d.Supported = d.Type != ""
+		if !d.Supported {
+			d.Note = "该设备类型暂不支持"
+		}
+		return d
+	}
+
+	devices := []dto.FaultRecordDevice{newDevice(proto.DeviceKindAGate, gatewaySn)}
+	if devInfo != nil {
+		i := 0
+		for _, sn := range strings.Split(devInfo.FhpSn, ",") {
+			if sn = strings.TrimSpace(sn); sn == "" {
+				continue
+			}
+			pe := newDevice(proto.DeviceKindAPowerPE, sn)
+			if i < len(devInfo.ApowerList) && devInfo.ApowerList[i].Id == sn {
+				pe.Model = devInfo.ApowerList[i].ModelName
+			}
+			if i < len(devInfo.PeHwVerList) {
+				ver := devInfo.PeHwVerList[i]
+				pe.HwVer = &ver
+			}
+			devices = append(devices, pe)
+			if pe.HwVer != nil && proto.SupportsMPPT(*pe.HwVer) {
+				mppt := newDevice(proto.DeviceKindAPowerMPPT, sn)
+				mppt.Model, mppt.HwVer = pe.Model, pe.HwVer
+				devices = append(devices, mppt)
+			}
+			i++
+		}
+	}
+	if power != nil {
+		for _, a := range power.Apbox20List {
+			if sn := strings.TrimSpace(a.Apbox20Sn); sn != "" {
+				devices = append(devices, newDevice(proto.DeviceKindAHub, sn))
+			}
+		}
+	}
+	return devices
+}
+
 /************** 创建/取消 **************/
 
 // CreateFaultRecordOperation 校验并创建操作,提交异步执行

+ 12 - 3
src/service/faultRecord/interface.ts

@@ -1,6 +1,7 @@
 export type FaultRecordEnv = 'prod' | 'test'
 export type FaultRecordKind = 'device_info' | 'record_fetch' | 'param_read' | 'param_write'
-export type FaultRecordDeviceType = 'aGate' | 'pe'
+/** 377 报文中的设备类型;dif 为 Digital Interface,前端暂未开放 */
+export type FaultRecordDeviceType = 'pe' | 'aGate' | 'mppt' | 'apbox' | 'dif'
 
 export type FaultRecordStatus =
   | 'pending'
@@ -41,9 +42,16 @@ export interface FaultRecordCreateReq {
   paraList?: FaultRecordParaItem[]
 }
 
+export type FaultRecordDeviceKind = 'aGate' | 'aPowerPE' | 'aPowerMPPT' | 'aHub'
+
 export interface FaultRecordDevice {
-  type: FaultRecordDeviceType
-  sn: string
+  kind: FaultRecordDeviceKind
+  type: FaultRecordDeviceType // 377 报文中的 type
+  sn: string // aPower-MPPT 与 aPower-PE 使用同一 SN
+  model?: string
+  hwVer?: number // aPower PE 硬件版本
+  supported: boolean
+  note?: string
 }
 
 export interface FaultRecordSiteDevices {
@@ -53,6 +61,7 @@ export interface FaultRecordSiteDevices {
   online: boolean
   onlineTime?: string
   devices: FaultRecordDevice[]
+  warnings?: string[]
 }
 
 export interface FaultRecordOpSummary {

+ 1 - 0
src/service/mqttDataManage/interface.ts

@@ -47,6 +47,7 @@ export interface MqttMessageItem {
   equipNo: string
   cmdType: number
   event?: Record<string, unknown>
+  docId: string // ES 文档唯一标识 "_index/_id"
 }
 
 // 逐条报文查询分页

+ 19 - 8
src/views/fault-record/common/DeviceSelector.vue

@@ -31,7 +31,10 @@
         {{ site.online ? '在线' : '离线' }}
       </NTag>
       <span v-if="!site.online && site.onlineTime">最后上线 {{ site.onlineTime }}</span>
-      <span>aPower × {{ site.devices.length - 1 }}</span>
+      <span>{{ deviceSummary }}</span>
+    </div>
+    <div v-if="site?.warnings?.length" class="text-xs text-amber-600 w-full">
+      <div v-for="(w, i) in site.warnings" :key="i">⚠ {{ w }}</div>
     </div>
   </div>
 </template>
@@ -47,7 +50,7 @@ import type {
   FaultRecordSiteDevices,
 } from '@/service/faultRecord/interface'
 
-import { ENV_LABEL, errText } from './constants'
+import { DEVICE_KIND_LABEL, ENV_LABEL, errText } from './constants'
 
 const props = defineProps<{ env: FaultRecordEnv }>()
 const gatewaySn = defineModel<string>('gatewaySn', { default: '' })
@@ -81,15 +84,23 @@ const recentOptions = computed(() =>
 )
 
 /** ************ 设备 **************/
-const deviceKey = computed(() => (device.value ? `${device.value.type}:${device.value.sn}` : null))
+// aPower-MPPT 与 aPower-PE 同 SN,按“种类:SN”区分
+const keyOf = (d: FaultRecordDevice) => `${d.kind}:${d.sn}`
+const deviceKey = computed(() => (device.value ? keyOf(device.value) : null))
 const deviceOptions = computed(() =>
   (site.value?.devices ?? []).map((d) => ({
-    label: d.type === 'aGate' ? `aGate  ${d.sn}` : `pe(aPower)  ${d.sn}`,
-    value: `${d.type}:${d.sn}`,
+    label: `${DEVICE_KIND_LABEL[d.kind] ?? d.kind}  ${d.sn}${d.model ? `(${d.model})` : ''}${d.supported ? '' : `  - ${d.note ?? '暂不可用'}`}`,
+    value: keyOf(d),
+    disabled: !d.supported,
   })),
 )
+const deviceSummary = computed(() => {
+  const count = (kind: string) => (site.value?.devices ?? []).filter((d) => d.kind === kind).length
+  return `aPower × ${count('aPowerPE')},aPower-MPPT × ${count('aPowerMPPT')},aHub × ${count('aHub')}`
+})
+
 function selectDevice(key: string) {
-  device.value = site.value?.devices.find((d) => `${d.type}:${d.sn}` === key)
+  device.value = site.value?.devices.find((d) => keyOf(d) === key)
 }
 
 /** 选择历史项时,select 事件早于输入框值的更新,需直接使用选中值 */
@@ -111,8 +122,8 @@ async function query() {
     site.value = await getFaultRecordDevices(props.env, sn)
     gatewaySn.value = site.value.gatewaySn
     // 保留已选设备(如从参数读取页带入),否则默认 aGate
-    const keep = site.value.devices.find((d) => d.type === device.value?.type && d.sn === device.value?.sn)
-    device.value = keep ?? site.value.devices[0]
+    const keep = site.value.devices.find((d) => d.supported && device.value && keyOf(d) === keyOf(device.value))
+    device.value = keep ?? site.value.devices.find((d) => d.supported)
     saveRecent(site.value.gatewaySn)
   } catch (e) {
     site.value = undefined

+ 2 - 2
src/views/fault-record/common/OperationHistory.vue

@@ -31,7 +31,7 @@ import type {
   FaultRecordOpSummary,
 } from '@/service/faultRecord/interface'
 
-import { KIND_LABEL, STATUS_META, errText, fmtTime } from './constants'
+import { KIND_LABEL, STATUS_META, deviceTypeLabel, errText, fmtTime } from './constants'
 
 const props = defineProps<{
   env: FaultRecordEnv
@@ -67,7 +67,7 @@ const columns: DataTableColumns<FaultRecordOpSummary> = [
   { title: '时间', key: 'createdAt', width: 160, render: (r) => fmtTime(r.createdAt) },
   { title: '操作人', key: 'operator', width: 110, ellipsis: { tooltip: true } },
   { title: '网关', key: 'gatewaySn', width: 180, ellipsis: { tooltip: true } },
-  { title: '设备', key: 'deviceSn', width: 200, render: (r) => `${r.deviceType} ${r.deviceSn}` },
+  { title: '设备', key: 'deviceSn', width: 200, render: (r) => `${deviceTypeLabel(r.deviceType)} ${r.deviceSn}` },
   { title: '操作', key: 'kind', width: 90, render: (r) => KIND_LABEL[r.kind] },
   { title: '参数', key: 'summary', ellipsis: { tooltip: true } },
   {

+ 2 - 2
src/views/fault-record/common/OperationProgress.vue

@@ -3,7 +3,7 @@
     <div class="flex flex-wrap gap-2 items-center">
       <NTag :type="STATUS_META[detail.status].type">{{ STATUS_META[detail.status].label }}</NTag>
       <span class="text-sm text-gray-500">
-        {{ KIND_LABEL[detail.kind] }} · {{ detail.deviceType }} {{ detail.deviceSn }} · {{ detail.summary }}
+        {{ KIND_LABEL[detail.kind] }} · {{ deviceTypeLabel(detail.deviceType) }} {{ detail.deviceSn }} · {{ detail.summary }}
         · {{ detail.operator }} · {{ fmtTime(detail.createdAt) }}
       </span>
       <div class="flex-1" />
@@ -91,7 +91,7 @@ import {
 
 import type { FaultRecordOpDetail } from '@/service/faultRecord/interface'
 
-import { KIND_LABEL, KIND_STAGES, STATUS_META, errText, fmtTime } from './constants'
+import { KIND_LABEL, KIND_STAGES, STATUS_META, deviceTypeLabel, errText, fmtTime } from './constants'
 
 const props = defineProps<{
   detail?: FaultRecordOpDetail

+ 21 - 0
src/views/fault-record/common/constants.ts

@@ -11,6 +11,27 @@ export const ENV_LABEL: Record<FaultRecordEnv, string> = {
   test: '测试服',
 }
 
+/**
+ * 设备类型显示名(对应平台设备类型 0-aPower-PE 1-aGate/MCA 2-aPower-MPPT 3-aHub);
+ * 键为 MQTT 协议中的 type 值,协议值不变
+ */
+export const DEVICE_TYPE_LABEL: Record<string, string> = {
+  aGate: 'aGate/MCA',
+  pe: 'aPower-PE',
+  mppt: 'aPower-MPPT',
+  apbox: 'aHub',
+  dif: 'DIF',
+}
+export const deviceTypeLabel = (t?: string) => (t ? (DEVICE_TYPE_LABEL[t] ?? t) : '')
+
+/** 设备种类显示名(设备选择时使用;aPower-MPPT 与 aPower-PE 同 SN,需按种类区分) */
+export const DEVICE_KIND_LABEL: Record<string, string> = {
+  aGate: 'aGate/MCA',
+  aPowerPE: 'aPower-PE',
+  aPowerMPPT: 'aPower-MPPT',
+  aHub: 'aHub',
+}
+
 export const KIND_LABEL: Record<FaultRecordKind, string> = {
   device_info: '设备信息',
   record_fetch: '录波获取',

+ 2 - 1
src/views/fault-record/function/index.vue

@@ -82,7 +82,7 @@
       <div v-if="pendingRun" class="flex flex-col gap-2">
         <div>
           环境:<NTag :type="env === 'prod' ? 'error' : 'info'" size="small">{{ ENV_LABEL[env] }}</NTag>
-          网关:{{ gatewaySn }} 设备:{{ device?.type }} {{ device?.sn }}
+          网关:{{ gatewaySn }} 设备:{{ device ? DEVICE_KIND_LABEL[device.kind] : '' }} {{ device?.sn }}
         </div>
         <div :class="danger ? 'text-red-500' : ''">
           {{ pendingRun.id }} {{ functionName(pendingRun.id) }} {{ pendingRunDesc }}
@@ -132,6 +132,7 @@ import {
   encodeDeviceTime,
   errText,
   functionName,
+  DEVICE_KIND_LABEL,
 } from '../common/constants'
 import { useFaultRecordEnv, useOperation } from '../common/useFaultRecord'
 

+ 2 - 1
src/views/fault-record/param-write/index.vue

@@ -146,7 +146,7 @@
       <div class="flex flex-col gap-2">
         <div>
           环境:<NTag :type="env === 'prod' ? 'error' : 'info'" size="small">{{ ENV_LABEL[env] }}</NTag>
-          网关:{{ gatewaySn }} 设备:{{ device?.type }} {{ device?.sn }}
+          网关:{{ gatewaySn }} 设备:{{ device ? DEVICE_KIND_LABEL[device.kind] : '' }} {{ device?.sn }}
         </div>
         <div v-for="p in pending" :key="p.key" class="flex gap-2">
           <span class="w-6">{{ p.id }}</span>
@@ -203,6 +203,7 @@ import {
   functionName,
   hex,
   isHex,
+  DEVICE_KIND_LABEL,
 } from '../common/constants'
 import { useFaultRecordEnv, useOperation } from '../common/useFaultRecord'
 

+ 2 - 1
src/views/mqtt-data-manage/list/index.vue

@@ -138,7 +138,8 @@ const realTotal = ref(0)
 // 是否被窗口截断(真实总数超过可翻页范围)
 const isTruncated = computed(() => realTotal.value > MAX_WINDOW)
 
-const tableRowKey = (row: MqttMessageItem) => `${row.equipNo}-${row.timeStamp}-${row.cmdType}`
+// 同设备同秒同 cmdType 可能有多条报文,需用 ES 文档 ID 作为唯一 key
+const tableRowKey = (row: MqttMessageItem) => row.docId
 
 // 时区统一 UTC。
 // 记录展示:timeStamp(秒) 为 UTC 绝对时刻,用 UTC getter 显示其 UTC 墙钟时间。