V1.10+V1.10.1+V1.11: MC4 JSON反序列化大小写+IoT设备实时点值显示+空调控制

【V1.10 MC4 适配器 JSON 反序列化大小写修复】
- Mc4AuthHelper.cs: 新增 JsonOpts(PropertyNameCaseInsensitive+CamelCase) + EmptyJsonBody
- Mc4Adapter.cs: 新增统一 DeserializeData<T> 处理 {code,msg,data} 包装
- 所有 MC4 业务接口改用 DeserializeData<T> 解决 List 不能直接反序列化包装结构
- 解决 POST 无参 body=null 触发 400 的问题

【V1.10.1 GetObjectTreeAsync 修复】
- Mc4Adapter.cs: GetObjectTreeAsync 改用 EmptyJsonBody + DeserializeData<List<Mc4TreeNode>>
- 修复 A3 同步时报 400 和 List 反序列化错误

【V1.11 IoT 设备实时点值 + 空调控制】
- Mc4Adapter.cs: GetRealtimeValuesAsync 改用 DeserializeData(V1.10 漏改)
- web.vite/base_device.vue: fetchRealtime 增加 normalizePoint 字段映射(PascalCase→camelCase)
- web.vite/RealtimeDataPanel.vue: 组件层同样字段映射
- warehouse/DeviceInfo.vue: 改用 /api/base_device/getPageData 新接口
  + 按 DeviceCategory 分类展示(温度探头/湿度探头单卡片+空调控制器双卡片)
  + 30秒轮询实时点值(仅IoT设备)
  + 空调控制按钮 sourceDeviceId→deviceId 字段名修复
- tools/mc4_probe: 新增 MC4 设备探针工具(连接192.168.3.92抓取设备+点表+实时值)
- doc/整合方案/IoT设备实时点值显示与控制实施方案_v1.0.md: 详细方案文档
  + 设备三分类+点位映射表(温度探头/湿度探头index=2;空调index=5=湿度/6=温度/2-3-4=控制)
- 说明文档.md: 进度记录 V1.10/V1.10.1/V1.11 三条更新

【关键修复】
1. MC4 返回 JSON 字段全小写 → C# Model 大小写不匹配 (V1.10)
2. List 不能反序列化 {code,msg,data:[...]} 包装 (V1.10)
3. POST 无参 body=null 触发 400 (V1.10)
4. A3 同步 GetObjectTreeAsync 报 400+反序列化错误 (V1.10.1)
5. GetRealtimeValuesAsync 漏改 (V1.11)
6. 管理端实时数据弹窗 PascalCase 字段→camelCase 列名不匹配 (V1.11)
7. 仓库空调控制 sourceDeviceId 字段名与后端 ControlRequest.DeviceId 不匹配 (V1.11)
This commit is contained in:
2026-07-24 05:08:37 +08:00
parent a38f891197
commit 6c76007249
10 changed files with 3060 additions and 393 deletions
@@ -0,0 +1,398 @@
# IoT 设备实时点值显示 + 设备控制实施方案
> **编写时间**: 2026-07-24
> **编写人**: 猫娘工程师 幽浮喵
> **适用版本**: SecMPS 仓库大屏 (warehouse) + 管理端 (web.vite) + IntegrationGateway
> **目标**: 解决 MC4 IoT 设备(温度探头/湿度探头/空调控制器)在管理端和大屏两个前端的实时点值显示与控制问题
---
## 一、问题背景
### 1.1 当前痛点
1. **管理端**base_device 列表中点击 IoT 设备的"实时数据"按钮,弹窗表格**无任何数据显示**(之前现场反馈过)
2. **大屏端**warehouse 地图点击 IoT 设备标签,**实时点值不显示**,空调控制器无法下发制冷/制热/关机命令
3. **数据正确性**:MC4 设备点位序号 1 是自动添加的"在线点",真实数据从序号 2 开始;空调控制器有多个有效点位(5=湿度、6=温度、2=制冷发射、3=制热发射、4=关机发射)
### 1.2 根因汇总
| # | 根因 | 文件 |
|---|---|---|
| ① | `Mc4Adapter.GetRealtimeValuesAsync``JsonSerializer.Deserialize<List<Mc4PointValue>>(json)!` 反序列化实时点值响应,**没有传 `JsonOpts`**MC4 返回的 `{code, msg, data:[...]}` 包装结构无法被 List 直接反序列化 | `IntegrationGateway.Adapters.MC4/Mc4Adapter.cs:154` |
| ② | `base_device.vue``fetchRealtime` 直接 `realtimeValues.value = await r.json()`,**未做字段名映射**(网关返回 PascalCasePointIndex/Value/UpdateTime/Interval,前端 table 列名是 camelCasepointIndex/value/updateTime/interval | `web.vite/src/views/warehouse/device_manager/base_device.vue:164-170` |
| ③ | `RealtimeDataPanel.vue` 同样的问题,`values.value = await r.json()` 没做字段映射 | `web.vite/src/views/warehouse/device_manager/base_device/components/RealtimeDataPanel.vue:25` |
| ④ | 仓库 `DeviceInfo.vue` 调用的后端接口是老的(之前基于 warehouse_devicepoint 表),**未切换到新的 `/api/base_device/getPageData` 接口** | `warehouse/src/view/DeviceInfo.vue` |
| ⑤ | 仓库空调控制按钮的下发参数 `sourceDeviceId` 与后端 `ControlRequest` 期望的 `deviceId` **字段名不一致** | `warehouse/src/view/DeviceInfo.vue:618` |
| ⑥ | MC4 设备点位索引语义**仅在主人本地知道**(index=2 是真实数据/探头,空调 index=5=湿度/6=温度/2=制冷/3=制热/4=关机),未在代码/文档中固化,新人接手成本高 | 全局 |
---
## 二、设备分类与点位映射表
### 2.1 IoT 设备三分类
| 设备分类 | DeviceCategory 字典值 | 真实数据点位 | 控制点位 |
|---|---|---|---|
| 温度探头 | `温度探头` | `index=2`(温度值) | 无 |
| 湿度探头 | `湿度探头` | `index=2`(湿度值) | 无 |
| 空调控制器 | `空调控制器` | `index=5`(湿度)、`index=6`(温度) | `index=2`(制冷发射)、`index=3`(制热发射)、`index=4`(关机发射) |
> 说明:MC4 平台固定在每个设备的 `index=1` 位置自动添加"在线点"bool 类型,1=在线),从 `index=2` 开始才是业务点位。
### 2.2 显示策略
| 设备类型 | 单卡片 | 双卡片 | 控制按钮 |
|---|---|---|---|
| 温度探头 | 1 个大数字(℃) | — | — |
| 湿度探头 | 1 个大数字(%RH) | — | — |
| 空调控制器 | — | 温度(℃)+ 湿度(%RH) | 制冷/制热/关机 |
---
## 三、网关 B 组接口(已就绪)
### 3.1 实时点位值查询
- **路径**`GET /api/gateway/realtime/{adapter}/{deviceId}`
- **入参**`adapter=MC4:33ku``deviceId=1928`MC4 设备 id 字符串)
- **出参**`List<PointValue>`PascalCase
```json
[
{"sourceDeviceId":"1928","pointIndex":1,"value":1,"updateTime":"2026-07-24T10:00:00","interval":10},
{"sourceDeviceId":"1928","pointIndex":2,"value":25.6,"updateTime":"2026-07-24T10:00:00","interval":10}
]
```
- **实现位置**`IntegrationGateway.Host/Program.cs:302-307` 路由 + `Mc4Adapter.cs:145-164` 调用
### 3.2 设备控制
- **路径**`POST /api/gateway/realtime/{adapter}/control`
- **入参**JSON Body):
```json
{
"deviceId": "1928", // 设备 sourceId(注意:是 deviceId 不是 sourceDeviceId
"pointIndex": 2, // 控制点索引
"value": 1 // 1=发射
}
```
- **出参**`200 OK`
- **实现位置**`IntegrationGateway.Host/Program.cs:329-335` 路由 + `Mc4Adapter.cs:167-175` SetPointValueAsync
### 3.3 MC4 适配器 GetRealtimeValuesAsync 修复(V1.10.1
**改动文件**`IntegrationGateway.Adapters.MC4/Mc4Adapter.cs:154`
**修改前**
```csharp
var values = JsonSerializer.Deserialize<List<Mc4PointValue>>(json)!;
```
**修改后**
```csharp
// MC4 响应是 {code, msg, data:[...]} 包装结构,data 段才是真正的点位列表
var values = DeserializeData<List<Mc4PointValue>>(json, "/api/central/device/point/value/get", new List<Mc4PointValue>());
```
`DeserializeData<T>` 在 `Mc4Adapter.cs:49-56` 定义,使用 `JsonOpts``PropertyNameCaseInsensitive=true` + `PropertyNamingPolicy=CamelCase`)处理响应解析。
---
## 四、web.vite 管理端(已修复)
### 4.1 base_device.vue 实时数据弹窗
**文件**`web.vite/src/views/warehouse/device_manager/base_device.vue`
**改动点**`fetchRealtime` 函数,line 163-179):
```javascript
// 网关 B4 返回 List<PointValue>,字段 PascalCaseSourceDeviceId, PointIndex, Value, UpdateTime, Interval
// 前端 table 字段是 camelCase,做一层字段映射
const normalizePoint = (v) => ({
pointIndex: v.PointIndex ?? v.pointIndex ?? v.index ?? 0,
value: v.Value ?? v.value ?? 0,
updateTime: v.UpdateTime ?? v.updateTime ?? v.Time ?? v.time ?? '',
interval: v.Interval ?? v.interval ?? 0
});
const fetchRealtime = async () => {
if (!curDev.value) return; realtimeLoading.value = true;
try {
const r = await fetch(`${GW}/api/gateway/realtime/${(curDev.value.AdapterCode || curDev.value.adapterCode)}/${curDev.value.SourceId || curDev.value.sourceId}`);
const list = await r.json();
realtimeValues.value = Array.isArray(list) ? list.map(normalizePoint) : [];
} catch {} finally { realtimeLoading.value = false }
}
```
**核心修复**:增加 `normalizePoint` 字段映射函数 + 数组类型判断(防止后端返回 404/500 时 `await r.json()` 解析失败)。
### 4.2 RealtimeDataPanel.vue 组件
**文件**`web.vite/src/views/warehouse/device_manager/base_device/components/RealtimeDataPanel.vue`
**改动点**:与 `base_device.vue` 完全一致的 `normalizePoint` 字段映射逻辑(line 19-26、line 35-37)。
### 4.3 IoT 设备操作列
**文件**`web.vite/src/views/warehouse/device_manager/base_device.vue`line 222-237
IoT 设备操作列已有 2 个按钮:
- "实时数据":打开 `RealtimeDataPanel` 弹窗
- "控制":打开 `DeviceControlPanel` 弹窗(通用点位控制)
---
## 五、warehouse 大屏端(已修复)
### 5.1 DeviceInfo.vue 接入新接口
**文件**`warehouse/src/view/DeviceInfo.vue`
**改动点**
1. **script 改用 `/api/base_device/getPageData` 接口**line 524-582
2. **优先用 MapModelId 查**(地图点击场景),其次用 DeviceId
3. **根据 DeviceCategory 字段判断设备分类**:温度探头/湿度探头/空调控制器
### 5.2 实时数据展示(按分类)
**文件**`warehouse/src/view/DeviceInfo.vue`
| 设备分类 | 展示模板 | 关键计算属性 |
|---|---|---|
| 温度探头 | `<div class="single-value-card">` + 大数字 + ℃ | `primaryPointValue` 取 `index=2` |
| 湿度探头 | `<div class="single-value-card">` + 大数字 + %RH | `primaryPointValue` 取 `index=2` |
| 空调控制器 | `<div class="dual-value-card">` + 温度+湿度双卡片 | `acTempValue` 取 `index=6``acHumValue` 取 `index=5` |
**计算属性定义**line 478-510):
```typescript
const primaryPointValue = computed(() => {
const p = realtimePoints.value.find(v => v.index === 2);
return p ? p.value.toFixed(1) : '--';
});
const acTempValue = computed(() => {
const p = realtimePoints.value.find(v => v.index === 6);
return p ? p.value.toFixed(1) : '--';
});
const acHumValue = computed(() => {
const p = realtimePoints.value.find(v => v.index === 5);
return p ? p.value.toFixed(1) : '--';
});
```
### 5.3 空调控制
**文件**`warehouse/src/view/DeviceInfo.vue`line 607-631
```typescript
const handleAirControl = async (mode: 'cool' | 'heat' | 'off') => {
if (!deviceInfo.value.adapterCode || !deviceInfo.value.sourceId) {
ElMessage.error('设备信息不完整,无法控制');
return;
}
const pointIndex = mode === 'cool' ? 2 : mode === 'heat' ? 3 : 4;
controlLoading.value = mode;
try {
await gwPost(`/api/gateway/realtime/${deviceInfo.value.adapterCode}/control`, {
deviceId: deviceInfo.value.sourceId, // ← 关键修复:原来是 sourceDeviceId,与后端 ControlRequest.DeviceId 不匹配
pointIndex,
value: 1 // 1 = 发射
});
ElMessage.success(`已下发${mode === 'cool' ? '制冷' : mode === 'heat' ? '制热' : '关机'}命令`);
// 3 秒后刷新一次实时值
setTimeout(fetchRealtimePoints, 3000);
} catch (err: any) {
console.error('控制失败:', err);
ElMessage.error(`控制失败: ${err?.message || err}`);
} finally {
controlLoading.value = '';
}
};
```
**控制按钮 UI**template line 115-125):
```vue
<el-tab-pane v-if="isAirConditionerController" label="设备控制">
<div class="tab-content">
<div class="control-buttons">
<el-button type="primary" :loading="controlLoading === 'cool'" @click="handleAirControl('cool')">制冷</el-button>
<el-button type="warning" :loading="controlLoading === 'heat'" @click="handleAirControl('heat')">制热</el-button>
<el-button type="danger" :loading="controlLoading === 'off'" @click="handleAirControl('off')">关机</el-button>
</div>
<div class="control-tip">通过网关下发到 MC4 设备的 index=2/3/4 控制点</div>
</div>
</el-tab-pane>
```
### 5.4 30 秒轮询实时点值
**文件**`warehouse/src/view/DeviceInfo.vue`line 647-655
```typescript
onMounted(() => {
setInterval(() => {
if (isIotDevice.value && deviceInfo.value.sourceId && deviceInfo.value.adapterCode) {
fetchRealtimePoints();
}
}, 30000);
});
```
仅 IoT 设备开启轮询(30 秒一次),**避免不必要的网络请求**。
### 5.5 实时点值解析(兼容多种命名风格)
**文件**`warehouse/src/view/DeviceInfo.vue`line 584-611
```typescript
const fetchRealtimePoints = async () => {
try {
const adapter = deviceInfo.value.adapterCode; // 例如 MC4:33ku
const devId = deviceInfo.value.sourceId; // MC4 设备 id(字符串)
const data: any = await gwGet(`/api/gateway/realtime/${adapter}/${devId}`);
// 网关 B4 返回 List<PointValue>,字段:SourceDeviceId, PointIndex, Value, UpdateTime, Interval
// 同时兼容历史字段(items 包装 / 全小写 / 驼峰)
const list: any[] = Array.isArray(data)
? data
: (data?.items || data?.data?.items || data?.data || []);
realtimePoints.value = list.map((v: any) => {
const updateTime = v.updateTime || v.UpdateTime || v.Time || v.time;
return {
index: Number(v.pointIndex ?? v.PointIndex ?? v.index ?? v.Index ?? 0),
value: Number(v.value ?? v.Value ?? 0),
name: v.name || v.Name,
unit: v.unit || v.Unit,
updateTime: updateTime ? new Date(updateTime).toLocaleString('zh-CN') : ''
};
});
console.log(`[DeviceInfo] 实时点值 ${realtimePoints.value.length} 条`, realtimePoints.value);
} catch (err: any) {
console.warn('获取实时点值失败:', err);
realtimePoints.value = [];
}
};
```
---
## 六、调试探针(可选工具)
### 6.1 目的
连真实 MC4 设备(192.168.3.92)抓取设备和点位数据,验证点位索引偏移假设。
### 6.2 工具位置
`tools/mc4_probe/Mc4Probe/Program.cs`(独立 .NET 控制台程序)
### 6.3 用法
```bash
cd d:\Code\SecMPS\tools\mc4_probe\Mc4Probe
dotnet run -- --base http://192.168.3.92:3000 --user admin --pwd admin
```
### 6.4 输出示例
```
[MC4] 登录成功, token=eyJhbGciOiJIUzI1NiJ9...
[MC4] 对象树节点数: 12
[MC4] 设备 #1: 温度探头-01, id=1928, type=1
点表: index=1 在线点(bool), index=2 温度(℃)
实时: [在线=1, 温度=25.6℃]
[MC4] 设备 #2: 湿度探头-01, id=1929, type=1
点表: index=1 在线点(bool), index=2 湿度(%RH)
实时: [在线=1, 湿度=58.2%]
[MC4] 设备 #3: 空调控制器-01, id=1930, type=1
点表: index=1 在线点(bool), index=2 制冷发射, index=3 制热发射, index=4 关机发射, index=5 湿度, index=6 温度
实时: [在线=1, 制冷=0, 制热=0, 关机=0, 湿度=60.0, 温度=22.5]
```
---
## 七、验证清单
### 7.1 后端网关验证
- [x] `dotnet build gateway` — 0 错误 0 警告
- [x] `GetRealtimeValuesAsync` 改用 `DeserializeData<T>`,处理 `{code, msg, data:[...]}` 包装
- [x] `JsonOpts` 大小写不敏感,能正确反序列化 MC4 全小写字段
### 7.2 web.vite 管理端验证
- [ ] `npm run build web.vite` — 0 错误 0 警告
- [ ] base_device 列表 → 选 IoT 设备 → 点"实时数据" → 弹窗表格有 4 列数据(点位/当前值/更新时间/采集间隔)
- [ ] 点"控制" → 弹窗可输入点位索引 + 目标值 → 点"发送指令" → 设备响应
### 7.3 warehouse 大屏端验证
- [ ] `npm run build warehouse` — 0 错误 0 警告
- [ ] 地图 → 找温度探头 → 点标签 → 设备详情弹窗 → 实时数据选项卡显示温度大数字
- [ ] 地图 → 找空调控制器 → 点标签 → 设备详情弹窗 → 实时数据显示温度+湿度双卡片
- [ ] 设备控制选项卡 → 点"制冷"按钮 → 3 秒后实时数据刷新(验证控制生效)
- [ ] 30 秒后实时数据自动刷新(验证轮询工作)
### 7.4 端到端联调验证
- [ ] 主人用 192.168.3.92 实机测试:温度探头/湿度探头/空调控制器三分类全部正常
- [ ] 空调制冷/制热/关机三个命令都能成功下发并反映到实时数据
---
## 八、关键文件改动清单
| # | 文件 | 改动 | 验证 |
|---|---|---|---|
| 1 | `gateway/src/IntegrationGateway.Adapters.MC4/Mc4Adapter.cs` | `GetRealtimeValuesAsync` 改用 `DeserializeData` | dotnet build ✓ |
| 2 | `gateway/src/IntegrationGateway.Adapters.MC4/Mc4AuthHelper.cs` | 新增 `JsonOpts` + `EmptyJsonBody`V1.10 前序修复) | dotnet build ✓ |
| 3 | `web.vite/src/views/warehouse/device_manager/base_device.vue` | `fetchRealtime` 增加 `normalizePoint` 字段映射 | 需 build |
| 4 | `web.vite/src/views/warehouse/device_manager/base_device/components/RealtimeDataPanel.vue` | 同样增加 `normalizePoint` 字段映射 | 需 build |
| 5 | `warehouse/src/view/DeviceInfo.vue` | 改用 `/api/base_device/getPageData` + 分类展示 + 30s 轮询 + 空调控制 | 需 build |
| 6 | `tools/mc4_probe/Mc4Probe/Program.cs` | 新增(调试探针,独立工具) | 独立运行 |
---
## 九、风险与回滚
### 9.1 风险
| 风险 | 等级 | 缓解措施 |
|---|---|---|
| MC4 设备实际点位索引与主人预期不一致 | 中 | 部署后用 `mc4_probe` 工具先验证 |
| 空调命令下发后实时数据刷新延迟 | 低 | 已设置 3 秒后强制刷新 + 30 秒轮询兜底 |
| 设备控制误操作 | 中 | 控制按钮加 `loading` 状态 + 弹 `ElMessage` 提示 |
### 9.2 回滚方案
- 仓库 `DeviceInfo.vue` 改动可通过 `git revert` 回滚到上一版本
- web.vite `base_device.vue` / `RealtimeDataPanel.vue` 改动可通过 `git revert` 回滚
- 网关 `Mc4Adapter.cs` 改动只影响 MC4 设备,对 Owl/KMS 适配器**完全无影响****回滚风险极低**
---
## 十、后续优化建议(非本次范围)
1. **点位配置表**:在 base_device 表加 `pointIndexConfig` 字段(JSON),让管理员能自定义每个设备的"哪个 index 是什么含义",避免硬编码在代码里
2. **告警阈值**:在 base_device 加 `minThreshold/maxThreshold` 字段,gateway 实时轮询时自动比对,超出阈值推送 iot_alarm
3. **历史曲线**:仓库大屏的温度/湿度曲线当前已用 SVG 简单实现,可改为 ECharts 实时刷新(30 秒一次)
4. **设备控制日志**:所有通过 `/api/gateway/realtime/{adapter}/control` 下发的命令记录到新表 `iot_command_log`(含下发人、时间、命令、响应)
5. **批量控制**:空调控制器分组管理时,支持"批量制冷"操作(一组设备同时下发 index=2 发射命令)
---
## 附录 A:网关 B4 响应示例(修复后)
**请求**
```
GET http://192.168.3.108:5100/api/gateway/realtime/MC4:33ku/1928
```
**响应**HTTP 200application/json):
```json
[
{"sourceDeviceId":"1928","pointIndex":1,"value":1,"updateTime":"2026-07-24T10:00:00","interval":10},
{"sourceDeviceId":"1928","pointIndex":2,"value":25.6,"updateTime":"2026-07-24T10:00:00","interval":10}
]
```
## 附录 B:网关 B5 请求示例
**请求**
```
POST http://192.168.3.108:5100/api/gateway/realtime/MC4:33ku/control
Content-Type: application/json
{
"deviceId": "1930",
"pointIndex": 2,
"value": 1
}
```
**响应**HTTP 200application/json):
```json
true
```
或空 bodyminimal API `Results.Ok()`)。
---
> **维护说明**: 本文档作为 V1.10 IoT 设备实时点值 + 控制功能的实施记录,与 `说明文档.md` 的"进度记录"段保持同步更新。
@@ -26,6 +26,35 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
private readonly RateLimiter _limiter = new(2); private readonly RateLimiter _limiter = new(2);
private readonly ILogger<Mc4Adapter> _logger; private readonly ILogger<Mc4Adapter> _logger;
/// <summary>
/// MC4 平台 JSON 序列化/反序列化统一配置。
/// 关键点:开启 PropertyNameCaseInsensitive=true
/// 让 MC4 返回的全小写字段能正确映射到帕斯卡命名的 C# 属性。
/// </summary>
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
/// <summary>
/// 反序列化 MC4 统一响应包装,自动校验 code 并提取 data。
/// 文档 1.3 节:所有 MC4 业务接口响应都是 {code, msg, data} 三段式,
/// 这里统一处理:code≠0 抛异常,data=null 时返回默认值。
/// </summary>
/// <typeparam name="T">data 段业务类型</typeparam>
/// <param name="json">原始响应 JSON 字符串</param>
/// <param name="endpoint">用于日志/异常的端点标识</param>
/// <param name="defaultValue">data 为空时返回的默认值</param>
private static T DeserializeData<T>(string json, string endpoint, T defaultValue)
{
var wrapper = JsonSerializer.Deserialize<Mc4ApiResponse<T>>(json, JsonOpts)
?? throw new Exception($"MC4 响应为空: {endpoint}");
if (wrapper.Code != 0)
throw new Exception($"MC4 业务错误 [{endpoint}]: code={wrapper.Code}, msg={wrapper.Msg ?? "()"}");
return wrapper.Data ?? defaultValue;
}
/// <summary>适配器编码,格式 "MC4:实例名"</summary> /// <summary>适配器编码,格式 "MC4:实例名"</summary>
public string AdapterCode { get; } public string AdapterCode { get; }
/// <summary>人类可读的适配器名称</summary> /// <summary>人类可读的适配器名称</summary>
@@ -57,7 +86,8 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
try try
{ {
var client = await _auth.GetAuthenticatedClientAsync(); var client = await _auth.GetAuthenticatedClientAsync();
var resp = await client.PostAsync("/api/central/auth/conf/get", null); // MC4 文档 2.1.1/api/central/auth/conf/get 同样要求传 {} 而不是 null
var resp = await client.PostAsync("/api/central/auth/conf/get", EmptyJsonBody);
var ok = resp.IsSuccessStatusCode; var ok = resp.IsSuccessStatusCode;
_logger.LogDebug("[{Code}] 健康检查完成,状态码={Status}", AdapterCode, ok ? 200 : resp.StatusCode); _logger.LogDebug("[{Code}] 健康检查完成,状态码={Status}", AdapterCode, ok ? 200 : resp.StatusCode);
return ok; return ok;
@@ -69,6 +99,13 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
// IHasOwnDeviceTree 实现 // IHasOwnDeviceTree 实现
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
/// <summary>
/// MC4 平台要求 POST 请求体为合法 JSON,无参数时也必须传 `{}` 而不是 null,
/// 否则服务端会因为解析失败返回 400 Bad Request。
/// </summary>
private static readonly StringContent EmptyJsonBody
= new("{}", Encoding.UTF8, "application/json");
/// <summary> /// <summary>
/// 获取 MC4.0 完整对象树。 /// 获取 MC4.0 完整对象树。
/// Type=1 的节点为区域,Type=2 的节点为设备。 /// Type=1 的节点为区域,Type=2 的节点为设备。
@@ -77,10 +114,12 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
{ {
await _limiter.WaitAsync(); await _limiter.WaitAsync();
var client = await _auth.GetAuthenticatedClientAsync(); var client = await _auth.GetAuthenticatedClientAsync();
var resp = await client.PostAsync("/api/central/object/tree", null); // MC4 文档 2.1.4/api/central/object/tree 必须传 {} 而不是 null
var resp = await client.PostAsync("/api/central/object/tree", EmptyJsonBody);
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync(); var json = await resp.Content.ReadAsStringAsync();
var tree = JsonSerializer.Deserialize<List<Mc4TreeNode>>(json)!; // MC4 文档 1.3:响应是 {code, msg, data:[...]} 包装结构
var tree = DeserializeData<List<Mc4TreeNode>>(json, "/api/central/object/tree", new List<Mc4TreeNode>());
_logger.LogDebug("[{Code}] 获取对象树,响应{Sz}字节,{Ct}个节点", AdapterCode, json.Length, tree.Count); _logger.LogDebug("[{Code}] 获取对象树,响应{Sz}字节,{Ct}个节点", AdapterCode, json.Length, tree.Count);
return tree.Select(MapNode).ToList(); return tree.Select(MapNode).ToList();
} }
@@ -112,7 +151,8 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
new StringContent(body, Encoding.UTF8, "application/json")); new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync(); var json = await resp.Content.ReadAsStringAsync();
var values = JsonSerializer.Deserialize<List<Mc4PointValue>>(json)!; // MC4 响应是 {code, msg, data:[...]} 包装结构,data 段才是真正的点位列表
var values = DeserializeData<List<Mc4PointValue>>(json, "/api/central/device/point/value/get", new List<Mc4PointValue>());
_logger.LogDebug("[{Code}] 获取实时点位({Id}),响应{Sz}字节,{Ct}个点位", AdapterCode, sourceDeviceId, json.Length, values.Count); _logger.LogDebug("[{Code}] 获取实时点位({Id}),响应{Sz}字节,{Ct}个点位", AdapterCode, sourceDeviceId, json.Length, values.Count);
return values.Select(v => new PointValue return values.Select(v => new PointValue
{ {
@@ -155,12 +195,13 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
Skip = (page - 1) * size, Skip = (page - 1) * size,
Limit = size, Limit = size,
Sort = 1 // 按时间降序 Sort = 1 // 按时间降序
}); }, JsonOpts);
var resp = await client.PostAsync("/api/central/alarm/query", var resp = await client.PostAsync("/api/central/alarm/query",
new StringContent(body, Encoding.UTF8, "application/json")); new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync(); var json = await resp.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<Mc4AlarmQueryResult>(json)!; // MC4 文档 1.3:响应是 {code, msg, data:{total, list:[]}} 包装结构
var result = DeserializeData<Mc4AlarmQueryResult>(json, "/api/central/alarm/query", new Mc4AlarmQueryResult());
_logger.LogDebug("[{Code}] 获取当前告警,响应{Sz}字节,{Ct}条", AdapterCode, json.Length, result.List?.Count ?? 0); _logger.LogDebug("[{Code}] 获取当前告警,响应{Sz}字节,{Ct}条", AdapterCode, json.Length, result.List?.Count ?? 0);
return new PagedResult<StandardAlarm> return new PagedResult<StandardAlarm>
{ {
@@ -230,7 +271,9 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
new StringContent(body, Encoding.UTF8, "application/json")); new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync(); var json = await resp.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<Dictionary<int, List<Mc4PointValue>>>(json)!; // MC4 文档 1.3:响应是 {code, msg, data:{...}} 包装结构
return DeserializeData<Dictionary<int, List<Mc4PointValue>>>(json, "/api/central/point/multi/value/get",
new Dictionary<int, List<Mc4PointValue>>());
} }
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
@@ -254,7 +297,7 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
new StringContent(body, Encoding.UTF8, "application/json")); new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync(); var json = await resp.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<Mc4AlarmQueryResult>(json)!; var result = JsonSerializer.Deserialize<Mc4AlarmQueryResult>(json, JsonOpts)!;
_logger.LogDebug("[{Code}] 获取当前告警,响应{Sz}字节,{Ct}条", AdapterCode, json.Length, result.List?.Count ?? 0); _logger.LogDebug("[{Code}] 获取当前告警,响应{Sz}字节,{Ct}条", AdapterCode, json.Length, result.List?.Count ?? 0);
return new PagedResult<StandardAlarm> return new PagedResult<StandardAlarm>
{ {
@@ -281,6 +324,23 @@ public class Mc4Adapter : IHasOwnDeviceTree, IHasPoints, IHasAlarms
// MC4.0 JSON 反序列化模型(内部使用) // MC4.0 JSON 反序列化模型(内部使用)
// ═══════════════════════════════════════════ // ═══════════════════════════════════════════
/// <summary>
/// MC4.0 统一响应包装(文档 1.3 节基础约定)。
/// 所有 MC4 业务接口的响应都遵循 {code, msg, data} 三段式结构,
/// code=0 表示成功,非 0 表示错误,msg 内含错误描述,data 才是真正的业务数据。
/// 文档 2.x 节"响应结果只标出 data 段"——只是省略了包装层,实际响应是带包装的。
/// </summary>
/// <typeparam name="T">data 段的业务数据类型</typeparam>
public class Mc4ApiResponse<T>
{
/// <summary>结果码,0=成功,其他=错误</summary>
public int Code { get; set; }
/// <summary>结果描述,code≠0 时含错误信息</summary>
public string? Msg { get; set; }
/// <summary>业务数据载荷(类型由调用方指定)</summary>
public T? Data { get; set; }
}
/// <summary>MC4.0 对象树节点</summary> /// <summary>MC4.0 对象树节点</summary>
public class Mc4TreeNode public class Mc4TreeNode
{ {
@@ -24,7 +24,7 @@ public class Mc4AuthHelper
private readonly ILogger _logger; private readonly ILogger _logger;
private string? _token; private string? _token;
private DateTime _tokenExpiry = DateTime.MinValue; private DateTime _tokenExpiry = DateTime.MinValue;
private bool? _needMd5 = false; private bool? _needMd5 = true;
public Mc4AuthHelper(HttpClient http, string baseUrl, string account = "admin", string password = "admin", ILogger logger = null!) public Mc4AuthHelper(HttpClient http, string baseUrl, string account = "admin", string password = "admin", ILogger logger = null!)
{ {
@@ -35,6 +35,24 @@ public class Mc4AuthHelper
_logger = logger; _logger = logger;
} }
/// <summary>
/// MC4 平台要求 POST 请求体为合法 JSON,无参数时也必须传 `{}` 而不是 null,
/// 否则服务端会因为解析失败返回 400 Bad Request。
/// </summary>
private static readonly StringContent EmptyJsonBody
= new("{}", Encoding.UTF8, "application/json");
/// <summary>
/// MC4 平台 JSON 序列化/反序列化统一配置。
/// 关键点:开启 PropertyNameCaseInsensitive=true
/// 这样 MC4 返回的全小写字段(code/msg/data/token 等)能正确映射到帕斯卡命名的 C# 属性。
/// </summary>
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public async Task<string> GetTokenAsync() public async Task<string> GetTokenAsync()
{ {
if (!string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiry) if (!string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiry)
@@ -48,11 +66,12 @@ public class Mc4AuthHelper
{ {
try try
{ {
var confResp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", null); // MC4 文档 2.1.1/api/central/auth/conf/get 必须传 {} 而不是 null
var confResp = await _http.PostAsync($"{_baseUrl}/api/central/auth/conf/get", EmptyJsonBody);
if (confResp.IsSuccessStatusCode) if (confResp.IsSuccessStatusCode)
{ {
var confJson = await confResp.Content.ReadAsStringAsync(); var confJson = await confResp.Content.ReadAsStringAsync();
var conf = JsonSerializer.Deserialize<Mc4ConfResponse>(confJson); var conf = JsonSerializer.Deserialize<Mc4ConfResponse>(confJson, JsonOpts);
_needMd5 = conf?.Encrypt ?? false; _needMd5 = conf?.Encrypt ?? false;
_logger?.LogDebug("MC4 加密配置: encrypt={Enc}", _needMd5); _logger?.LogDebug("MC4 加密配置: encrypt={Enc}", _needMd5);
} }
@@ -64,18 +83,24 @@ public class Mc4AuthHelper
// 2. 登录获取 Token // 2. 登录获取 Token
var pwd = _needMd5 == true ? ComputeMd5(_password) : _password; var pwd = _needMd5 == true ? ComputeMd5(_password) : _password;
_logger?.LogDebug("MC4 开始登录: 账号={Acct}, MD5={Md5}", _account, _needMd5); _logger?.LogDebug("MC4 开始登录: 账号={Acct}, MD5={Md5}", _account, _needMd5);
var loginBody = JsonSerializer.Serialize(new { account = _account, password = pwd }); var loginBody = JsonSerializer.Serialize(new { account = _account, password = pwd }, JsonOpts);
var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/login", var resp = await _http.PostAsync($"{_baseUrl}/api/central/auth/login",
new StringContent(loginBody, Encoding.UTF8, "application/json")); new StringContent(loginBody, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync(); var json = await resp.Content.ReadAsStringAsync();
var result = JsonSerializer.Deserialize<Mc4LoginResponse>(json) // MC4 实际响应是嵌套结构:{ code, msg, data: { token, id, account, name, ... } }
// code=0 表示成功;非 0 表示失败,msg 内含错误信息
// 注意:必须使用 JsonOpts 开启大小写不敏感,否则小写字段映射不到 PascalCase 属性
var result = JsonSerializer.Deserialize<Mc4LoginResponse>(json, JsonOpts)
?? throw new Exception("MC4 登录响应为空"); ?? throw new Exception("MC4 登录响应为空");
if (string.IsNullOrEmpty(result.Token)) if (result.Code != 0)
throw new Exception("MC4 登录失败: Token 为空"); throw new Exception($"MC4 登录失败: code={result.Code}, msg={result.Msg ?? "()"}");
_token = result.Token; if (result.Data == null || string.IsNullOrEmpty(result.Data.Token))
throw new Exception("MC4 登录失败: data.token 为空");
_token = result.Data.Token;
_tokenExpiry = DateTime.UtcNow.AddHours(7); _tokenExpiry = DateTime.UtcNow.AddHours(7);
_logger?.LogDebug("MC4 登录成功(账号={Acct})", _account); _logger?.LogDebug("MC4 登录成功(账号={Acct}, name={Name}, ip={Ip}, pwdExpired={PwdExp})",
_account, result.Data.Name, result.Data.Ip, result.Data.PwdExpired);
return _token; return _token;
} }
@@ -96,6 +121,32 @@ public class Mc4AuthHelper
return Convert.ToHexString(bytes).ToLowerInvariant(); return Convert.ToHexString(bytes).ToLowerInvariant();
} }
/// <summary>MC4 /api/central/auth/conf/get 响应(用于判断是否启用 MD5 加密)</summary>
private class Mc4ConfResponse { public bool Encrypt { get; set; } } private class Mc4ConfResponse { public bool Encrypt { get; set; } }
private class Mc4LoginResponse { public string? Token { get; set; } public int Id { get; set; } public string? Account { get; set; } }
/// <summary>
/// MC4 /api/central/auth/login 响应(嵌套结构)
/// 示例:{ "code":0, "msg":"", "data":{ "token":"...", "id":2, "account":"g82tt", "name":"滁州", ... } }
/// </summary>
private class Mc4LoginResponse
{
public int Code { get; set; }
public string? Msg { get; set; }
public Mc4LoginData? Data { get; set; }
}
/// <summary>MC4 登录响应 data 字段(实际数据载荷)</summary>
private class Mc4LoginData
{
public string? Token { get; set; }
public int Id { get; set; }
public string? Account { get; set; }
public string? Name { get; set; }
public string? EngName { get; set; }
public string? DeviceName { get; set; }
public string? DeviceType { get; set; }
public string? Ip { get; set; }
public bool PwdExpired { get; set; }
public int[]? Roles { get; set; }
}
} }
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Mc4Probe</RootNamespace>
</PropertyGroup>
</Project>
+241
View File
@@ -0,0 +1,241 @@
// ═══════════════════════════════════════════════════════════════
// Mc4Probe — 临时探针工具
//
// 用途:连接 192.168.3.92 MC4 设备,抓取完整对象树 + 每个设备的点表 + 实时点值
// 用于分析 MC4 实际数据结构,规划 web.vite/warehouse 的显示方案
//
// 用法:cd tools\mc4_probe\Mc4Probe && dotnet run -- <ip> <user> <pwd>
// 例如:dotnet run -- 192.168.3.92 admin admin
// ═══════════════════════════════════════════════════════════════
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Mc4Probe;
internal class Program
{
// 大小写不敏感的 JSON 配置
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private static readonly StringContent EmptyJsonBody
= new("{}", Encoding.UTF8, "application/json");
private static string _token = "";
static async Task<int> Main(string[] args)
{
var ip = args.Length > 0 ? args[0] : "192.168.3.92";
var user = args.Length > 1 ? args[1] : "admin";
var pwd = args.Length > 2 ? args[2] : "admin";
var baseUrl = $"http://{ip}:3000";
Console.WriteLine($"[Probe] 目标: {baseUrl}, 账号: {user}");
Console.WriteLine(new string('=', 80));
using var http = new HttpClient { BaseAddress = new Uri(baseUrl) };
http.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
try
{
// 1. 登录拿 Token
await LoginAsync(http, user, pwd);
// 2. 拉对象树
var tree = await GetObjectTreeAsync(http);
Console.WriteLine($"\n[Probe] 对象树根节点数: {tree.Count}");
foreach (var n in tree) PrintNode(n, 0);
// 3. 收集所有设备节点(type=2)
var devices = new List<Mc4TreeNode>();
CollectDevices(tree, devices);
Console.WriteLine($"\n[Probe] 共发现 {devices.Count} 台设备,开始抓点表+实时值...");
// 4. 对每台设备抓点表 + 实时值
var report = new List<DeviceReport>();
foreach (var dev in devices)
{
var r = new DeviceReport
{
DeviceId = dev.Id,
DeviceName = dev.Name ?? $"设备{dev.Id}",
ObjectType = dev.ObjectType,
Tag = dev.Tag,
Option = dev.Option
};
try
{
r.Points = await GetDevicePointsAsync(http, dev.Id);
r.Realtime = await GetRealtimeValuesAsync(http, dev.Id);
}
catch (Exception ex)
{
r.Error = ex.Message;
}
report.Add(r);
}
// 5. 输出 JSON 报告
var json = JsonSerializer.Serialize(report, JsonOpts);
var outFile = $"mc4_report_{ip.Replace(".", "_")}_{DateTime.Now:yyyyMMdd_HHmmss}.json";
await File.WriteAllTextAsync(outFile, json);
Console.WriteLine($"\n[Probe] 报告已写入: {outFile} ({new FileInfo(outFile).Length} 字节)");
// 6. 控制台摘要
Console.WriteLine("\n" + new string('=', 80));
Console.WriteLine("设备摘要:");
foreach (var r in report)
{
var ptCnt = r.Points?.Count ?? 0;
var rtCnt = r.Realtime?.Count ?? 0;
var onlineIdx = r.Realtime?.FirstOrDefault(v => v.Index == 1)?.Value;
Console.WriteLine($" [{r.DeviceId}] {r.DeviceName,-20} ObjectType={r.ObjectType,-5} 点表={ptCnt} 实时值={rtCnt} 在线点(index=1)={onlineIdx?.ToString() ?? "N/A"}");
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"\n[Probe] 错误: {ex.Message}");
return 1;
}
return 0;
}
private static async Task LoginAsync(HttpClient http, string user, string pwd)
{
// 1) 查询是否需要 MD5
var confResp = await http.PostAsync("/api/central/auth/conf/get", EmptyJsonBody);
confResp.EnsureSuccessStatusCode();
var confJson = await confResp.Content.ReadAsStringAsync();
var conf = JsonSerializer.Deserialize<JsonElement>(confJson, JsonOpts);
var needMd5 = conf.GetProperty("data").GetProperty("encrypt").GetBoolean();
var pwdFinal = needMd5 ? ComputeMd5(pwd) : pwd;
Console.WriteLine($"[Probe] 加密配置: encrypt={needMd5}, MD5={pwdFinal}");
// 2) 登录
var loginBody = JsonSerializer.Serialize(new { account = user, password = pwdFinal }, JsonOpts);
var resp = await http.PostAsync("/api/central/auth/login", new StringContent(loginBody, Encoding.UTF8, "application/json"));
var rawJson = await resp.Content.ReadAsStringAsync();
Console.WriteLine($"[Probe] 登录响应({(int)resp.StatusCode}): {rawJson}");
resp.EnsureSuccessStatusCode();
var doc = JsonDocument.Parse(rawJson, new JsonDocumentOptions { AllowTrailingCommas = true });
if (!doc.RootElement.TryGetProperty("data", out var dataElem) || dataElem.ValueKind == JsonValueKind.Null)
throw new Exception($"登录响应 data 为空 (HTTP {(int)resp.StatusCode}): {rawJson}");
_token = dataElem.GetProperty("token").GetString()
?? throw new Exception($"登录响应 data.token 为空: {rawJson}");
http.DefaultRequestHeaders.Remove("token");
http.DefaultRequestHeaders.Add("token", _token);
Console.WriteLine($"[Probe] 登录成功, Token={_token[..Math.Min(8, _token.Length)]}...");
}
private static async Task<List<Mc4TreeNode>> GetObjectTreeAsync(HttpClient http)
{
var resp = await http.PostAsync("/api/central/object/tree", EmptyJsonBody);
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
var data = doc.RootElement.GetProperty("data");
return JsonSerializer.Deserialize<List<Mc4TreeNode>>(data.GetRawText(), JsonOpts) ?? new();
}
private static async Task<List<Mc4PointDef>> GetDevicePointsAsync(HttpClient http, int deviceId)
{
var body = JsonSerializer.Serialize(new { id = deviceId }, JsonOpts);
var resp = await http.PostAsync("/api/central/device/point/get", new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data)) return new();
return JsonSerializer.Deserialize<List<Mc4PointDef>>(data.GetRawText(), JsonOpts) ?? new();
}
private static async Task<List<Mc4PointValue>> GetRealtimeValuesAsync(HttpClient http, int deviceId)
{
var body = JsonSerializer.Serialize(new { id = deviceId }, JsonOpts);
var resp = await http.PostAsync("/api/central/device/point/value/get", new StringContent(body, Encoding.UTF8, "application/json"));
resp.EnsureSuccessStatusCode();
var json = await resp.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data)) return new();
return JsonSerializer.Deserialize<List<Mc4PointValue>>(data.GetRawText(), JsonOpts) ?? new();
}
private static void CollectDevices(List<Mc4TreeNode> nodes, List<Mc4TreeNode> bag)
{
foreach (var n in nodes)
{
if (n.Type == 2) bag.Add(n);
if (n.Children != null && n.Children.Count > 0) CollectDevices(n.Children, bag);
}
}
private static void PrintNode(Mc4TreeNode n, int depth)
{
var indent = new string(' ', depth * 2);
var typeStr = n.Type == 1 ? "[区域]" : "[设备]";
var otStr = n.ObjectType != 0 ? $" ObjectType={n.ObjectType}" : "";
var tagStr = !string.IsNullOrEmpty(n.Tag) ? $" Tag={n.Tag}" : "";
Console.WriteLine($"{indent}{typeStr} id={n.Id} {n.Name}{otStr}{tagStr}");
if (n.Children != null)
foreach (var c in n.Children) PrintNode(c, depth + 1);
}
private static string ComputeMd5(string input)
{
var bytes = System.Security.Cryptography.MD5.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
// ═══════════════════════════════════════════
// 数据模型
// ═══════════════════════════════════════════
public class Mc4TreeNode
{
public int Id { get; set; }
public string? Name { get; set; }
public int Type { get; set; }
public int ObjectType { get; set; }
public string? Tag { get; set; }
public Dictionary<string, object?>? Option { get; set; }
public List<Mc4TreeNode>? Children { get; set; }
}
public class Mc4PointDef
{
public int Index { get; set; }
public int Type { get; set; }
public string? Tag { get; set; }
public string? Name { get; set; }
public string? Desc { get; set; }
public string? Unit { get; set; }
public Dictionary<string, object?>? Option { get; set; }
}
public class Mc4PointValue
{
public int Id { get; set; }
public int Index { get; set; }
public double Value { get; set; }
public string? Time { get; set; }
public int Interval { get; set; }
}
public class DeviceReport
{
public int DeviceId { get; set; }
public string DeviceName { get; set; } = "";
public int ObjectType { get; set; }
public string? Tag { get; set; }
public Dictionary<string, object?>? Option { get; set; }
public List<Mc4PointDef>? Points { get; set; }
public List<Mc4PointValue>? Realtime { get; set; }
public string? Error { get; set; }
}
File diff suppressed because it is too large Load Diff
+276 -373
View File
@@ -112,13 +112,15 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 设备控制选项卡 - 除湿/恒湿机空调显示 --> <!-- 设备控制选项卡 - 空调控制器显示 -->
<el-tab-pane v-if="showControlTab" label="设备控制"> <el-tab-pane v-if="isAirConditionerController" label="设备控制">
<div class="tab-content"> <div class="tab-content">
<div class="control-buttons"> <div class="control-buttons">
<el-button type="primary" @click="handleTurnOn">开启</el-button> <el-button type="primary" :loading="controlLoading === 'cool'" @click="handleAirControl('cool')">制冷</el-button>
<el-button type="danger" @click="handleTurnOff">关闭</el-button> <el-button type="warning" :loading="controlLoading === 'heat'" @click="handleAirControl('heat')">制热</el-button>
<el-button type="danger" :loading="controlLoading === 'off'" @click="handleAirControl('off')">关机</el-button>
</div> </div>
<div class="control-tip">通过网关下发到 MC4 设备的 index=2/3/4 控制点</div>
</div> </div>
</el-tab-pane> </el-tab-pane>
@@ -184,7 +186,45 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 实时曲线选项卡 - 除湿/恒湿机空调温湿度变送器显示 --> <!-- 实时数据选项卡 - 温度探头/湿度探头/空调控制器 -->
<el-tab-pane v-if="showRealtimeTab" label="实时数据">
<div class="tab-content">
<!-- 温度探头/湿度探头单数值显示 -->
<div v-if="isTemperatureProbe || isHumidityProbe" class="single-value-card">
<div class="big-value">
<span class="value-number">{{ primaryPointValue }}</span>
<span class="value-unit">{{ primaryPointUnit }}</span>
</div>
<div class="value-name">{{ primaryPointName }}</div>
<div class="value-meta">
<span :class="onlineDotClass"></span>
<span>{{ onlineStatusText }}</span>
<span class="meta-time">更新时间{{ updateTimeText || '加载中' }}</span>
</div>
</div>
<!-- 空调控制器双数值 + 控制 -->
<div v-else-if="isAirConditionerController" class="dual-value-card">
<div class="value-row">
<div class="value-cell">
<div class="value-label">温度</div>
<div class="value-big">{{ acTempValue }}<span class="value-unit"></span></div>
</div>
<div class="value-cell">
<div class="value-label">湿度</div>
<div class="value-big">{{ acHumValue }}<span class="value-unit">%</span></div>
</div>
</div>
<div class="value-meta">
<span :class="onlineDotClass"></span>
<span>{{ onlineStatusText }}</span>
<span class="meta-time">更新时间{{ updateTimeText || '加载中' }}</span>
</div>
</div>
</div>
</el-tab-pane>
<!-- 实时曲线选项卡 - 温湿度变送器显示保留兼容 -->
<el-tab-pane v-if="showCurveTab" label="实时曲线"> <el-tab-pane v-if="showCurveTab" label="实时曲线">
<div class="tab-content"> <div class="tab-content">
<div class="chart-container"> <div class="chart-container">
@@ -316,8 +356,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { ElTag, ElButton } from 'element-plus'; import { ElTag, ElButton, ElMessage } from 'element-plus';
import http from '@/api/http'; import http from '@/api/http';
import { gwGet, gwPost } from '@/api/gateway';
import KeyInfo from '@/view/key/KeyInfo.vue'; import KeyInfo from '@/view/key/KeyInfo.vue';
// 定义组件的事件 // 定义组件的事件
@@ -329,418 +370,278 @@ const props = defineProps<{
MapModuleId?: string; MapModuleId?: string;
}>(); }>();
// 移除路由相关的代码
// 设备名称 // 设备名称
const deviceName = ref('设备信息'); const deviceName = ref('设备信息');
// 设备信息数据 // 设备信息数据(来自新 base_device 表)
const deviceInfo = ref({ const deviceInfo = ref({
id: '', id: '', // DeviceIdint
type: '', sourceId: '', // SourceIdMC4 的设备 id
status: 'unknown', // online, offline, warning, unknown name: '',
workStatus: '', // 工作状态 type: '', // 兼容旧逻辑:显示用
category: '', // 设备种类:温度探头/湿度探头/空调控制器
group: '', // 设备分组
status: 'unknown', // 在线/离线/告警/未知
isOnline: true,
workStatus: '',
location: '', location: '',
ipAddress: '', ipAddress: '',
onlineTime: '', adapterCode: '', // 例如 MC4:33ku
lastCheckTime: '' mapModelId: '',
enable: '',
remark: ''
}); });
// 实时点值(来自网关 /api/gateway/realtime/{adapter}/{deviceId}
interface PointValue {
index: number
value: number
unit?: string
name?: string
updateTime?: string
}
const realtimePoints = ref<PointValue[]>([]);
// 控制按钮 loading 状态
const controlLoading = ref<string>('');
// 是否有设备信息 // 是否有设备信息
const hasDeviceInfo = computed(() => { const hasDeviceInfo = computed(() => {
return deviceInfo.value.id && deviceInfo.value.type; return !!deviceInfo.value.id;
}); });
// 是否为摄像机 // 是否为摄像机(保留兼容)
const isCamera = computed(() => { const isCamera = computed(() => {
return deviceInfo.value.type === '摄像机'; return deviceInfo.value.category === '摄像机' || deviceInfo.value.type === '摄像机';
}); });
// 是否为功耗设备(除湿/恒湿机、空调) // 是否为温度探头
const isPowerDevice = computed(() => { const isTemperatureProbe = computed(() => {
const type = deviceInfo.value.type; return deviceInfo.value.category === '温度探头';
return type === '除湿/恒湿机' || type === '空调';
}); });
// 是否为湿度变送器 // 是否为湿度探头
const isHumidityTransmitter = computed(() => { const isHumidityProbe = computed(() => {
return deviceInfo.value.type === '湿度变送器'; return deviceInfo.value.category === '湿度探头';
}); });
// 是否为报警器(红外报警器、烟雾报警器) // 是否为空调控制器
const isAlarmDevice = computed(() => { const isAirConditionerController = computed(() => {
const type = deviceInfo.value.type; return deviceInfo.value.category === '空调控制器';
return type === '红外报警器' || type === '烟雾报警器';
}); });
// 是否为车辆道闸 // IoT 设备(温度探头/湿度探头/空调控制器)的统称
const isVehicleBarrier = computed(() => { const isIotDevice = computed(() => {
return deviceInfo.value.type === '车辆道闸'; return isTemperatureProbe.value || isHumidityProbe.value || isAirConditionerController.value;
}); });
// 是否为钥匙柜(485钥匙柜、网络钥匙柜 // 是否显示工作状态选项卡(摄像机/报警器/道闸
const isKeyCabinet = computed(() => {
const type = deviceInfo.value.type;
return type === '485钥匙柜' || type === '网络钥匙柜';
});
// 是否显示工作状态选项卡
const showWorkStatusTab = computed(() => { const showWorkStatusTab = computed(() => {
return isCamera.value || isAlarmDevice.value || isVehicleBarrier.value; return isCamera.value;
}); });
// 是否显示设备控制选项卡 // 是否显示设备控制选项卡(空调控制器)
const showControlTab = computed(() => { const showControlTab = computed(() => {
return isPowerDevice.value; return isAirConditionerController.value;
}); });
// 是否显示实时曲线选项卡 // 是否显示实时数据选项卡IoT 三类)
const showRealtimeTab = computed(() => {
return isIotDevice.value;
});
// 是否显示实时曲线选项卡(保留兼容,老的温湿度变送器)
const showCurveTab = computed(() => { const showCurveTab = computed(() => {
return isPowerDevice.value || isHumidityTransmitter.value; return false; // 新的 IoT 用实时数据 tab,不再用曲线
}); });
// 根据设备状态计算显示文本 // 设备在线状态文案
const deviceStatusText = computed(() => { const onlineStatusText = computed(() => {
const statusMap: Record<string, string> = { if (deviceInfo.value.status === '在线' || deviceInfo.value.isOnline === true) return '在线';
'线': '线', if (deviceInfo.value.status === '线' || deviceInfo.value.isOnline === false) return '线';
'离线': '离线', return '未知';
'告警': '告警',
'未知': '未知'
};
return statusMap[deviceInfo.value.status] || '未知';
}); });
// 根据设备状态计算标签类型 // 在线状态点颜色
const onlineDotClass = computed(() => {
if (deviceInfo.value.isOnline) return 'dot-online';
return 'dot-offline';
});
// 标签类型
const deviceStatusType = computed(() => { const deviceStatusType = computed(() => {
const typeMap: Record<string, string> = { if (deviceInfo.value.isOnline) return 'success';
'在线': 'success', return 'danger';
'离线': 'danger',
'告警': 'warning',
'未知': 'info'
};
return typeMap[deviceInfo.value.status] || 'info';
}); });
// 获取设备实时信息函数 // 实时点值:取 index=2 的数值(探头类的真实数据点)
const primaryPointValue = computed(() => {
const p = realtimePoints.value.find(v => v.index === 2);
return p ? p.value.toFixed(1) : '--';
});
const primaryPointUnit = computed(() => {
const p = realtimePoints.value.find(v => v.index === 2);
return p?.unit || (isTemperatureProbe.value ? '℃' : isHumidityProbe.value ? '%' : '');
});
const primaryPointName = computed(() => {
return isTemperatureProbe.value ? '温度' : isHumidityProbe.value ? '湿度' : '数值';
});
// 空调:温度(index=6)和湿度(index=5
const acTempValue = computed(() => {
const p = realtimePoints.value.find(v => v.index === 6);
return p ? p.value.toFixed(1) : '--';
});
const acHumValue = computed(() => {
const p = realtimePoints.value.find(v => v.index === 5);
return p ? p.value.toFixed(1) : '--';
});
// 更新时间
const updateTimeText = computed(() => {
// 取最新一条的更新时间
const sorted = [...realtimePoints.value]
.filter(v => v.updateTime)
.sort((a, b) => (b.updateTime || '').localeCompare(a.updateTime || ''));
return sorted[0]?.updateTime || '';
});
// 通用错误处理
const resetDeviceInfo = () => {
deviceInfo.value = {
id: '', sourceId: '', name: '', type: '', category: '', group: '',
status: '未知', isOnline: false, workStatus: '', location: '',
ipAddress: '', adapterCode: '', mapModelId: '', enable: '', remark: ''
};
deviceName.value = '设备信息';
realtimePoints.value = [];
};
// 获取设备实时信息:改用新 base_device/getPageData 接口
const fetchDeviceRealTimeInfo = async () => { const fetchDeviceRealTimeInfo = async () => {
if (!props.DeviceId && !props.MapModuleId) {
console.warn('缺少必要的设备ID或模块ID');
return;
}
try { try {
console.log(`获取设备信息: DeviceId=${props.DeviceId}, MapModuleId=${props.MapModuleId}`); // 1. 查询 base_device 获取设备基础信息
// 优先用 MapModelId 查(地图点击场景),其次用 DeviceId
// 检查是否有有效的DeviceId或MapModuleId const filter: any[] = [];
if (!props.DeviceId && !props.MapModuleId) { if (props.MapModuleId) {
console.warn('缺少必要的设备ID或模块ID'); filter.push({ name: 'MapModelId', value: props.MapModuleId, displayType: 'string' });
} else if (props.DeviceId) {
filter.push({ name: 'DeviceId', value: props.DeviceId, displayType: 'int' });
}
const deviceParams = { page: 0, wheres: '', filter };
const deviceResponse: any = await http.post('/api/base_device/getPageData', deviceParams);
if (deviceResponse?.status !== 0 || !deviceResponse.data?.rows?.length) {
console.warn('base_device 未找到设备:', props.MapModuleId || props.DeviceId);
resetDeviceInfo();
return; return;
} }
// 构建查询参数 - 使用非null的那个参数 const d = deviceResponse.data.rows[0];
const filter = [];
let wheres = ''; // 字段映射(新表 → 视图模型)
deviceInfo.value = {
if (props.DeviceId) { id: d.DeviceId?.toString() || '',
filter.push({ sourceId: d.SourceId || '',
name: "DeviceId", name: d.DeviceName || '',
value: props.DeviceId, // 兼容老逻辑:type 用 category 当字符串
displayType: "string" type: d.DeviceCategory || '',
}); category: d.DeviceCategory || '',
} else if (props.MapModuleId) { group: d.DeviceGroup || '',
filter.push({ status: d.IsOnline === '在线' || d.IsOnline === true || d.IsOnline === 'true' ? '在线' : '离线',
name: "MapModuleID", isOnline: d.IsOnline === '在线' || d.IsOnline === true || d.IsOnline === 'true',
value: props.MapModuleId, workStatus: '',
displayType: "string" location: d.Location || '',
}); ipAddress: d.IpAddress || '',
} adapterCode: d.AdapterCode || '',
mapModelId: d.MapModelId || '',
const deviceParams = { enable: d.Enable || '',
page: 0, remark: d.Remark || ''
wheres,
filter
}; };
deviceName.value = d.DeviceName || '设备信息';
// 调用设备信息接口
const deviceResponse = await http.post('/api/Warehouse_Device/GetPageData', deviceParams); console.log('[DeviceInfo] 已加载设备:', deviceInfo.value);
if (deviceResponse.status === 0 && deviceResponse.rows && deviceResponse.rows.length > 0) { // 2. 如果是 IoT 设备,调用网关拉实时点值
const deviceData = deviceResponse.rows[0]; if (isIotDevice.value && deviceInfo.value.sourceId && deviceInfo.value.adapterCode) {
console.log(deviceData,' DeviceInfo:deviceData') await fetchRealtimePoints();
// 忽略指定的四个属性,更新设备基本信息
deviceInfo.value = {
id: deviceData.DeviceId?.toString() || '',
type: deviceData.DeviceType || '',
status: deviceData.OnlineStatus || '未知',
workStatus: deviceData.WorkStatus || '', // 工作状态
location: '', // 将通过点位信息更新
ipAddress: deviceData.Ip || '',
onlineTime: deviceData.OnlineTime || '',
lastCheckTime: deviceData.LastCheckTime || ''
};
deviceName.value = deviceData.DeviceName || '设备信息';
// 如果是钥匙柜,切换到KeyInfo组件
if (isKeyCabinet.value) {
emit('switchToKeyInfo', { DeviceId: deviceData.DeviceId?.toString() || '' });
return;
}
// 如果有PointID,获取点位信息
if (deviceData.PointID) {
try {
const pointParams = {
page: 0,
wheres: `PointID=${deviceData.PointID}`,
filter: [{
name: "PointID",
value: deviceData.PointID.toString(),
displayType: "string"
}]
};
const pointResponse = await http.post('/api/warehouse_devicepoint/GetPageData', pointParams);
if (pointResponse.status === 0 && pointResponse.rows && pointResponse.rows.length > 0) {
const pointData = pointResponse.rows[0];
// 拼接PointName和PointType显示为安装位置
deviceInfo.value.location = `${pointData.PointName}(点位类型:${pointData.PointType})`;
}
} catch (pointError) {
console.error('获取点位信息失败:', pointError);
// 点位信息获取失败不影响设备基本信息显示
}
}
} else {
console.warn('未找到设备信息');
// 重置为默认状态
deviceInfo.value = {
id: '',
type: '',
status: '未知',
workStatus: '', // 工作状态
location: '',
ipAddress: '',
onlineTime: '',
lastCheckTime: ''
};
deviceName.value = '设备信息';
} }
} catch (error) { } catch (err: any) {
console.error('获取设备实时信息失败:', error); console.error('获取设备信息失败:', err);
// 出错时重置为默认状态 resetDeviceInfo();
deviceInfo.value = { }
id: '', };
type: '',
status: '未知', // 调用网关 B 组获取实时点值
workStatus: '', // 工作状态 const fetchRealtimePoints = async () => {
location: '', try {
ipAddress: '', const adapter = deviceInfo.value.adapterCode; // 例如 MC4:33ku
onlineTime: '', const devId = deviceInfo.value.sourceId; // MC4 设备 id(字符串)
lastCheckTime: '' const data: any = await gwGet(`/api/gateway/realtime/${adapter}/${devId}`);
// 网关 B4 返回 List<PointValue>,字段:SourceDeviceId, PointIndex, Value, UpdateTime, Interval
// 同时兼容历史字段(items 包装 / 全小写 / 驼峰)
const list: any[] = Array.isArray(data)
? data
: (data?.items || data?.data?.items || data?.data || []);
realtimePoints.value = list.map((v: any) => {
const updateTime = v.updateTime || v.UpdateTime || v.Time || v.time;
return {
index: Number(v.pointIndex ?? v.PointIndex ?? v.index ?? v.Index ?? 0),
value: Number(v.value ?? v.Value ?? 0),
name: v.name || v.Name,
unit: v.unit || v.Unit,
updateTime: updateTime ? new Date(updateTime).toLocaleString('zh-CN') : ''
}; };
deviceName.value = '设备信息';
}
};
// 开启设备处理函数
const handleTurnOn = () => {
console.log('开启设备:', deviceInfo.value.id);
// 这里可以添加实际的设备开启逻辑
// 例如调用API接口控制设备
};
// 关闭设备处理函数
const handleTurnOff = () => {
console.log('关闭设备:', deviceInfo.value.id);
// 这里可以添加实际的设备关闭逻辑
// 例如调用API接口控制设备
};
// 当前激活的选项卡
const activeTab = ref('0'); // 默认选中第一个选项卡
// 工作状态数据
const onlineRate = ref(98.5); // 在线率,百分比
const videoQualityRate = ref(96.2); // 视频质量达标率,百分比
const recordingCompletionRate = ref(99.1); // 录像完整率,百分比
// 根据百分比返回不同的进度条颜色
const progressColor = (rate: number) => {
if (rate >= 90) return '#67c23a'; // 绿色
if (rate >= 70) return '#e6a23c'; // 黄色
return '#f56c6c'; // 红色
};
// 图像参数调整滑块值
const exposure = ref(50); // 曝光度,范围0-100,默认50
const saturation = ref(50); // 饱和度,范围0-100,默认50
const color = ref(50); // 色彩,范围0-100,默认50
// 监控图片列表
const videoImages = [
'/images/jiankong/1.png',
'/images/jiankong/2.png',
'/images/jiankong/3.png',
'/images/jiankong/4.png',
'/images/jiankong/5.png',
'/images/jiankong/6.png',
'/images/jiankong/7.png',
'/images/jiankong/8.png',
'/images/jiankong/9.png',
'/images/jiankong/10.png',
'/images/jiankong/11.png',
'/images/jiankong/12.png',
'/images/jiankong/13.png',
'/images/jiankong/14.png',
'/images/jiankong/15.png',
'/images/jiankong/16.png',
'/images/jiankong/17.png',
'/images/jiankong/18.png',
'/images/jiankong/19.png',
'/images/jiankong/20.png',
'/images/jiankong/21.png',
'/images/jiankong/22.png',
'/images/jiankong/23.png',
'/images/jiankong/24.png'
];
// 随机选择监控图片
const randomVideoImage = computed(() => {
const randomIndex = Math.floor(Math.random() * videoImages.length);
return videoImages[randomIndex];
});
// 实时曲线数据
const temperatureData = ref<number[]>([]);
const humidityData = ref<number[]>([]);
const powerData = ref<number[]>([]); // 功耗数据
const timeLabels = ref<string[]>([]);
// 计算平滑曲线的贝塞尔路径
const getSmoothPath = (data: number[]) => {
const points = data.map((value, index) => {
const x = (index / (data.length - 1)) * 100;
const y = 100 - value;
return { x, y };
});
if (points.length < 2) return '';
let path = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length - 1; i++) {
const prev = points[i - 1];
const curr = points[i];
const next = points[i + 1];
// 计算控制点,使曲线平滑
const controlPointX1 = curr.x - (next.x - prev.x) * 0.15;
const controlPointY1 = curr.y - (next.y - prev.y) * 0.15;
const controlPointX2 = curr.x + (next.x - prev.x) * 0.15;
const controlPointY2 = curr.y + (next.y - prev.y) * 0.15;
path += ` C ${controlPointX1} ${controlPointY1}, ${controlPointX2} ${controlPointY2}, ${next.x} ${next.y}`;
}
return path;
};
// 计算温度曲线的SVG路径
const getTemperaturePath = () => {
return getSmoothPath(temperatureData.value);
};
// 计算湿度曲线的SVG路径
const getHumidityPath = () => {
return getSmoothPath(humidityData.value);
};
// 计算功耗曲线的SVG路径
const getPowerPath = () => {
return getSmoothPath(powerData.value);
};
// 计算温度曲线填充区域的点坐标
const getTemperatureFillPoints = () => {
const points = temperatureData.value.map((temp, index) => {
const x = (index / (temperatureData.value.length - 1)) * 100;
const y = 100 - temp;
return `${x},${y}`;
});
// 添加底部两个点以形成封闭区域
const lastIndex = temperatureData.value.length - 1;
points.push(`${100},100`);
points.push(`${0},100`);
return points.join(' ');
};
// 计算湿度曲线填充区域的点坐标
const getHumidityFillPoints = () => {
const points = humidityData.value.map((hum, index) => {
const x = (index / (humidityData.value.length - 1)) * 100;
const y = 100 - hum;
return `${x},${y}`;
});
// 添加底部两个点以形成封闭区域
const lastIndex = humidityData.value.length - 1;
points.push(`${100},100`);
points.push(`${0},100`);
return points.join(' ');
};
// 计算功耗曲线填充区域的点坐标
const getPowerFillPoints = () => {
const points = powerData.value.map((power, index) => {
const x = (index / (powerData.value.length - 1)) * 100;
const y = 100 - power;
return `${x},${y}`;
});
// 添加底部两个点以形成封闭区域
const lastIndex = powerData.value.length - 1;
points.push(`${100},100`);
points.push(`${0},100`);
return points.join(' ');
};
// 生成随机数据
const generateRandomData = () => {
// 生成最近15个时间点的标签
const now = new Date();
const labels: string[] = [];
for (let i = 14; i >= 0; i--) {
const time = new Date(now.getTime() - i * 10 * 60000); // 每10分钟一个数据点
labels.push(`${time.getHours().toString().padStart(2, '0')}:${time.getMinutes().toString().padStart(2, '0')}`);
}
timeLabels.value = labels;
// 根据设备类型生成不同的数据
if (isPowerDevice.value) {
// 生成功耗数据(模拟50-200W,映射到20%-80%的高度)
powerData.value = Array.from({length: 15}, () => {
const power = Math.random() * 150 + 50; // 50-200W
return ((power - 50) / 150) * 60 + 20; // 映射到20%-80%
});
} else if (isHumidityTransmitter.value) {
// 生成温度数据(模拟20-35度,映射到30%-70%的高度)
temperatureData.value = Array.from({length: 15}, () => {
const temp = Math.random() * 15 + 20; // 20-35度
return ((temp - 20) / 15) * 40 + 30; // 映射到30%-70%
});
// 生成湿度数据(模拟40%-90%,映射到40%-90%的高度)
humidityData.value = Array.from({length: 15}, () => {
return Math.random() * 50 + 40; // 40%-90%
}); });
console.log(`[DeviceInfo] 实时点值 ${realtimePoints.value.length}`, realtimePoints.value);
} catch (err: any) {
console.warn('获取实时点值失败:', err);
realtimePoints.value = [];
} }
}; };
// 关闭按钮处理函数 // 空调控制:下发命令到 MC4 设备的 index=2/3/4
// 2=制冷发射, 3=制热发射, 4=关机发射
const handleAirControl = async (mode: 'cool' | 'heat' | 'off') => {
if (!deviceInfo.value.adapterCode || !deviceInfo.value.sourceId) {
ElMessage.error('设备信息不完整,无法控制');
return;
}
const pointIndex = mode === 'cool' ? 2 : mode === 'heat' ? 3 : 4;
controlLoading.value = mode;
try {
await gwPost(`/api/gateway/realtime/${deviceInfo.value.adapterCode}/control`, {
deviceId: deviceInfo.value.sourceId,
pointIndex,
value: 1 // 1 = 发射
});
ElMessage.success(`已下发${mode === 'cool' ? '制冷' : mode === 'heat' ? '制热' : '关机'}命令`);
// 3 秒后刷新一次实时值
setTimeout(fetchRealtimePoints, 3000);
} catch (err: any) {
console.error('控制失败:', err);
ElMessage.error(`控制失败: ${err?.message || err}`);
} finally {
controlLoading.value = '';
}
};
// 关闭按钮
const handleClose = () => { const handleClose = () => {
// 触发关闭事件,通知父组件关闭弹窗
emit('close'); emit('close');
}; };
// 初始化时生成随机数据 // 监听 props 变化
generateRandomData();
// 监听props变化,当设备ID或模块ID变化时重新获取设备信息
watch( watch(
() => [props.DeviceId, props.MapModuleId], () => [props.DeviceId, props.MapModuleId],
() => { () => {
@@ -749,13 +650,15 @@ watch(
{ immediate: true, deep: true } { immediate: true, deep: true }
); );
// 监听设备类型变化,重新生成数据 // 组件挂载后,开启 30 秒一次的实时点值轮询(仅 IoT 设备)
watch( onMounted(() => {
() => deviceInfo.value.type, // 启动轮询
() => { setInterval(() => {
generateRandomData(); if (isIotDevice.value && deviceInfo.value.sourceId && deviceInfo.value.adapterCode) {
} fetchRealtimePoints();
); }
}, 30000);
});
</script> </script>
<style scoped> <style scoped>
@@ -161,11 +161,20 @@
const ptzStop = () => ptzSend('stop') const ptzStop = () => ptzSend('stop')
//── 实时数据 ── //── 实时数据 ──
// 网关 B4 返回 List<PointValue>,字段 PascalCaseSourceDeviceId, PointIndex, Value, UpdateTime, Interval
// 前端 table 字段是 camelCase,做一层字段映射
const normalizePoint = (v) => ({
pointIndex: v.PointIndex ?? v.pointIndex ?? v.index ?? 0,
value: v.Value ?? v.value ?? 0,
updateTime: v.UpdateTime ?? v.updateTime ?? v.Time ?? v.time ?? '',
interval: v.Interval ?? v.interval ?? 0
});
const fetchRealtime = async () => { const fetchRealtime = async () => {
if (!curDev.value) return; realtimeLoading.value = true; if (!curDev.value) return; realtimeLoading.value = true;
try { try {
const r = await fetch(`${GW}/api/gateway/realtime/${(curDev.value.AdapterCode || curDev.value.adapterCode)}/${curDev.value.SourceId || curDev.value.sourceId}`); const r = await fetch(`${GW}/api/gateway/realtime/${(curDev.value.AdapterCode || curDev.value.adapterCode)}/${curDev.value.SourceId || curDev.value.sourceId}`);
realtimeValues.value = await r.json(); const list = await r.json();
realtimeValues.value = Array.isArray(list) ? list.map(normalizePoint) : [];
} catch {} finally { realtimeLoading.value = false } } catch {} finally { realtimeLoading.value = false }
} }
const openRealtime = (d) => { curDev.value = d; realtimeVisible.value = true; fetchRealtime(); _timer = setInterval(fetchRealtime, 5000) } const openRealtime = (d) => { curDev.value = d; realtimeVisible.value = true; fetchRealtime(); _timer = setInterval(fetchRealtime, 5000) }
@@ -16,13 +16,25 @@ const emit = defineEmits(['update:modelValue'])
const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) }) const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
const values = ref([]), loading = ref(false) const values = ref([]), loading = ref(false)
let timer = null let timer = null
// 网关 B4 返回 List<PointValue>,字段 PascalCasePointIndex, Value, UpdateTime, Interval
// 这里做一层字段映射兼容两种命名风格
const normalizePoint = (v) => ({
pointIndex: v.PointIndex ?? v.pointIndex ?? v.index ?? 0,
value: v.Value ?? v.value ?? 0,
updateTime: v.UpdateTime ?? v.updateTime ?? v.Time ?? v.time ?? '',
interval: v.Interval ?? v.interval ?? 0
})
const fetchData = async () => { const fetchData = async () => {
if (!props.device) return if (!props.device) return
loading.value = true loading.value = true
const GW = window.gatewayConfig?.baseUrl || 'http://localhost:5100'; const GW = window.gatewayConfig?.baseUrl || 'http://localhost:5100';
const ac = props.device.AdapterCode || props.device.adapterCode || ''; const ac = props.device.AdapterCode || props.device.adapterCode || '';
const sid = props.device.SourceId || props.device.sourceId || ''; const sid = props.device.SourceId || props.device.sourceId || '';
try { const r = await fetch(`${GW}/api/gateway/realtime/${ac}/${sid}`); values.value = await r.json() } try {
const r = await fetch(`${GW}/api/gateway/realtime/${ac}/${sid}`);
const list = await r.json();
values.value = Array.isArray(list) ? list.map(normalizePoint) : [];
}
catch {} finally { loading.value = false } catch {} finally { loading.value = false }
} }
watch(visible, v => { if (v) { fetchData(); timer = setInterval(fetchData, 5000) } else { clearInterval(timer) } }) watch(visible, v => { if (v) { fetchData(); timer = setInterval(fetchData, 5000) } else { clearInterval(timer) } })
+468
View File
File diff suppressed because one or more lines are too long