From fdf66164d89da704e15cd617a53471678976fc9e Mon Sep 17 00:00:00 2001 From: "Ray.Hao" <1490493387@qq.com> Date: Tue, 22 Apr 2025 22:15:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20:sparkles:=20=E5=AD=97=E5=85=B8?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E5=90=8C=E6=AD=A5=E5=92=8C=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=9C=A8=E7=BA=BF=E7=94=A8=E6=88=B7=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/system/dict.api.ts | 14 +- src/components/Dict/DictLabel.vue | 11 + src/components/Dict/index.vue | 23 +- src/hooks/useStomp.ts | 97 +++-- src/hooks/useWebSocketDict.ts | 180 ++++++++ src/hooks/useWebSocketOnlineUsers.ts | 176 ++++++++ src/plugins/index.ts | 3 + src/plugins/websocket.ts | 39 ++ src/store/modules/dict.store.ts | 11 + src/styles/reset.scss | 5 +- src/types/websocket.ts | 15 + src/views/dashboard/index.vue | 73 +++- src/views/demo/dict-sync.vue | 308 ++++++++++++++ src/views/system/websocket/websocket-test.vue | 390 ++++++++++++++++++ 14 files changed, 1275 insertions(+), 70 deletions(-) create mode 100644 src/hooks/useWebSocketDict.ts create mode 100644 src/hooks/useWebSocketOnlineUsers.ts create mode 100644 src/plugins/websocket.ts create mode 100644 src/types/websocket.ts create mode 100644 src/views/demo/dict-sync.vue create mode 100644 src/views/system/websocket/websocket-test.vue diff --git a/src/api/system/dict.api.ts b/src/api/system/dict.api.ts index 3a3d4da9..2c2d9f86 100644 --- a/src/api/system/dict.api.ts +++ b/src/api/system/dict.api.ts @@ -40,7 +40,7 @@ const DictAPI = { * @returns 字典表单数据 */ getFormData(id: string) { - return request>({ + return request({ url: `${DICT_BASE_URL}/${id}/form`, method: "get", }); @@ -130,7 +130,7 @@ const DictAPI = { * @returns 字典项表单数据 */ getDictItemFormData(dictCode: string, id: string) { - return request>({ + return request({ url: `${DICT_BASE_URL}/${dictCode}/items/${id}/form`, method: "get", }); @@ -303,12 +303,8 @@ export interface DictItemForm { * 字典项下拉选项 */ export interface DictItemOption { - /** 字典数据值 */ - value: string; - - /** 字典数据标签 */ + value: number | string; label: string; - - /** 标签类型 */ - tagType: string; + tagType?: "" | "success" | "info" | "warning" | "danger"; + [key: string]: any; } diff --git a/src/components/Dict/DictLabel.vue b/src/components/Dict/DictLabel.vue index 4cf23ea3..1ef82bea 100644 --- a/src/components/Dict/DictLabel.vue +++ b/src/components/Dict/DictLabel.vue @@ -56,4 +56,15 @@ const updateLabelAndTag = async () => { watch([() => props.code, () => props.modelValue], updateLabelAndTag); onMounted(updateLabelAndTag); + +// 监听WebSocket字典更新事件,强制刷新标签 +watch( + () => dictStore.getDictItems(props.code || ""), + async () => { + if (props.code) { + await updateLabelAndTag(); + } + }, + { deep: true } +); diff --git a/src/components/Dict/index.vue b/src/components/Dict/index.vue index 14dbc9c7..ee5f1136 100644 --- a/src/components/Dict/index.vue +++ b/src/components/Dict/index.vue @@ -23,12 +23,7 @@ :style="style" @change="handleChange" > - + {{ option.label }} @@ -40,12 +35,7 @@ :style="style" @change="handleChange" > - + {{ option.label }} @@ -130,4 +120,13 @@ onMounted(async () => { await dictStore.loadDictItems(props.code); options.value = dictStore.getDictItems(props.code); }); + +// 监听字典数据变化,确保WebSocket更新时刷新选项 +watch( + () => dictStore.getDictItems(props.code), + (newItems) => { + options.value = newItems; + }, + { deep: true } +); diff --git a/src/hooks/useStomp.ts b/src/hooks/useStomp.ts index 302482dc..a6deb5a7 100644 --- a/src/hooks/useStomp.ts +++ b/src/hooks/useStomp.ts @@ -1,5 +1,6 @@ import { Client, IMessage, StompSubscription } from "@stomp/stompjs"; import { getAccessToken } from "@/utils/auth"; +import { ref, watch } from "vue"; export interface UseStompOptions { /** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */ @@ -30,43 +31,46 @@ export function useStomp(options: UseStompOptions = {}) { /** * 初始化 STOMP 客户端 - * 只有在 brokerURL 非空时才会初始化客户端 */ const initializeClient = () => { - if (!brokerURL.value) { - console.warn( - "brokerURL is required. Please set the WebSocket URL in your .env file (VITE_APP_WS_ENDPOINT)." - ); + if (client.value) { return; } - if (!client.value) { - client.value = new Client({ - brokerURL: brokerURL.value, - reconnectDelay: options.reconnectDelay ?? 5000, - debug: options.debug ? (msg) => console.log("[STOMP]", msg) : () => {}, - connectHeaders: { - Authorization: `Bearer ${token}`, - }, - heartbeatIncoming: 4000, - heartbeatOutgoing: 4000, - }); + // 创建 STOMP 客户端 + client.value = new Client({ + brokerURL: brokerURL.value, + connectHeaders: { + Authorization: `Bearer ${token}`, + }, + debug: () => {}, + reconnectDelay: 5000, + heartbeatIncoming: 4000, + heartbeatOutgoing: 4000, + }); - client.value.onConnect = (frame) => { - isConnected.value = true; - console.log("STOMP connected", frame); - }; + // 设置连接监听器 + client.value.onConnect = () => { + isConnected.value = true; + console.log("WebSocket连接已建立"); + }; - client.value.onStompError = (frame) => { - console.error("Broker reported error: " + frame.headers["message"]); - console.error("Additional details: " + frame.body); - }; + // 设置断开连接监听器 + client.value.onDisconnect = () => { + isConnected.value = false; + console.log("WebSocket连接已断开"); + }; - client.value.onWebSocketClose = (evt) => { - isConnected.value = false; - console.warn("WebSocket closed", evt); - }; - } + // 设置 Web Socket 关闭监听器 + client.value.onWebSocketClose = (event) => { + isConnected.value = false; + console.log(`WebSocket已关闭: ${event?.code} ${event?.reason}`); + }; + + // 设置错误监听器 + client.value.onStompError = (frame) => { + console.error("STOMP错误:", frame.headers, frame.body); + }; }; // 监听 brokerURL 的变化,若地址改变则重新初始化 @@ -82,21 +86,27 @@ export function useStomp(options: UseStompOptions = {}) { } }); - // 在组件挂载时检查并初始化客户端 - onMounted(() => { - console.log("useStomp onMounted initializeClient"); - initializeClient(); - }); + // 初始化客户端 + initializeClient(); /** * 激活连接(如果已经连接或正在激活则直接返回) */ const connect = () => { + if (!client.value) { + initializeClient(); + } + if (client.value && (client.value.connected || client.value.active)) { - console.log("Already connected or connecting, skipping connect() call."); return; } - client.value?.activate(); + + if (!client.value) { + console.error("STOMP客户端初始化失败"); + return; + } + + client.value.activate(); }; /** @@ -106,12 +116,23 @@ export function useStomp(options: UseStompOptions = {}) { * @returns 返回订阅 id,用于后续取消订阅 */ const subscribe = (destination: string, callback: (message: IMessage) => void): string => { - if (client.value) { + if (!client.value) { + return ""; + } + + if (!client.value.connected) { + return ""; + } + + try { const subscription = client.value.subscribe(destination, callback); subscriptions.set(subscription.id, subscription); + console.log(`订阅成功: ${destination}, ID: ${subscription.id}`); return subscription.id; + } catch (error) { + console.error(`订阅失败(${destination}):`, error); + return ""; } - return ""; }; /** diff --git a/src/hooks/useWebSocketDict.ts b/src/hooks/useWebSocketDict.ts new file mode 100644 index 00000000..3eb4054a --- /dev/null +++ b/src/hooks/useWebSocketDict.ts @@ -0,0 +1,180 @@ +import { useDictStoreHook } from "@/store/modules/dict.store"; +import { useStomp } from "@/hooks/useStomp"; +import { IMessage } from "@stomp/stompjs"; + +// 字典消息类型 +export interface DictMessage { + dictCode: string; + timestamp: number; +} + +// 字典事件回调类型 +export type DictMessageCallback = (message: DictMessage) => void; + +// 全局单例实例 +let instance: ReturnType | null = null; + +// 创建WebSocket词典处理函数 +function createWebSocketDict() { + const dictStore = useDictStoreHook(); + + // 使用现有的useStomp + const { isConnected, connect, subscribe, unsubscribe, disconnect } = useStomp(); + + // 存储订阅ID + const subscriptionIds = ref([]); + + // 已订阅的主题 + const subscribedTopics = ref>(new Set()); + + // 消息回调函数列表 + const messageCallbacks = ref([]); + + /** + * 注册字典消息回调 + * @param callback 回调函数 + */ + const onDictMessage = (callback: DictMessageCallback) => { + messageCallbacks.value.push(callback); + return () => { + // 返回取消注册的函数 + const index = messageCallbacks.value.indexOf(callback); + if (index !== -1) { + messageCallbacks.value.splice(index, 1); + } + }; + }; + + /** + * 初始化WebSocket + */ + const initWebSocket = async () => { + try { + // 连接WebSocket + connect(); + + // 设置字典订阅 + setupDictSubscription(); + } catch (error) { + console.error("[WebSocket] 初始化失败:", error); + } + }; + + /** + * 关闭WebSocket + */ + const closeWebSocket = () => { + // 取消所有订阅 + subscriptionIds.value.forEach((id) => { + unsubscribe(id); + }); + subscriptionIds.value = []; + subscribedTopics.value.clear(); + + // 断开连接 + disconnect(); + }; + + /** + * 设置字典订阅 + */ + const setupDictSubscription = () => { + const topic = "/topic/dict"; + + // 防止重复订阅 + if (subscribedTopics.value.has(topic)) { + console.log(`跳过重复订阅: ${topic}`); + return; + } + + console.log(`开始尝试订阅字典主题: ${topic}`); + + // 延迟订阅,确保连接先建立 + const attemptSubscribe = () => { + if (!isConnected.value) { + console.log("等待WebSocket连接建立..."); + // 500ms后再次尝试 + setTimeout(attemptSubscribe, 500); + return; + } + + // 检查是否已订阅 + if (subscribedTopics.value.has(topic)) { + return; + } + + console.log(`连接已建立,开始订阅: ${topic}`); + + // 订阅字典更新 + const subId = subscribe(topic, (message: IMessage) => { + handleDictEvent(message); + }); + + if (subId) { + subscriptionIds.value.push(subId); + subscribedTopics.value.add(topic); + console.log(`字典主题订阅成功: ${topic}`); + } else { + console.warn(`字典主题订阅失败,1秒后重试: ${topic}`); + // 尝试重新订阅 + setTimeout(attemptSubscribe, 1000); + } + }; + + // 开始尝试订阅 + attemptSubscribe(); + }; + + /** + * 处理字典事件 + * @param message STOMP消息 + */ + const handleDictEvent = (message: IMessage) => { + if (!message.body) return; + + try { + // 记录接收到的消息 + console.log(`收到字典更新消息: ${message.body}`); + + // 尝试解析消息 + const parsedData = JSON.parse(message.body) as DictMessage; + const dictCode = parsedData.dictCode; + + if (!dictCode) return; + + // 清除缓存,等待按需加载 + dictStore.removeDictItem(dictCode); + console.log(`字典缓存已清除: ${dictCode}`); + + // 调用所有注册的回调函数 + messageCallbacks.value.forEach((callback) => { + try { + callback(parsedData); + } catch (callbackError) { + console.error("[WebSocket] 回调执行失败:", callbackError); + } + }); + + // 显示提示消息 + console.info(`字典 ${dictCode} 已变更,将在下次使用时自动加载`); + } catch (error) { + console.error("[WebSocket] 解析消息失败:", error); + } + }; + + return { + isConnected, + initWebSocket, + closeWebSocket, + handleDictEvent, + onDictMessage, + }; +} + +// 导出单例实例的钩子 +export function useWebSocketDict() { + if (!instance) { + instance = createWebSocketDict(); + } + return instance; +} diff --git a/src/hooks/useWebSocketOnlineUsers.ts b/src/hooks/useWebSocketOnlineUsers.ts new file mode 100644 index 00000000..8921f1d9 --- /dev/null +++ b/src/hooks/useWebSocketOnlineUsers.ts @@ -0,0 +1,176 @@ +import { ref, onMounted, onUnmounted, watch } from "vue"; +import { useStomp } from "./useStomp"; +import { ElMessage } from "element-plus"; + +export interface OnlineUserStats { + type: string; // 事件类型 + count: number; // 当前在线用户数量 + users?: any[]; // 用户列表(可选) + timestamp: number; // 时间戳 +} + +/** + * 在线用户WebSocket Hook + * 用于订阅后端推送的在线用户数量变化 + */ +export function useWebSocketOnlineUsers() { + // 在线用户数量 + const onlineUserCount = ref(0); + + // 最后更新时间戳 + const lastUpdateTime = ref(0); + + // 连接状态 + const isConnected = ref(false); + + // 连接正在尝试中 + const isConnecting = ref(false); + + // 使用Stomp客户端 + const { connect, subscribe, unsubscribe, disconnect, isConnected: stompConnected } = useStomp(); + + // 订阅ID + let subscriptionId = ""; + + // 重连次数 + let reconnectCount = 0; + // 最大重连次数 + const maxReconnectAttempts = 5; + // 重连计时器 + let reconnectTimer: any = null; + + // 监听Stomp连接状态 + watch(stompConnected, (connected) => { + if (connected && isConnecting.value) { + isConnected.value = true; + isConnecting.value = false; + reconnectCount = 0; + + // 一旦连接成功,立即订阅主题 + subscribeToOnlineUsers(); + console.log("WebSocket连接成功,已订阅在线用户主题"); + } + }); + + /** + * 订阅在线用户主题 + */ + const subscribeToOnlineUsers = () => { + if (!stompConnected.value) return; + + // 如果已经订阅,先取消订阅 + if (subscriptionId) { + unsubscribe(subscriptionId); + } + + // 订阅在线用户主题 + subscriptionId = subscribe("/topic/online-users", (message) => { + try { + const data: OnlineUserStats = JSON.parse(message.body); + + // 只有在消息类型为ONLINE_USERS_CHANGE时更新数据 + if (data.type === "ONLINE_USERS_CHANGE") { + onlineUserCount.value = data.count || 0; + lastUpdateTime.value = data.timestamp || Date.now(); + } + } catch (error) { + console.error("解析在线用户数据失败:", error); + } + }); + }; + + /** + * 初始化WebSocket连接并订阅在线用户主题 + */ + const initWebSocket = () => { + if (isConnecting.value) return; + + isConnecting.value = true; + + // 连接WebSocket + connect(); + + // 设置连接超时 + const connectionTimeout = setTimeout(() => { + if (!isConnected.value) { + console.warn("WebSocket连接超时,尝试重连"); + attemptReconnect(); + } + }, 5000); + + // 监听连接状态变化,连接成功后清除超时计时器 + const unwatch = watch(stompConnected, (connected) => { + if (connected) { + clearTimeout(connectionTimeout); + unwatch(); + } + }); + }; + + /** + * 尝试重新连接 + */ + const attemptReconnect = () => { + if (reconnectCount >= maxReconnectAttempts) { + console.error(`已达到最大重连次数(${maxReconnectAttempts}),停止重连`); + isConnecting.value = false; + ElMessage.error("WebSocket连接失败,请稍后刷新页面重试"); + return; + } + + reconnectCount++; + console.log(`尝试重连(${reconnectCount}/${maxReconnectAttempts})...`); + + // 使用指数退避策略增加重连间隔 + const delay = Math.min(1000 * Math.pow(2, reconnectCount), 30000); + + // 清除之前的计时器 + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + + // 设置重连计时器 + reconnectTimer = setTimeout(() => { + connect(); + }, delay); + }; + + /** + * 关闭WebSocket连接 + */ + const closeWebSocket = () => { + if (subscriptionId) { + unsubscribe(subscriptionId); + subscriptionId = ""; + } + + // 清除重连计时器 + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + disconnect(); + isConnected.value = false; + isConnecting.value = false; + }; + + // 组件挂载时初始化WebSocket + onMounted(() => { + initWebSocket(); + }); + + // 组件卸载时关闭WebSocket + onUnmounted(() => { + closeWebSocket(); + }); + + return { + onlineUserCount, + lastUpdateTime, + isConnected, + isConnecting, + initWebSocket, + closeWebSocket, + }; +} diff --git a/src/plugins/index.ts b/src/plugins/index.ts index e5262519..98d26205 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -6,6 +6,7 @@ import { setupRouter } from "@/router"; import { setupStore } from "@/store"; import { setupElIcons } from "./icons"; import { setupPermission } from "./permission"; +import { setupWebSocket } from "./websocket"; import { InstallCodeMirror } from "codemirror-editor-vue3"; export default { @@ -22,6 +23,8 @@ export default { setupElIcons(app); // 路由守卫 setupPermission(); + // WebSocket服务 + setupWebSocket(); // 注册 CodeMirror app.use(InstallCodeMirror); }, diff --git a/src/plugins/websocket.ts b/src/plugins/websocket.ts new file mode 100644 index 00000000..3844dd9e --- /dev/null +++ b/src/plugins/websocket.ts @@ -0,0 +1,39 @@ +import { useWebSocketDict } from "@/hooks/useWebSocketDict"; +import { getAccessToken } from "@/utils/auth"; + +/** + * 初始化WebSocket服务 + */ +export function setupWebSocket() { + console.log("[WebSocketPlugin] 开始初始化WebSocket服务..."); + + // 检查token是否存在 + const token = getAccessToken(); + if (!token) { + console.warn( + "[WebSocketPlugin] 未找到访问令牌,WebSocket初始化已跳过。用户登录后将自动重新连接。" + ); + return; + } + + try { + // 延迟初始化,确保应用完全启动 + setTimeout(() => { + const dictWebSocket = useWebSocketDict(); + + // 初始化字典WebSocket服务 + dictWebSocket.initWebSocket(); + console.log("[WebSocketPlugin] 字典WebSocket初始化完成"); + + // 在窗口关闭前断开WebSocket连接 + window.addEventListener("beforeunload", () => { + console.log("[WebSocketPlugin] 窗口即将关闭,断开WebSocket连接"); + dictWebSocket.closeWebSocket(); + }); + + console.log("[WebSocketPlugin] WebSocket服务初始化完成"); + }, 1000); // 延迟1秒初始化 + } catch (error) { + console.error("[WebSocketPlugin] 初始化WebSocket服务失败:", error); + } +} diff --git a/src/store/modules/dict.store.ts b/src/store/modules/dict.store.ts index e053ed94..84f7ff56 100644 --- a/src/store/modules/dict.store.ts +++ b/src/store/modules/dict.store.ts @@ -42,6 +42,16 @@ export const useDictStore = defineStore("dict", () => { return dictCache.value[dictCode] || []; }; + /** + * 移除指定字典项 + * @param dictCode 字典编码 + */ + const removeDictItem = (dictCode: string) => { + if (dictCache.value[dictCode]) { + Reflect.deleteProperty(dictCache.value, dictCode); + } + }; + /** * 清空字典缓存 */ @@ -52,6 +62,7 @@ export const useDictStore = defineStore("dict", () => { return { loadDictItems, getDictItems, + removeDictItem, clearDictCache, }; }); diff --git a/src/styles/reset.scss b/src/styles/reset.scss index 56689def..b3b43c7a 100644 --- a/src/styles/reset.scss +++ b/src/styles/reset.scss @@ -25,8 +25,9 @@ body { width: 100%; height: 100%; margin: 0; - font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", - "微软雅黑", Arial, sans-serif; + font-family: + "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", + Arial, sans-serif; line-height: inherit; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; diff --git a/src/types/websocket.ts b/src/types/websocket.ts new file mode 100644 index 00000000..30b56a2d --- /dev/null +++ b/src/types/websocket.ts @@ -0,0 +1,15 @@ +/** + * WebSocket相关类型定义 + */ + +/** + * 字典WebSocket事件类型 + */ +export interface DictWebSocketEvent { + /** 事件类型:更新或删除 */ + type: "DICT_UPDATED" | "DICT_DELETED"; + /** 字典编码 */ + dictCode: string; + /** 时间戳 */ + timestamp: number; +} diff --git a/src/views/dashboard/index.vue b/src/views/dashboard/index.vue index 7f9f3c64..ad38d539 100644 --- a/src/views/dashboard/index.vue +++ b/src/views/dashboard/index.vue @@ -81,8 +81,42 @@ + + + + + +
+
+ + {{ onlineUserCount }} + + + + 已连接 + + + + 未连接 + +
+
+
+ +
+ 更新时间 + {{ formattedTime }} +
+ + + - +