diff --git a/src/components/NoticeDropdown/index.vue b/src/components/NoticeDropdown/index.vue index dcf4b93c..9950a9e0 100644 --- a/src/components/NoticeDropdown/index.vue +++ b/src/components/NoticeDropdown/index.vue @@ -94,7 +94,7 @@ const noticeList = ref([]); const noticeDialogVisible = ref(false); const noticeDetail = ref(null); -import { useStomp } from "@/hooks/useStomp"; +import { useStomp } from "@/hooks/websocket/core/useStomp"; const { subscribe, unsubscribe, isConnected } = useStomp(); watch( diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 00000000..abab537d --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,7 @@ +/** + * 全局Hooks入口文件 + * 导出所有可用的Hooks + */ + +// 导出WebSocket相关Hook +export * from "./websocket"; diff --git a/src/hooks/useWebSocketOnlineUsers.ts b/src/hooks/useWebSocketOnlineUsers.ts deleted file mode 100644 index 8921f1d9..00000000 --- a/src/hooks/useWebSocketOnlineUsers.ts +++ /dev/null @@ -1,176 +0,0 @@ -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/hooks/useStomp.ts b/src/hooks/websocket/core/useStomp.ts similarity index 60% rename from src/hooks/useStomp.ts rename to src/hooks/websocket/core/useStomp.ts index a6deb5a7..7b7cdb16 100644 --- a/src/hooks/useStomp.ts +++ b/src/hooks/websocket/core/useStomp.ts @@ -7,12 +7,24 @@ export interface UseStompOptions { brokerURL?: string; /** 用于鉴权的 token,不传时使用 getAccessToken() 的返回值 */ token?: string; - /** 重连延迟,单位毫秒,默认为 5000 */ + /** 重连延迟,单位毫秒,默认为 8000 */ reconnectDelay?: number; + /** 连接超时时间,单位毫秒,默认为 10000 */ + connectionTimeout?: number; + /** 是否开启指数退避重连策略 */ + useExponentialBackoff?: boolean; + /** 最大重连次数,默认为 5 */ + maxReconnectAttempts?: number; + /** 最大重连延迟,单位毫秒,默认为 60000 */ + maxReconnectDelay?: number; /** 是否开启调试日志 */ debug?: boolean; } +/** + * STOMP WebSocket连接Hook + * 用于管理WebSocket连接的建立、断开、重连和消息订阅 + */ export function useStomp(options: UseStompOptions = {}) { // 默认值:brokerURL 从环境变量中获取,token 从 getAccessToken() 获取 const defaultBrokerURL = import.meta.env.VITE_APP_WS_ENDPOINT || ""; @@ -20,9 +32,20 @@ export function useStomp(options: UseStompOptions = {}) { const brokerURL = ref(options.brokerURL ?? defaultBrokerURL); const token = options.token ?? defaultToken; + const reconnectDelay = options.reconnectDelay ?? 8000; + const connectionTimeout = options.connectionTimeout ?? 10000; + const useExponentialBackoff = options.useExponentialBackoff ?? false; + const maxReconnectAttempts = options.maxReconnectAttempts ?? 5; + const maxReconnectDelay = options.maxReconnectDelay ?? 60000; // 连接状态标记 const isConnected = ref(false); + // 重连尝试次数 + const reconnectCount = ref(0); + // 重连计时器 + let reconnectTimer: any = null; + // 连接超时计时器 + let connectionTimeoutTimer: any = null; // 存储所有订阅 const subscriptions = new Map(); @@ -43,8 +66,8 @@ export function useStomp(options: UseStompOptions = {}) { connectHeaders: { Authorization: `Bearer ${token}`, }, - debug: () => {}, - reconnectDelay: 5000, + debug: options.debug ? console.log : () => {}, + reconnectDelay: useExponentialBackoff ? 0 : reconnectDelay, // 使用自定义退避策略时禁用内置重连 heartbeatIncoming: 4000, heartbeatOutgoing: 4000, }); @@ -52,6 +75,8 @@ export function useStomp(options: UseStompOptions = {}) { // 设置连接监听器 client.value.onConnect = () => { isConnected.value = true; + reconnectCount.value = 0; + clearTimeout(connectionTimeoutTimer); console.log("WebSocket连接已建立"); }; @@ -59,6 +84,11 @@ export function useStomp(options: UseStompOptions = {}) { client.value.onDisconnect = () => { isConnected.value = false; console.log("WebSocket连接已断开"); + + // 如果使用自定义指数退避重连策略,则在这里处理 + if (useExponentialBackoff && reconnectCount.value < maxReconnectAttempts) { + handleReconnect(); + } }; // 设置 Web Socket 关闭监听器 @@ -73,6 +103,36 @@ export function useStomp(options: UseStompOptions = {}) { }; }; + /** + * 处理重连逻辑 + */ + const handleReconnect = () => { + if (reconnectCount.value >= maxReconnectAttempts) { + console.error(`已达到最大重连次数(${maxReconnectAttempts}),停止重连`); + return; + } + + reconnectCount.value++; + console.log(`尝试重连(${reconnectCount.value}/${maxReconnectAttempts})...`); + + // 使用指数退避策略增加重连间隔 + const delay = useExponentialBackoff + ? Math.min(reconnectDelay * Math.pow(2, reconnectCount.value - 1), maxReconnectDelay) + : reconnectDelay; + + // 清除之前的计时器 + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + + // 设置重连计时器 + reconnectTimer = setTimeout(() => { + if (!isConnected.value && client.value) { + client.value.activate(); + } + }, delay); + }; + // 监听 brokerURL 的变化,若地址改变则重新初始化 watch(brokerURL, (newURL, oldURL) => { if (newURL !== oldURL) { @@ -106,6 +166,17 @@ export function useStomp(options: UseStompOptions = {}) { return; } + // 设置连接超时 + clearTimeout(connectionTimeoutTimer); + connectionTimeoutTimer = setTimeout(() => { + if (!isConnected.value) { + console.warn("WebSocket连接超时"); + if (useExponentialBackoff) { + handleReconnect(); + } + } + }, connectionTimeout); + client.value.activate(); }; @@ -155,13 +226,27 @@ export function useStomp(options: UseStompOptions = {}) { console.log("Already disconnected, skipping disconnect() call."); return; } + + // 清除所有计时器 + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + if (connectionTimeoutTimer) { + clearTimeout(connectionTimeoutTimer); + connectionTimeoutTimer = null; + } + client.value?.deactivate(); isConnected.value = false; + reconnectCount.value = 0; }; return { client, isConnected, + reconnectCount, connect, subscribe, unsubscribe, diff --git a/src/hooks/websocket/index.ts b/src/hooks/websocket/index.ts new file mode 100644 index 00000000..3d44e748 --- /dev/null +++ b/src/hooks/websocket/index.ts @@ -0,0 +1,11 @@ +/** + * WebSocket相关Hook入口文件 + * 统一导出所有WebSocket相关Hook + */ + +// 核心基础Hook +export { useStomp } from "./core/useStomp"; + +// 业务服务Hook +export { useOnlineCount } from "./services/useOnlineCount"; +export { useDictSync } from "./services/useDictSync"; diff --git a/src/hooks/useWebSocketDict.ts b/src/hooks/websocket/services/useDictSync.ts similarity index 80% rename from src/hooks/useWebSocketDict.ts rename to src/hooks/websocket/services/useDictSync.ts index 3eb4054a..d37fa8ba 100644 --- a/src/hooks/useWebSocketDict.ts +++ b/src/hooks/websocket/services/useDictSync.ts @@ -1,6 +1,7 @@ import { useDictStoreHook } from "@/store/modules/dict.store"; -import { useStomp } from "@/hooks/useStomp"; +import { useStomp } from "../core/useStomp"; import { IMessage } from "@stomp/stompjs"; +import { ref } from "vue"; // 字典消息类型 export interface DictMessage { @@ -12,14 +13,21 @@ export interface DictMessage { export type DictMessageCallback = (message: DictMessage) => void; // 全局单例实例 -let instance: ReturnType | null = null; +let instance: ReturnType | null = null; -// 创建WebSocket词典处理函数 -function createWebSocketDict() { +/** + * 创建字典同步Hook + * 负责监听后端字典变更并同步到前端 + */ +function createDictSyncHook() { const dictStore = useDictStoreHook(); - // 使用现有的useStomp - const { isConnected, connect, subscribe, unsubscribe, disconnect } = useStomp(); + // 使用现有的useStomp,配置适合字典场景的重连参数 + const { isConnected, connect, subscribe, unsubscribe, disconnect } = useStomp({ + reconnectDelay: 10000, // 使用更长的重连延迟 - 10秒 + connectionTimeout: 15000, // 更长的连接超时时间 - 15秒 + useExponentialBackoff: false, // 字典数据不需要指数退避策略 + }); // 存储订阅ID const subscriptionIds = ref([]); @@ -89,12 +97,12 @@ function createWebSocketDict() { console.log(`开始尝试订阅字典主题: ${topic}`); - // 延迟订阅,确保连接先建立 + // 使用简化的重试逻辑,依赖useStomp的连接管理 const attemptSubscribe = () => { if (!isConnected.value) { console.log("等待WebSocket连接建立..."); - // 500ms后再次尝试 - setTimeout(attemptSubscribe, 500); + // 3秒后再次尝试 + setTimeout(attemptSubscribe, 3000); return; } @@ -115,9 +123,7 @@ function createWebSocketDict() { subscribedTopics.value.add(topic); console.log(`字典主题订阅成功: ${topic}`); } else { - console.warn(`字典主题订阅失败,1秒后重试: ${topic}`); - // 尝试重新订阅 - setTimeout(attemptSubscribe, 1000); + console.warn(`字典主题订阅失败: ${topic}`); } }; @@ -171,10 +177,13 @@ function createWebSocketDict() { }; } -// 导出单例实例的钩子 -export function useWebSocketDict() { +/** + * 字典同步Hook + * 用于监听后端字典变更并同步到前端 + */ +export function useDictSync() { if (!instance) { - instance = createWebSocketDict(); + instance = createDictSyncHook(); } return instance; } diff --git a/src/hooks/websocket/services/useOnlineCount.ts b/src/hooks/websocket/services/useOnlineCount.ts new file mode 100644 index 00000000..587532fe --- /dev/null +++ b/src/hooks/websocket/services/useOnlineCount.ts @@ -0,0 +1,149 @@ +import { ref, onMounted, onUnmounted, watch } from "vue"; +import { useStomp } from "../core/useStomp"; +import { ElMessage } from "element-plus"; + +/** + * 在线用户计数Hook + * 用于订阅后端推送的在线用户数量变化 + */ +export function useOnlineCount() { + // 在线用户数量 + 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({ + reconnectDelay: 5000, // 初始重连延迟5秒 + maxReconnectAttempts: 3, // 最大重连3次 + connectionTimeout: 10000, // 连接超时10秒 + useExponentialBackoff: true, // 启用指数退避 + }); + + // 订阅ID + let subscriptionId = ""; + + // 连接超时计时器 + let connectionTimeoutTimer: any = null; + + // 监听Stomp连接状态 + watch(stompConnected, (connected) => { + if (connected && isConnecting.value) { + isConnected.value = true; + isConnecting.value = false; + + // 一旦连接成功,立即订阅主题 + subscribeToOnlineCount(); + console.log("WebSocket连接成功,已订阅在线用户计数主题"); + } + }); + + /** + * 订阅在线用户计数主题 + */ + const subscribeToOnlineCount = () => { + if (!stompConnected.value) return; + + // 如果已经订阅,先取消订阅 + if (subscriptionId) { + unsubscribe(subscriptionId); + } + + // 订阅在线用户计数主题 + subscriptionId = subscribe("/topic/online-count", (message) => { + try { + const data = message.body; + + const jsonData = JSON.parse(data); + const count = typeof jsonData === "number" ? jsonData : jsonData.count; + + if (!isNaN(count)) { + onlineUserCount.value = count; + lastUpdateTime.value = Date.now(); + } + } catch (error) { + console.error("解析在线用户数量失败:", error); + } + }); + }; + + /** + * 初始化WebSocket连接并订阅在线用户主题 + */ + const initWebSocket = () => { + if (isConnecting.value) return; + + isConnecting.value = true; + + // 连接WebSocket + connect(); + + // 设置连接超时显示UI提示 + connectionTimeoutTimer = setTimeout(() => { + if (!isConnected.value) { + console.warn("WebSocket连接超时,将自动尝试重连"); + ElMessage.warning("正在尝试连接服务器,请稍候..."); + } + }, 10000); // 较长的UI提示超时 + + // 监听连接状态变化,连接成功后清除超时计时器 + const unwatch = watch(stompConnected, (connected) => { + if (connected) { + clearTimeout(connectionTimeoutTimer); + unwatch(); + } + }); + }; + + /** + * 关闭WebSocket连接 + */ + const closeWebSocket = () => { + if (subscriptionId) { + unsubscribe(subscriptionId); + subscriptionId = ""; + } + + // 清除连接超时计时器 + if (connectionTimeoutTimer) { + clearTimeout(connectionTimeoutTimer); + connectionTimeoutTimer = 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/websocket.ts b/src/plugins/websocket.ts index 3844dd9e..890f438f 100644 --- a/src/plugins/websocket.ts +++ b/src/plugins/websocket.ts @@ -1,4 +1,4 @@ -import { useWebSocketDict } from "@/hooks/useWebSocketDict"; +import { useDictSync } from "@/hooks/websocket/services/useDictSync"; import { getAccessToken } from "@/utils/auth"; /** @@ -19,7 +19,7 @@ export function setupWebSocket() { try { // 延迟初始化,确保应用完全启动 setTimeout(() => { - const dictWebSocket = useWebSocketDict(); + const dictWebSocket = useDictSync(); // 初始化字典WebSocket服务 dictWebSocket.initWebSocket(); diff --git a/src/views/dashboard/index.vue b/src/views/dashboard/index.vue index ad38d539..6687ef95 100644 --- a/src/views/dashboard/index.vue +++ b/src/views/dashboard/index.vue @@ -324,10 +324,10 @@ import { useUserStore } from "@/store/modules/user.store"; import { formatGrowthRate } from "@/utils"; import { useTransition, useDateFormat } from "@vueuse/core"; import { Connection, Failed } from "@element-plus/icons-vue"; -import { useWebSocketOnlineUsers } from "@/hooks/useWebSocketOnlineUsers"; +import { useOnlineCount } from "@/hooks/websocket/services/useOnlineCount"; // 在线用户数量组件相关 -const { onlineUserCount, lastUpdateTime, isConnected } = useWebSocketOnlineUsers(); +const { onlineUserCount, lastUpdateTime, isConnected } = useOnlineCount(); // 记录上一次的用户数量用于计算趋势 const previousCount = ref(0); diff --git a/src/views/demo/dict-sync.vue b/src/views/demo/dict-sync.vue index 08c6bd07..6e10da0a 100644 --- a/src/views/demo/dict-sync.vue +++ b/src/views/demo/dict-sync.vue @@ -142,7 +142,7 @@ import { useDictStoreHook } from "@/store/modules/dict.store"; import { useDateFormat } from "@vueuse/core"; import DictAPI, { DictItemForm } from "@/api/system/dict.api"; -import { useWebSocketDict, DictMessage } from "@/hooks/useWebSocketDict"; +import { useDictSync, DictMessage } from "@/hooks/websocket/services/useDictSync"; // 性别字典编码 const DICT_CODE = "gender"; @@ -161,7 +161,7 @@ const dictForm = ref(null); const selectedGender = ref(""); // 初始化WebSocket -const dictWebSocket = useWebSocketDict(); +const dictWebSocket = useDictSync(); // 获取连接状态 const wsConnected = computed(() => dictWebSocket.isConnected); diff --git a/src/views/demo/websocket.vue b/src/views/demo/websocket.vue index 946fb464..32ddaaf8 100644 --- a/src/views/demo/websocket.vue +++ b/src/views/demo/websocket.vue @@ -97,7 +97,7 @@