diff --git a/apps/web-antd/.env.development b/apps/web-antd/.env.development index 7f1129b..137a5e8 100644 --- a/apps/web-antd/.env.development +++ b/apps/web-antd/.env.development @@ -22,4 +22,7 @@ VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuP VITE_GLOB_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e # 开启SSE -VITE_GLOB_SSE_ENABLE=true +VITE_GLOB_SSE_ENABLE=false +#开始websocket +VITE_APP_WEBSOCKET=true + diff --git a/apps/web-antd/.env.production b/apps/web-antd/.env.production index 0c30914..8e07a13 100644 --- a/apps/web-antd/.env.production +++ b/apps/web-antd/.env.production @@ -28,5 +28,7 @@ VITE_GLOB_RSA_PRIVATE_KEY=MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAmc3CuP VITE_GLOB_APP_CLIENT_ID=e5cd7e4891bf95d1d19206ce24a7b32e # 开启SSE -VITE_GLOB_SSE_ENABLE=true +VITE_GLOB_SSE_ENABLE=false +#开始websocket +VITE_APP_WEBSOCKET=true diff --git a/apps/web-antd/package.json b/apps/web-antd/package.json index ddbbe8b..110d9bc 100644 --- a/apps/web-antd/package.json +++ b/apps/web-antd/package.json @@ -52,6 +52,7 @@ "lodash-es": "^4.17.21", "monaco-editor": "^0.52.2", "pinia": "catalog:", + "rxjs": "^7.8.2", "tinymce": "^7.3.0", "unplugin-vue-components": "^0.27.3", "vite-plugin-monaco-editor": "^1.1.0", diff --git a/apps/web-antd/src/api/device/device/index.ts b/apps/web-antd/src/api/device/device/index.ts index 370739f..d8271c9 100644 --- a/apps/web-antd/src/api/device/device/index.ts +++ b/apps/web-antd/src/api/device/device/index.ts @@ -60,3 +60,32 @@ export function deviceUpdate(data: DeviceForm) { export function deviceRemove(id: ID | IDS) { return requestClient.deleteWithMsg(`/device/device/${id}`); } + +// 设备模拟 + +/** + * 设备属性上报 + * @param data + * @returns void + */ +export function deviceOperateReport(data: any) { + return requestClient.postWithMsg('/device/operate/mockReport', data); +} + +/** + * 设备功能下发 + * @param data + * @returns void + */ +export function deviceOperateFunc(data: any) { + return requestClient.postWithMsg('/device/operate/func', data); +} + +/** + * 设备事件上报 + * @param data + * @returns void + */ +export function deviceOperateEvent(data: any) { + return requestClient.postWithMsg('/device/operate/mockEvent', data); +} diff --git a/apps/web-antd/src/utils/websocket.ts b/apps/web-antd/src/utils/websocket.ts new file mode 100644 index 0000000..2517645 --- /dev/null +++ b/apps/web-antd/src/utils/websocket.ts @@ -0,0 +1,195 @@ +import { useAppConfig } from '@vben/hooks'; +import { SvgMessageUrl } from '@vben/icons'; +import { $t } from '@vben/locales'; +import { useAccessStore, useUserStore } from '@vben/stores'; + +import { notification } from 'ant-design-vue'; +import dayjs from 'dayjs'; +import { Observable } from 'rxjs'; + +import { useNotifyStore } from '#/store/notify'; + +// 全局变量(原生 WebSocket 单例) +let ws: null | WebSocket = null; +let reconnectCount = 0; // 重连计数 +let reconnectTimer: any = null; +let heartTimer: any = null; +let lockReconnect = false; // 避免重复连接 +const maxReconnect = 100; // 重连总次数 +const backoffBaseMs = 5000; + +// 订阅管理:requestId -> subscriber handlers +const subs: Record< + string, + Array<{ complete: () => void; next: (v: any) => void }> +> = {}; +// 未连接时缓存要发送的消息 +const tempQueue: string[] = []; + +// 初始化 socket(原生 WebSocket) +export const initWebSocket = () => { + if (import.meta.env.VITE_APP_WEBSOCKET === 'false') { + return; + } + if (ws) { + return ws; + } + + const { apiURL, clientId } = useAppConfig( + import.meta.env, + import.meta.env.PROD, + ); + + const userStore = useUserStore(); + const accessStore = useAccessStore(); + const token = encodeURIComponent(accessStore.accessToken || ''); + + const url = `${document.location.protocol.replace('http', 'ws')}//${document.location.host}${apiURL}/resource/websocket?Authorization=Bearer ${token}&clientid=${clientId}`; + + if (reconnectCount >= maxReconnect) return; + reconnectCount += 1; + + ws = new WebSocket(url); + + ws.addEventListener('open', () => { + reconnectCount = 0; + // 心跳 + heartTimer && clearInterval(heartTimer); + heartTimer = setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })); + } + }, 10_000); + + // flush queue + if (ws && ws.readyState === WebSocket.OPEN && tempQueue.length > 0) { + while (tempQueue.length > 0) { + const payload = tempQueue.pop(); + if (payload) ws.send(payload); + } + } + }); + + ws.addEventListener('close', () => { + ws = null; + reconnect(); + }); + + ws.addEventListener('error', () => { + ws = null; + reconnect(); + }); + + ws.addEventListener('message', (e: MessageEvent) => { + const raw = e.data; + if (typeof raw === 'string' && raw.includes('ping')) return; + + let data: any; + try { + data = JSON.parse(raw); + } catch { + return; + } + + if (data?.type === 'error') { + notification.error({ + key: 'ws-error', + message: data.message || 'WebSocket错误', + }); + return; + } + + // 通知类消息(可选) + if (data?.type === 'notification') { + useNotifyStore().notificationList.unshift({ + message: data.message || raw, + avatar: SvgMessageUrl, + date: dayjs().format('YYYY-MM-DD HH:mm:ss'), + isRead: false, + title: $t('component.notice.title'), + userId: userStore.userInfo?.userId || '0', + }); + notification.success({ + description: data.message || raw, + duration: 3, + message: $t('component.notice.received'), + }); + return; + } + + const requestId: string = + typeof data?.requestId === 'string' ? data.requestId : ''; + if (!requestId) return; + + const list = subs[requestId]; + if (!Array.isArray(list) || list.length === 0) return; + + if (data.type === 'complete') { + list.forEach((s) => s.complete()); + return; + } + if (data.type === 'result') { + list.forEach((s) => s.next(data)); + } + }); + + return ws; +}; + +function reconnect() { + heartTimer && clearInterval(heartTimer); + if (lockReconnect) return; + lockReconnect = true; + + reconnectTimer && clearTimeout(reconnectTimer); + reconnectTimer = setTimeout(() => { + initWebSocket(); + lockReconnect = false; + }, backoffBaseMs * reconnectCount); +} + +export const getWebSocket = ( + id: string, + topic: string, + parameter: Record, +) => + new Observable( + (subscriber: { complete: () => void; next: (v: any) => void }) => { + if (!subs[id]) subs[id] = []; + const handle = { + next: (val: any) => subscriber.next(val), + complete: () => subscriber.complete(), + }; + subs[id].push(handle); + + const msg = JSON.stringify({ id, topic, parameter, type: 'sub' }); + initWebSocket(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(msg); + } else { + tempQueue.push(msg); + } + + return () => { + const unsub = JSON.stringify({ id, type: 'unsub' }); + const list = subs[id]; + if (Array.isArray(list)) { + const idx = list.indexOf(handle); + if (idx !== -1) list.splice(idx, 1); + if (list.length === 0) subs[id] = []; + } + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(unsub); + } + }; + }, + ); + +export const closeWs = () => { + heartTimer && clearInterval(heartTimer); + reconnectTimer && clearTimeout(reconnectTimer); + if (ws) { + ws.close(); + ws = null; + } +}; diff --git a/apps/web-antd/src/views/device/device/data.ts b/apps/web-antd/src/views/device/device/data.ts index 1bacd3a..ad29f8a 100644 --- a/apps/web-antd/src/views/device/device/data.ts +++ b/apps/web-antd/src/views/device/device/data.ts @@ -55,7 +55,7 @@ export const columns: VxeGridProps['columns'] = [ }, { title: '所属产品', - field: 'productName', + field: 'productObj.productName', }, { title: '设备类型', diff --git a/apps/web-antd/src/views/device/device/detail/components/DeviceSimulation.vue b/apps/web-antd/src/views/device/device/detail/components/DeviceSimulation.vue index 0ca9811..ef17eca 100644 --- a/apps/web-antd/src/views/device/device/detail/components/DeviceSimulation.vue +++ b/apps/web-antd/src/views/device/device/detail/components/DeviceSimulation.vue @@ -3,7 +3,9 @@ import { computed, ref } from 'vue'; import { TabPane, Tabs } from 'ant-design-vue'; +import EventSimulation from './simulation/EventSimulation.vue'; import FunctionSimulation from './simulation/FunctionSimulation.vue'; +import PropertySimulation from './simulation/PropertySimulation.vue'; interface Props { deviceId: string; @@ -13,20 +15,34 @@ interface Props { const props = defineProps(); // 当前激活的tab -const activeTab = ref('function'); +const activeTab = ref('property'); // 获取物模型数据,优先使用设备信息中的物模型,否则使用模拟数据 const getMetadata = () => { try { const raw = props.deviceInfo?.productObj?.metadata; - if (!raw) return { functions: [] } as any; + if (!raw) + return { + functions: [], + properties: [], + propertyGroups: [], + events: [], + } as any; const obj = JSON.parse(raw || '{}'); return { functions: obj?.functions || [], + properties: obj?.properties || [], + propertyGroups: obj?.propertyGroups || [], + events: obj?.events || [], }; } catch (error) { console.warn('parse metadata error', error); - return { functions: [] } as any; + return { + functions: [], + properties: [], + propertyGroups: [], + events: [], + } as any; } }; @@ -37,13 +53,22 @@ const metadata = computed(() => getMetadata()); const functionList = computed(() => { return metadata.value.functions || []; }); + +// 事件列表 +const eventList = computed(() => { + return metadata.value.events || []; +});