EdgeOS UI 规划文档
本文档按照 EdgeOS 四个核心功能的顺序规划前端界面:
- 消息总线管理(UI 添加 MQTT/NATS 连接)
- EdgeX 节点注册(注册状态监控)
- EdgeX 子设备列表同步(设备列表管理)
- EdgeX 子设备点位同步(点位查看与实时数据)
- EdgeX 子设备双向控制(命令下发与响应追踪)
目录
- 设计规范
- 路由结构
- 功能一:消息总线管理
- 功能二:EdgeX 节点注册
- 功能三:子设备列表同步
- 功能四:子设备点位同步
- 功能五:子设备双向控制
- 实时通信方案
- 全局状态管理
- 类型定义
- API 服务层
- 组件复用规范
1. 设计规范
1.1 视觉风格
工业仪表盘风格:信息密度高、状态可视、实时流、告警高亮
| 项目 | 规格 |
|---|---|
| 背景色 | #0B1220(主)/ #111827(卡片) |
| 主色 | #1D4ED8 / #0EA5E9 |
| 文字 | #E5E7EB(主)/ #9CA3AF(辅) |
| 成功色 | #10B981 |
| 警告色 | #F59E0B |
| 错误色 | #EF4444 |
| 强调色 | #6366F1 |
| 字体 | system-ui |
| 标题 | 24px / 600 |
| 正文 | 14px / 400 |
1.2 状态色标准
| 状态 | 颜色 | 说明 |
|---|---|---|
connected / online |
#10B981 |
正常在线 |
connecting |
#F59E0B |
连接中 |
disconnected / offline |
#9CA3AF |
离线 |
error |
#EF4444 |
异常 |
critical 告警 |
#EF4444 |
红色闪烁 |
warning 告警 |
#F59E0B |
橙色 |
info |
#6366F1 |
蓝紫色 |
1.3 布局规则
- 顶部导航栏固定高度
64px,左侧 Sidebar 宽度240px(折叠64px) - 主内容区
padding: 24px,使用 CSS Grid / Flexbox 混合布局 - 每屏卡片数量控制在 4-8 个,避免过度分散
- 实时数据刷新不超过 1s 间隔
- 告警角标在 Sidebar 菜单项右上角实时展示数量
2. 路由结构
/
├── / → 重定向到 /dashboard
├── /dashboard → 总览仪表盘
├── /middlewares → 消息总线列表
│ ├── /middlewares/add → 添加连接(表单页或弹窗)
│ └── /middlewares/:id → 连接详情与订阅主题状态
├── /nodes → EdgeX 节点列表
│ └── /nodes/:nodeId → 节点详情(设备列表入口)
├── /nodes/:nodeId/devices → 子设备列表
│ └── /nodes/:nodeId/devices/:deviceId → 设备详情(点位列表入口)
├── /nodes/:nodeId/devices/:deviceId/points → 点位列表与实时数据
├── /control → 双向控制面板
│ └── /control/:nodeId/:deviceId → 指定设备控制界面
├── /realtime → 全局实时数据流
├── /alerts → 告警与事件列表
└── /settings → 系统设置
2.1 导航菜单结构
◎ 总览
─ 消息总线 [+] ← 核心入口,支持 MQTT/NATS 添加
─ EdgeX 节点 ← 注册状态
─ 子设备管理 ← 设备列表同步
─ 点位监控 ← 点位同步 + 实时数据
─ 设备控制 [!] ← 双向控制(告警角标)
─ 事件告警 [N] ← 数字角标
─ 系统设置
3. 功能一:消息总线管理
3.1 页面:消息总线列表(/middlewares)
职责: 展示所有已配置的 MQTT/NATS 连接,支持新增、编辑、删除、连接/断开操作。
┌─────────────────────────────────────────────────────────────┐
│ 消息总线管理 [+ 添加连接] │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 生产 MQTT │ │ 测试 NATS │ │ 备用 MQTT │ │
│ │ edgeOS(MQTT) │ │ edgeOS(NATS) │ │ edgeOS(MQTT) │ │
│ │ ● 已连接 │ │ ● 已连接 │ │ ○ 断开 │ │
│ │ 127.0.0.1 │ │ 127.0.0.1 │ │ 10.0.0.1 │ │
│ │ :1883 │ │ :4222 │ │ :1883 │ │
│ │ 订阅: 12个主题│ │ 订阅: 10个 │ │ 订阅: 0个 │ │
│ │ [详情][断开] │ │ [详情][断开] │ │ [连接][删除] │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
组件: MiddlewareCard.vue
<!-- src/components/middleware/MiddlewareCard.vue -->
<template>
<div class="bg-gray-800 rounded-xl p-5 border border-gray-700 hover:border-blue-500
transition-all cursor-pointer" @click="$emit('detail', config.id)">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<Server class="w-5 h-5 text-blue-400" />
<span class="font-medium text-gray-100"></span>
</div>
<StatusBadge :status="config.status" />
</div>
<div class="text-sm text-gray-400 mb-1"></div>
<div class="text-sm text-gray-300 mb-3"></div>
<div class="text-xs text-gray-500 mb-4">
已订阅 个主题
</div>
<div class="flex gap-2">
<button v-if="config.status !== 'connected'" @click.stop="$emit('connect', config.id)"
class="flex-1 py-1.5 text-xs bg-blue-600 hover:bg-blue-500 rounded-lg text-white transition-colors">
连接
</button>
<button v-else @click.stop="$emit('disconnect', config.id)"
class="flex-1 py-1.5 text-xs bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-300 transition-colors">
断开
</button>
<button @click.stop="$emit('delete', config.id)"
class="px-3 py-1.5 text-xs bg-red-900/40 hover:bg-red-900/60 rounded-lg text-red-400 transition-colors">
删除
</button>
</div>
</div>
</template>
3.2 添加连接表单
职责: 用户通过表单添加 MQTT 或 NATS 连接配置,填写完成后点击「测试连接」验证,再「保存并连接」。
┌────────────────────────────────────────────────────┐
│ 添加消息总线 [×] │
├────────────────────────────────────────────────────┤
│ 连接名称 [___________________________] │
│ 协议类型 ○ edgeOS(MQTT) ○ edgeOS(NATS) │
│ │
│ ── MQTT 配置 ─────────────────────────────────── │
│ Broker 地址 [tcp://127.0.0.1 ] 端口 [1883] │
│ Client ID [edgeos-queen-001 ] │
│ 用户名 [__________________ ] │
│ 密码 [•••••••••••••••••• ] │
│ QoS 级别 [1 ▼] Keep Alive [60] 秒 │
│ □ 自动重连 □ 清理会话 □ 启用 TLS │
│ │
│ ──────────────────────────────────────────────── │
│ [测试连接] [取消] [保存并连接] │
└────────────────────────────────────────────────────┘
NATS 配置表单字段:
| 字段 | 输入类型 | 默认值 |
|---|---|---|
| URL | text | nats://127.0.0.1:4222 |
| Client Name | text | edgeos-queen |
| 用户名 | text | - |
| 密码 | password | - |
| Token | password | - |
| Reconnect Wait | number | 2 秒 |
| Max Reconnects | number | 10 |
| 启用 JetStream | checkbox | true |
| 启用 TLS | checkbox | false |
组件: AddMiddlewareModal.vue
<!-- src/components/middleware/AddMiddlewareModal.vue -->
<template>
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-gray-900 rounded-2xl w-[560px] border border-gray-700 shadow-2xl">
<div class="flex items-center justify-between p-6 border-b border-gray-700">
<h2 class="text-xl font-semibold text-gray-100">添加消息总线</h2>
<button @click="$emit('close')" class="text-gray-400 hover:text-gray-200">
<X class="w-5 h-5" />
</button>
</div>
<div class="p-6 space-y-4 max-h-[70vh] overflow-y-auto">
<!-- 基础信息 -->
<FormField label="连接名称" required>
<input v-model="form.name" type="text" class="form-input" placeholder="如:生产环境MQTT" />
</FormField>
<!-- 协议类型 -->
<FormField label="协议类型" required>
<div class="flex gap-4">
<label v-for="t in middlewareTypes" :key="t.value"
class="flex items-center gap-2 cursor-pointer">
<input type="radio" v-model="form.type" :value="t.value"
class="accent-blue-500" />
<span class="text-gray-300 text-sm"></span>
</label>
</div>
</FormField>
<!-- 动态表单 -->
<MQTTConfigForm v-if="form.type === 'edgeOS(MQTT)'" v-model="form.mqtt" />
<NATSConfigForm v-else v-model="form.nats" />
</div>
<div class="flex justify-between items-center p-6 border-t border-gray-700">
<button @click="testConnection" :disabled="testing"
class="flex items-center gap-2 px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600
rounded-lg text-gray-200 transition-colors">
<Zap class="w-4 h-4" />
</button>
<div class="flex gap-3">
<button @click="$emit('close')" class="btn-secondary">取消</button>
<button @click="submit" :disabled="submitting" class="btn-primary">
保存并连接
</button>
</div>
</div>
</div>
</div>
</template>
3.3 连接详情页(/middlewares/:id)
展示该连接的详细信息及已订阅的主题状态:
┌────────────────────────────────────────────────────────────┐
│ 生产 MQTT · 已连接 ● [断开] [编辑] │
├─────────────────────────┬──────────────────────────────────┤
│ 连接信息 │ 已订阅主题列表 │
│ 地址: tcp://127.0.0.1 │ edgex/nodes/register ● 活跃 │
│ 端口: 1883 │ edgex/nodes/unregister ● 活跃 │
│ ClientID: edgeos-001 │ edgex/devices/report ● 活跃 │
│ QoS: 1 │ edgex/points/report ● 活跃 │
│ │ edgex/data/# ● 活跃 │
│ 统计 │ edgex/nodes/+/heartbeat ● 活跃 │
│ 接收消息: 1,243 │ edgex/nodes/+/status ● 活跃 │
│ 发送消息: 87 │ edgex/events/alert ● 活跃 │
│ 重连次数: 0 │ edgex/responses/# ● 活跃 │
└─────────────────────────┴──────────────────────────────────┘
4. 功能二:EdgeX 节点注册
4.1 页面:节点列表(/nodes)
职责: 展示所有通过消息中间件注册的 EdgeX 节点,包含在线状态、能力、上次心跳时间。
┌──────────────────────────────────────────────────────────────────┐
│ EdgeX 节点 (共 3 个 · 在线 2 · 离线 1) [刷新] [触发发现] │
├──────────────────────────────────────────────────────────────────┤
│ 节点ID 名称 协议 状态 最后心跳 │
│ ─────────────────────────────────────────────────────────────── │
│ edgex-node-001 EdgeX Gateway edgeOS(MQTT) ● 在线 3s前 │
│ edgex-node-002 EdgeX Gateway2 edgeOS(NATS) ● 在线 8s前 │
│ edgex-node-003 老版网关 edgeOS(MQTT) ○ 离线 5m前 │
│ │
│ 点击行 → 查看节点详情 / 子设备列表 │
└──────────────────────────────────────────────────────────────────┘
节点注册事件实时通知: 当新节点通过 MQTT/NATS 发送 node_register 消息时,页面顶部弹出 Toast 通知:
┌──────────────────────────────────────┐
│ ● 新节点已注册: edgex-node-004 │
│ EdgeX Gateway Node · edgeOS(MQTT) │
│ [查看] │
└──────────────────────────────────────┘
4.2 页面:节点详情(/nodes/:nodeId)
┌────────────────────────────────────────────────────────────┐
│ ← 节点列表 / edgex-node-001 │
├────────────────┬───────────────────────────────────────────┤
│ 基本信息 │ 资源监控 │
│ ID: edgex-001 │ CPU: ████░░░░ 25% │
│ 名称: Gateway │ 内存: ██████░░ 512MB │
│ 协议: MQTT │ 磁盘: ███░░░░░ 45% │
│ 版本: 1.0.0 │ 活跃设备: 10 / 活跃任务: 5 │
│ 能力: │ │
│ shadow-sync │ 心跳序列: #100 在线时长: 1h │
│ heartbeat │ │
│ device-control │ ─────────────────────────────────────── │
│ task-execution │ 子设备 (10) [同步设备列表] [发现设备] │
├────────────────┴───────────────────────────────────────────┤
│ 子设备列表快览(最多展示5个,[查看全部]跳转设备列表) │
└────────────────────────────────────────────────────────────┘
4.3 节点状态流转
发送 node_register
↓
[注册中] → 注册成功 → [在线]
↓ 心跳超时 (3分钟无心跳)
[超时] → 自动标记 [离线]
↓ 发送 node_unregister
[已注销]
组件: NodeStatusBadge.vue — 展示带动画脉冲的在线状态点。
5. 功能三:子设备列表同步
5.1 页面:子设备列表(/nodes/:nodeId/devices)
职责: 展示指定节点下所有已同步的子设备,支持主动触发设备发现与手动同步。
┌─────────────────────────────────────────────────────────────────┐
│ ← edgex-node-001 / 子设备列表 (共 10 台) │
│ [触发设备发现] [请求同步] [刷新] │
├───────┬──────────────┬───────────┬──────┬───────────┬──────────┤
│ 设备ID │ 设备名称 │ 配置文件 │ 状态 │ 最后更新 │ 操作 │
├───────┼──────────────┼───────────┼──────┼───────────┼──────────┤
│dev-001│ Modbus TCP │modbus-tcp │● 在线 │ 1s前 │[点位][控制]│
│dev-002│ OPC-UA设备 │opc-ua │● 在线 │ 2s前 │[点位][控制]│
│dev-003│ BACnet设备 │bacnet │○ 离线 │ 5m前 │[点位][控制]│
└───────┴──────────────┴───────────┴──────┴───────────┴──────────┘
触发设备发现弹窗:
┌───────────────────────────────────┐
│ 触发设备发现 [×] │
│ │
│ 目标节点: edgex-node-001 │
│ 协议类型: [modbus-tcp ▼] │
│ 网络范围: [192.168.1.0/24 ] │
│ 超时时间: [30] 秒 │
│ □ 自动注册发现的设备 │
│ □ 立即同步 │
│ │
│ [取消] [发起发现] │
└───────────────────────────────────┘
5.2 设备同步实时反馈
收到 edgex/devices/report 消息时,设备列表自动刷新,新增设备闪烁高亮 2 秒:
<!-- src/views/DeviceListView.vue 关键逻辑 -->
<script setup lang="ts">
import { useDeviceStore } from '@/stores/device'
import { useRealtimeStore } from '@/stores/realtime'
const deviceStore = useDeviceStore()
const realtime = useRealtimeStore()
const newDeviceIds = ref<Set<string>>(new Set())
// 监听实时事件:设备同步
realtime.on('device_synced', (device: Device) => {
deviceStore.upsertDevice(device)
newDeviceIds.value.add(device.device_id)
setTimeout(() => newDeviceIds.value.delete(device.device_id), 2000)
})
</script>
6. 功能四:子设备点位同步(物模型)
6.1 数据加载策略:物模型驱动
点位列表页的数据来源分为两种,前端需同时处理:
| 来源 | 触发时机 | 内容 | 前端处理 |
|---|---|---|---|
| REST API | 页面首次加载 | 从后端获取当前存储的全量点位(含 current_value) |
初始化点位列表展示 |
WebSocket data_update 事件 |
后续实时推送 | is_full_snapshot=true 全量 / false 差量 |
Merge 到 Pinia store,组件响应式刷新 |
差量 Merge 原则:收到 data_update 事件后,只更新 payload 中出现的 point_id,未出现的点位保留上一次的值,不清空也不置为空。
6.2 页面:点位列表(/nodes/:nodeId/devices/:deviceId/points)
职责: 展示设备物模型(全量点位 + 实时值),区分只读(R)和可写(W/RW)点位,实时更新差量变化。
┌──────────────────────────────────────────────────────────────────────────┐
│ ← Room_FC_2014_19 (风机盘管) / 物模型点位 (共 9 个) │
│ [触发全量同步] [创建采集任务] │
├──────────────────┬──────────┬──────┬───────┬──────────────┬───────┬──────┤
│ 点位ID │ 点位名称 │ 类型 │ 访问 │ 当前值 │ 单位 │ 操作 │
├──────────────────┼──────────┼──────┼───────┼──────────────┼───────┼──────┤
│ SetPoint.Value │ 设定温度 │Float │ RW │ 20 ● │ °C │[写入]│
│ Setpoint.1 │ 设定点1 │Float │ RW │ 20 ● │ °C │[写入]│
│ Setpoint.2 │ 设定点2 │Float │ RW │ 20 ● │ °C │[写入]│
│ Setpoint.3 │ 设定点3 │Float │ RW │ 19 │ °C │[写入]│
│ State.Chiller │ 制冷状态 │Int │ R │ 0 │ - │[图表]│
│ State.Heater │ 制热状态 │Int │ R │ 1 │ - │[图表]│
│ Temperature.Indoor│室内温度 │Float │ R │ 18.8 │ °C │[图表]│
│ Temperature.Outdoor│室外温度 │Float │ R │ 12 │ °C │[图表]│
│ Temperature.Water│水管温度 │Float │ R │ 39.7 │ °C │[图表]│
└──────────────────┴──────────┴──────┴───────┴──────────────┴───────┴──────┘
● 绿点 = 本轮差量更新的点位(闪烁 1.5s) 无点 = 自上次全量后未变化
数据质量 Good/Uncertain/Bad 用行底色区分
「触发全量同步」按钮:向后端发送 POST,后端向 EdgeX 发布
edgex/cmd/{node_id}/sync,触发 EdgeX 重新全量上报物模型数据。
6.3 点位实时卡片视图(切换)
用户可在表格视图与卡片视图之间切换:
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ SetPoint.Value │ │ Temperature.Indoor│ │ State.Heater │
│ 设定温度 │ │ 室内温度 │ │ 制热状态 │
│ │ │ │ │ │
│ 20 °C ● │ │ 18.8 °C │ │ 1 (开启) │
│ ████░░░░ 20/30 │ │ ████░░░░ 18.8 │ │ │
│ 范围: 16~30 │ │ R · Float32 │ │ R · Int │
│ RW · Float32 │ │ 3s前更新 │ │ 未变化 │
│ [立即写入] │ │ [图表] │ │ [图表] │
└──────────────────┘ └──────────────────┘ └──────────────────┘
6.4 实时差量更新的前端 Store
// src/stores/realtime.ts
import { defineStore } from 'pinia'
import type { DataUpdatePayload } from '@/types/realtime'
export const useRealtimeStore = defineStore('realtime', {
state: () => ({
// key = `${nodeId}/${deviceId}` → 全量物模型缓存(初始化后持续 Merge)
deviceSnapshot: {} as Record<string, Record<string, {
value: unknown
ts: number
quality: string
changed: boolean // 标记本轮差量中是否刚更新(用于 UI 高亮)
}>>,
alertCount: 0,
}),
actions: {
// 处理 data_update WebSocket 事件
updateData(payload: DataUpdatePayload) {
const key = `${payload.node_id}/${payload.device_id}`
if (payload.is_full_snapshot) {
// 全量:重建整个设备快照
const snap: typeof this.deviceSnapshot[string] = {}
for (const [pointId, value] of Object.entries(payload.points)) {
snap[pointId] = { value, ts: payload.timestamp, quality: payload.quality, changed: true }
}
this.deviceSnapshot[key] = snap
} else {
// 差量:仅 Merge 出现的点位,不删除未出现的
if (!this.deviceSnapshot[key]) this.deviceSnapshot[key] = {}
// 先将上一轮的 changed 标记清除
for (const p of Object.values(this.deviceSnapshot[key])) p.changed = false
for (const [pointId, value] of Object.entries(payload.points)) {
this.deviceSnapshot[key][pointId] = {
value,
ts: payload.timestamp,
quality: payload.quality,
changed: true, // 本轮差量更新,UI 高亮 1.5s
}
}
}
},
getPointValue(nodeId: string, deviceId: string, pointId: string) {
return this.deviceSnapshot[`${nodeId}/${deviceId}`]?.[pointId]
},
getDeviceSnapshot(nodeId: string, deviceId: string) {
return this.deviceSnapshot[`${nodeId}/${deviceId}`] ?? {}
},
incrementAlertCount() { this.alertCount++ },
clearAlertCount() { this.alertCount = 0 },
},
})
6.5 点位行差量高亮逻辑
<!-- src/components/point/PointTable.vue 关键片段 -->
<script setup lang="ts">
import { useRealtimeStore } from '@/stores/realtime'
const realtimeStore = useRealtimeStore()
// 订阅 data_update 事件(由 useWebSocket composable 注入)
realtime.on('data_update', (payload: DataUpdatePayload) => {
realtimeStore.updateData(payload)
})
// 点位行是否本轮刚更新(差量高亮)
const isChanged = (pointId: string) =>
realtimeStore.getPointValue(props.nodeId, props.deviceId, pointId)?.changed ?? false
</script>
<template>
<tr v-for="point in points" :key="point.point_id"
:class="isChanged(point.point_id)
? 'bg-blue-900/30 transition-colors duration-1500'
: 'bg-transparent'">
<td></td>
<td></td>
<td></td>
<td></td>
<td class="font-mono">
<span v-if="isChanged(point.point_id)"
class="inline-block w-2 h-2 rounded-full bg-green-400 animate-pulse ml-1" />
</td>
<td></td>
<td>
<button v-if="point.access_mode?.includes('W')" @click="openWriteModal(point)">写入</button>
<button v-else @click="openChart(point)">图表</button>
</td>
</tr>
</template>
6.6 点位历史图表
┌──────────────────────────────────────────────────┐
│ Temperature.Indoor 历史曲线 最近 1 小时 [×] │
│ │
│ 20 ───────────────────────────────── │
│ 19 ────╮ ╭────────────╮ │
│ 18 ────╯╮ ╭╯ ╰────────────── │
│ 17 ─────╰──╯ │
│ T1 T2 T3 T4 T5 │
│ 当前: 18.8°C 最高: 19.5°C 最低: 17.2°C │
└──────────────────────────────────────────────────┘
6.7 创建采集任务
┌───────────────────────────────────────────────────┐
│ 创建采集任务 [×] │
│ │
│ 任务名称: [Room_FC_2014_19 采集 ] │
│ 目标设备: Room_FC_2014_19 │
│ 采集点位: ☑ SetPoint.Value ☑ Temperature.Indoor │
│ □ State.Chiller □ Temperature.Water │
│ 采集方式: ● 定时间隔 ○ 变化触发 │
│ 间隔时间: [5] 秒 │
│ 批量大小: [10] 最大重试: [3] │
│ │
│ [取消] [创建并启动] │
└───────────────────────────────────────────────────┘
7. 功能五:子设备双向控制
7.1 页面:控制面板(/control 或 /control/:nodeId/:deviceId)
职责: 提供统一的命令下发界面,支持写入点位值、任务控制,并实时展示命令执行结果。
┌─────────────────────────────────────────────────────────────────────┐
│ 设备控制面板 │
├────────────────────────┬────────────────────────────────────────────┤
│ 选择目标 │ 控制操作 │
│ │ │
│ 节点: [edgex-node-001▼]│ ── 写入点位 ───────────────────────────── │
│ 设备: [dev-001 ▼]│ 点位名称: [Switch ▼] │
│ │ 写入值: [true ] │
│ 设备状态: ● 在线 │ [写入并等待响应] │
│ │ │
│ 可写点位 (3/12): │ ── 任务控制 ──────────────────────────── │
│ ● Switch RW Bool │ 任务 task-001 [Temperature Coll...] │
│ ● Setpoint W Float │ 状态: ● 运行中 │
│ ● Relay W Bool │ [暂停] [恢复] [停止] │
│ │ │
│ [←] [→] 分页 │ ── 配置更新 ───────────────────────────── │
│ │ [更新节点配置] │
└────────────────────────┴────────────────────────────────────────────┘
7.2 命令执行状态追踪
每次下发控制命令后,在页面右侧展示「命令执行记录」面板:
┌─────────────────────────────────────────────────┐
│ 命令执行记录 [清空] │
│ │
│ 14:05:32 写入命令 Switch=true │
│ → 发送中... ⟳ │
│ │
│ 14:05:28 写入命令 Setpoint=80.5 │
│ ✓ 执行成功 延迟: 145ms │
│ │
│ 14:05:10 写入命令 Switch=false │
│ ✗ 执行失败: Connection timeout │
│ [重试] │
│ │
│ 14:04:55 任务控制 task-001 暂停 │
│ ✓ 执行成功 延迟: 89ms │
└─────────────────────────────────────────────────┘
组件: CommandLogPanel.vue
<!-- src/components/control/CommandLogPanel.vue -->
<template>
<div class="bg-gray-900 rounded-xl border border-gray-700 h-full flex flex-col">
<div class="flex items-center justify-between p-4 border-b border-gray-700">
<span class="font-medium text-gray-200">命令执行记录</span>
<button @click="commandStore.clearLogs()" class="text-xs text-gray-500 hover:text-gray-300">
清空
</button>
</div>
<div class="flex-1 overflow-y-auto p-3 space-y-3">
<div v-for="log in commandStore.logs" :key="log.id"
class="p-3 rounded-lg border text-sm"
:class="{
'border-yellow-700/50 bg-yellow-900/20': log.status === 'pending',
'border-green-700/50 bg-green-900/20': log.status === 'success',
'border-red-700/50 bg-red-900/20': log.status === 'error',
}">
<div class="flex items-center justify-between mb-1">
<span class="text-gray-400 text-xs"></span>
<span class="text-xs font-medium"
:class="{
'text-yellow-400': log.status === 'pending',
'text-green-400': log.status === 'success',
'text-red-400': log.status === 'error',
}">
</span>
</div>
<div class="text-gray-200"> → </div>
<div v-if="log.status === 'success'" class="text-xs text-gray-500 mt-1">
延迟: ms
</div>
<div v-if="log.status === 'error'" class="text-xs text-red-400 mt-1">
<button @click="retryCommand(log)" class="ml-2 underline hover:no-underline">重试</button>
</div>
</div>
</div>
</div>
</template>
7.3 写入点位弹窗(快速控制)
在点位列表页点击「写入」按钮触发:
┌──────────────────────────────────┐
│ 写入点位 [×] │
│ │
│ 设备: dev-001 (Modbus TCP) │
│ 点位: Switch (Bool · RW) │
│ │
│ 写入值: ●——○ false → true │
│ │
│ □ 等待执行确认 (超时 10 秒) │
│ │
│ [取消] [确认写入] │
└──────────────────────────────────┘
对于数值型点位(Float/Int),展示数字输入框并显示范围限制:
┌──────────────────────────────────┐
│ 写入点位 [×] │
│ │
│ 设备: dev-001 │
│ 点位: Setpoint (Float32 · W) │
│ 范围: 0 ~ 100 单位: °C │
│ │
│ 写入值: [80.5 ] │
│ │
│ [取消] [确认写入] │
└──────────────────────────────────┘
8. 实时通信方案
8.1 架构
EdgeOS 后端 (Go)
│
│ WebSocket / SSE (HTTP /api/v1/ws 或 /api/v1/sse)
↓
EdgeOS 前端 (Vue)
│
↓ 事件分发
useRealtimeStore (Pinia) → 各页面组件响应式更新
8.2 事件类型定义
// src/types/realtime.ts
export type RealtimeEventType =
| 'node_registered' // 新节点注册
| 'node_offline' // 节点离线
| 'node_heartbeat' // 心跳更新
| 'device_synced' // 设备列表同步
| 'point_synced' // 点位元数据同步
| 'data_update' // 实时数据更新
| 'command_response' // 命令执行响应
| 'alert' // 告警事件
| 'middleware_status' // 消息总线状态变化
export interface RealtimeEvent<T = unknown> {
type: RealtimeEventType
timestamp: number
payload: T
}
export interface DataUpdatePayload {
node_id: string
device_id: string
points: Record<string, number | boolean | string> // 全量或差量点位
timestamp: number
quality: 'Good' | 'Bad' | 'Uncertain'
is_full_snapshot: boolean // true=物模型首次全量上报,false=差量 Merge
}
export interface CommandResponsePayload {
request_id: string
node_id: string
device_id: string
success: boolean
message: string
latency_ms: number
}
export interface AlertPayload {
alert_id: string
node_id: string
device_id: string
alert_type: string
severity: 'info' | 'warning' | 'error' | 'critical'
message: string
timestamp: number
}
8.3 WebSocket Composable
// src/composables/useWebSocket.ts
import { ref, onMounted, onUnmounted } from 'vue'
import type { RealtimeEvent } from '@/types/realtime'
export function useWebSocket() {
const ws = ref<WebSocket | null>(null)
const connected = ref(false)
const handlers = new Map<string, Set<(payload: unknown) => void>>()
function connect() {
ws.value = new WebSocket(`ws://${location.host}/api/v1/ws`)
ws.value.onopen = () => { connected.value = true }
ws.value.onclose = () => {
connected.value = false
setTimeout(connect, 3000) // 自动重连
}
ws.value.onmessage = (event) => {
const evt: RealtimeEvent = JSON.parse(event.data)
const set = handlers.get(evt.type)
if (set) set.forEach(fn => fn(evt.payload))
}
}
function on(type: string, fn: (payload: unknown) => void) {
if (!handlers.has(type)) handlers.set(type, new Set())
handlers.get(type)!.add(fn)
return () => handlers.get(type)?.delete(fn)
}
onMounted(connect)
onUnmounted(() => ws.value?.close())
return { connected, on }
}
9. 全局状态管理
9.1 中间件 Store
// src/stores/middleware.ts
import { defineStore } from 'pinia'
import type { MiddlewareConfig } from '@/types/middleware'
import * as middlewareApi from '@/api/middleware'
export const useMiddlewareStore = defineStore('middleware', {
state: () => ({
list: [] as MiddlewareConfig[],
loading: false,
}),
getters: {
connectedCount: (s) => s.list.filter(m => m.status === 'connected').length,
getById: (s) => (id: string) => s.list.find(m => m.id === id),
},
actions: {
async fetchAll() {
this.loading = true
this.list = await middlewareApi.getAll()
this.loading = false
},
async addMiddleware(cfg: Omit<MiddlewareConfig, 'id' | 'status'>) {
const result = await middlewareApi.create(cfg)
this.list.push(result)
return result
},
async connect(id: string) {
const cfg = await middlewareApi.connect(id)
this.upsert(cfg)
},
async disconnect(id: string) {
const cfg = await middlewareApi.disconnect(id)
this.upsert(cfg)
},
async remove(id: string) {
await middlewareApi.remove(id)
this.list = this.list.filter(m => m.id !== id)
},
upsert(cfg: MiddlewareConfig) {
const idx = this.list.findIndex(m => m.id === cfg.id)
if (idx >= 0) this.list[idx] = cfg
else this.list.push(cfg)
},
updateStatus(id: string, status: MiddlewareConfig['status']) {
const m = this.list.find(m => m.id === id)
if (m) m.status = status
},
},
})
9.2 节点 Store
// src/stores/node.ts
import { defineStore } from 'pinia'
import type { Node } from '@/types/node'
import * as nodeApi from '@/api/node'
export const useNodeStore = defineStore('node', {
state: () => ({
list: [] as Node[],
loading: false,
}),
getters: {
onlineNodes: (s) => s.list.filter(n => n.status === 'online'),
offlineNodes: (s) => s.list.filter(n => n.status === 'offline'),
},
actions: {
async fetchAll() {
this.loading = true
this.list = await nodeApi.getAll()
this.loading = false
},
upsertNode(node: Node) {
const idx = this.list.findIndex(n => n.id === node.id)
if (idx >= 0) this.list[idx] = { ...this.list[idx], ...node }
else this.list.push(node)
},
markOffline(nodeId: string) {
const node = this.list.find(n => n.id === nodeId)
if (node) node.status = 'offline'
},
updateHeartbeat(nodeId: string, metrics: Node['metrics']) {
const node = this.list.find(n => n.id === nodeId)
if (node) {
node.last_heartbeat = Date.now()
node.metrics = metrics
node.status = 'online'
}
},
},
})
9.3 点位实时数据 Store
完整实现见 § 6.4 实时差量更新的前端 Store,核心策略如下:
is_full_snapshot=true:重建整个设备的物模型快照(替换)is_full_snapshot=false:仅 Merge 本次 payload 中出现的 point_id,未出现的点位保留上次值- 每个点位条目附带
changed: boolean标记,供 UI 差量高亮动画使用(1.5s 后清除)
// src/stores/realtime.ts ← 完整代码见 §6.4
// 关键接口摘要:
realtimeStore.updateData(payload: DataUpdatePayload) // 全量或差量 Merge
realtimeStore.getPointValue(nodeId, deviceId, pointId) // 获取点位最新值+changed标记
realtimeStore.getDeviceSnapshot(nodeId, deviceId) // 获取设备物模型完整快照
9.4 命令 Store
// src/stores/command.ts
import { defineStore } from 'pinia'
import type { CommandLog } from '@/types/command'
import * as controlApi from '@/api/control'
export const useCommandStore = defineStore('command', {
state: () => ({
logs: [] as CommandLog[],
}),
actions: {
async writePoints(nodeId: string, deviceId: string, points: Record<string, unknown>) {
const log: CommandLog = {
id: crypto.randomUUID(),
type: 'write',
nodeId, deviceId,
command: `写入: ${JSON.stringify(points)}`,
target: `${nodeId}/${deviceId}`,
status: 'pending',
timestamp: Date.now(),
}
this.logs.unshift(log)
const result = await controlApi.writePoints(nodeId, deviceId, points)
const idx = this.logs.findIndex(l => l.id === log.id)
if (idx >= 0) {
this.logs[idx] = {
...this.logs[idx],
status: result.success ? 'success' : 'error',
latencyMs: result.latency_ms,
errorMessage: result.message,
}
}
return result
},
clearLogs() { this.logs = [] },
},
})
10. 类型定义
// src/types/middleware.ts
export interface MiddlewareConfig {
id: string
name: string
type: 'edgeOS(MQTT)' | 'edgeOS(NATS)'
description?: string
mqtt?: MQTTConfig
nats?: NATSConfig
status: 'connected' | 'disconnected' | 'connecting' | 'error'
created_at: number
updated_at: number
}
export interface MQTTConfig {
broker: string
client_id: string
username: string
password: string
qos: 0 | 1 | 2
clean_session: boolean
keep_alive: number
connect_timeout: number
auto_reconnect: boolean
tls_enabled: boolean
}
export interface NATSConfig {
url: string
client_name: string
username: string
password: string
token?: string
reconnect_wait: number
max_reconnects: number
jetstream_enabled: boolean
tls_enabled: boolean
}
// src/types/node.ts
export interface Node {
id: string
name: string
model: string
version: string
capabilities: string[]
protocol: string
endpoint: { host: string; port: number }
metadata: Record<string, string>
status: 'online' | 'offline' | 'error'
metrics?: NodeMetrics
registered_at: number
last_heartbeat: number
}
export interface NodeMetrics {
cpu_usage: number
memory_usage: number
disk_usage: number
active_devices: number
active_tasks: number
}
// src/types/device.ts
export interface Device {
node_id: string
device_id: string
device_name: string
device_profile: string
service_name: string
labels: string[]
description: string
admin_state: 'ENABLED' | 'DISABLED'
operating_state: 'ENABLED' | 'DISABLED'
properties: Record<string, unknown>
status: 'online' | 'offline'
created_at: number
updated_at: number
}
// src/types/point.ts
export interface Point {
node_id: string
device_id: string
point_id: string
point_name: string
resource_name: string
value_type: string
access_mode: 'R' | 'W' | 'RW'
unit: string
minimum?: number
maximum?: number
address: string
data_type: string
scale: number
offset: number
current_value?: unknown
quality?: 'good' | 'bad' | 'uncertain'
last_updated?: number
}
// src/types/command.ts
export interface CommandLog {
id: string
type: 'write' | 'task_control' | 'discover' | 'config'
nodeId: string
deviceId: string
command: string
target: string
status: 'pending' | 'success' | 'error'
timestamp: number
latencyMs?: number
errorMessage?: string
}
11. API 服务层
// src/api/middleware.ts
import { http } from './http'
import type { MiddlewareConfig } from '@/types/middleware'
const BASE = '/api/v1/middlewares'
export const getAll = () => http.get<MiddlewareConfig[]>(BASE)
export const getById = (id: string) => http.get<MiddlewareConfig>(`${BASE}/${id}`)
export const create = (data: Partial<MiddlewareConfig>) => http.post<MiddlewareConfig>(BASE, data)
export const update = (id: string, data: Partial<MiddlewareConfig>) => http.put<MiddlewareConfig>(`${BASE}/${id}`, data)
export const remove = (id: string) => http.delete(`${BASE}/${id}`)
export const connect = (id: string) => http.post<MiddlewareConfig>(`${BASE}/${id}/connect`)
export const disconnect = (id: string) => http.post<MiddlewareConfig>(`${BASE}/${id}/disconnect`)
export const testConnection = (data: Partial<MiddlewareConfig>) => http.post<{ success: boolean; message: string }>(`${BASE}/test`, data)
// src/api/node.ts
const NODE_BASE = '/api/v1/nodes'
export const getAll = () => http.get<Node[]>(NODE_BASE)
export const getById = (id: string) => http.get<Node>(`${NODE_BASE}/${id}`)
export const triggerDiscover = (nodeId: string, opts: object) => http.post(`${NODE_BASE}/${nodeId}/discover`, opts)
// src/api/device.ts
export const list = (nodeId: string) => http.get<Device[]>(`/api/v1/nodes/${nodeId}/devices`)
export const getDetail = (nodeId: string, deviceId: string) => http.get<Device>(`/api/v1/nodes/${nodeId}/devices/${deviceId}`)
export const triggerSync = (nodeId: string) => http.post(`/api/v1/nodes/${nodeId}/devices/sync`)
// src/api/point.ts
export const list = (nodeId: string, deviceId: string) => http.get<Point[]>(`/api/v1/nodes/${nodeId}/devices/${deviceId}/points`)
export const syncPoints = (nodeId: string, deviceId: string) => http.post(`/api/v1/nodes/${nodeId}/devices/${deviceId}/points/sync`)
export const createTask = (nodeId: string, data: object) => http.post(`/api/v1/nodes/${nodeId}/tasks`, data)
// src/api/control.ts
export const writePoints = (nodeId: string, deviceId: string, points: Record<string, unknown>) =>
http.post<{ success: boolean; latency_ms: number; message: string }>(
`/api/v1/nodes/${nodeId}/devices/${deviceId}/write`, { points }
)
export const controlTask = (nodeId: string, taskId: string, action: 'pause' | 'resume' | 'stop') =>
http.put(`/api/v1/nodes/${nodeId}/tasks/${taskId}/${action}`)
12. 组件复用规范
12.1 通用组件
| 组件 | 路径 | 说明 |
|---|---|---|
StatusBadge |
components/common/StatusBadge.vue |
带颜色点的状态标签 |
PulsingDot |
components/common/PulsingDot.vue |
动态脉冲在线指示 |
FormField |
components/common/FormField.vue |
带 label + 验证的表单行 |
DataQualityDot |
components/common/DataQualityDot.vue |
数据质量指示点 |
CommandResultToast |
components/common/CommandResultToast.vue |
命令执行结果通知 |
ConfirmModal |
components/common/ConfirmModal.vue |
二次确认弹窗 |
12.2 业务组件
| 组件 | 路径 | 说明 |
|---|---|---|
MiddlewareCard |
components/middleware/MiddlewareCard.vue |
连接卡片 |
AddMiddlewareModal |
components/middleware/AddMiddlewareModal.vue |
添加连接弹窗 |
MQTTConfigForm |
components/middleware/MQTTConfigForm.vue |
MQTT 配置表单 |
NATSConfigForm |
components/middleware/NATSConfigForm.vue |
NATS 配置表单 |
NodeCard |
components/node/NodeCard.vue |
节点卡片 |
NodeMetricsBar |
components/node/NodeMetricsBar.vue |
节点资源监控条 |
DeviceTable |
components/device/DeviceTable.vue |
设备列表表格 |
PointTable |
components/point/PointTable.vue |
点位列表表格 |
PointCard |
components/point/PointCard.vue |
点位实时数据卡片 |
WritePointModal |
components/control/WritePointModal.vue |
写入点位弹窗 |
CommandLogPanel |
components/control/CommandLogPanel.vue |
命令执行记录 |
TaskControlPanel |
components/control/TaskControlPanel.vue |
采集任务控制 |
AlertBanner |
components/alert/AlertBanner.vue |
告警横幅 |
RealtimeDataChart |
components/chart/RealtimeDataChart.vue |
实时/历史折线图 |
12.3 视图页面
| 路径 | 视图文件 | 说明 |
|---|---|---|
/dashboard |
views/DashboardView.vue |
总览仪表盘 |
/middlewares |
views/MiddlewareListView.vue |
消息总线列表 |
/middlewares/:id |
views/MiddlewareDetailView.vue |
连接详情 |
/nodes |
views/NodeListView.vue |
节点列表 |
/nodes/:nodeId |
views/NodeDetailView.vue |
节点详情 |
/nodes/:nodeId/devices |
views/DeviceListView.vue |
子设备列表 |
/nodes/:nodeId/devices/:deviceId/points |
views/PointListView.vue |
点位列表 |
/control |
views/ControlView.vue |
双向控制面板 |
/alerts |
views/AlertListView.vue |
告警列表 |
/settings |
views/SettingsView.vue |
系统设置 |
文档版本: v2.0
最后更新: 2026-04-16
维护者: edgeOS 团队