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:
@@ -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()`,**未做字段名映射**(网关返回 PascalCase:PointIndex/Value/UpdateTime/Interval,前端 table 列名是 camelCase:pointIndex/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>,字段 PascalCase:SourceDeviceId, 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 200,application/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 200,application/json):
|
||||||
|
```json
|
||||||
|
true
|
||||||
|
```
|
||||||
|
|
||||||
|
或空 body(minimal 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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
+273
-370
@@ -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: '', // DeviceId(int)
|
||||||
type: '',
|
sourceId: '', // SourceId(MC4 的设备 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 fetchDeviceRealTimeInfo = async () => {
|
const primaryPointValue = computed(() => {
|
||||||
try {
|
const p = realtimePoints.value.find(v => v.index === 2);
|
||||||
console.log(`获取设备信息: DeviceId=${props.DeviceId}, MapModuleId=${props.MapModuleId}`);
|
return p ? p.value.toFixed(1) : '--';
|
||||||
|
});
|
||||||
|
|
||||||
// 检查是否有有效的DeviceId或MapModuleId
|
const primaryPointUnit = computed(() => {
|
||||||
if (!props.DeviceId && !props.MapModuleId) {
|
const p = realtimePoints.value.find(v => v.index === 2);
|
||||||
console.warn('缺少必要的设备ID或模块ID');
|
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 () => {
|
||||||
|
if (!props.DeviceId && !props.MapModuleId) {
|
||||||
|
console.warn('缺少必要的设备ID或模块ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 查询 base_device 获取设备基础信息
|
||||||
|
// 优先用 MapModelId 查(地图点击场景),其次用 DeviceId
|
||||||
|
const filter: any[] = [];
|
||||||
|
if (props.MapModuleId) {
|
||||||
|
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 = '';
|
|
||||||
|
|
||||||
if (props.DeviceId) {
|
// 字段映射(新表 → 视图模型)
|
||||||
filter.push({
|
deviceInfo.value = {
|
||||||
name: "DeviceId",
|
id: d.DeviceId?.toString() || '',
|
||||||
value: props.DeviceId,
|
sourceId: d.SourceId || '',
|
||||||
displayType: "string"
|
name: d.DeviceName || '',
|
||||||
});
|
// 兼容老逻辑:type 用 category 当字符串
|
||||||
} else if (props.MapModuleId) {
|
type: d.DeviceCategory || '',
|
||||||
filter.push({
|
category: d.DeviceCategory || '',
|
||||||
name: "MapModuleID",
|
group: d.DeviceGroup || '',
|
||||||
value: props.MapModuleId,
|
status: d.IsOnline === '在线' || d.IsOnline === true || d.IsOnline === 'true' ? '在线' : '离线',
|
||||||
displayType: "string"
|
isOnline: d.IsOnline === '在线' || d.IsOnline === true || d.IsOnline === 'true',
|
||||||
});
|
workStatus: '',
|
||||||
}
|
location: d.Location || '',
|
||||||
|
ipAddress: d.IpAddress || '',
|
||||||
const deviceParams = {
|
adapterCode: d.AdapterCode || '',
|
||||||
page: 0,
|
mapModelId: d.MapModelId || '',
|
||||||
wheres,
|
enable: d.Enable || '',
|
||||||
filter
|
remark: d.Remark || ''
|
||||||
};
|
};
|
||||||
|
deviceName.value = d.DeviceName || '设备信息';
|
||||||
|
|
||||||
// 调用设备信息接口
|
console.log('[DeviceInfo] 已加载设备:', deviceInfo.value);
|
||||||
const deviceResponse = await http.post('/api/Warehouse_Device/GetPageData', deviceParams);
|
|
||||||
|
|
||||||
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>,字段 PascalCase:SourceDeviceId, 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) }
|
||||||
|
|||||||
+13
-1
@@ -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>,字段 PascalCase:PointIndex, 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) } })
|
||||||
|
|||||||
Reference in New Issue
Block a user