diff --git a/.env.development b/.env.development index d18552f6..09a0c3cd 100644 --- a/.env.development +++ b/.env.development @@ -10,12 +10,6 @@ VITE_APP_API_URL=https://api.youlai.tech # 线上 # VITE_APP_API_URL=https://api.youlai.tech/v2 # 线上(多租户) # VITE_APP_API_URL=http://localhost:8000 # 本地 -# WebSocket 端点(不配置则关闭) -# 线上: ws://api.youlai.tech/ws -# 本地: ws://localhost:8000/ws -VITE_APP_WS_ENDPOINT= - - # 启用 Mock 服务(true:开启 false:关闭) VITE_MOCK_DEV_SERVER=false diff --git a/mock/log.mock.ts b/mock/log.mock.ts index 5943b6e9..d97ad127 100644 --- a/mock/log.mock.ts +++ b/mock/log.mock.ts @@ -164,4 +164,43 @@ export default defineMock([ msg: "一切ok", }, }, + { + url: "logs/views/trend", + method: ["GET"], + body: { + code: "00000", + data: { + dates: [ + "2024-06-30", + "2024-07-01", + "2024-07-02", + "2024-07-03", + "2024-07-04", + "2024-07-05", + "2024-07-06", + "2024-07-07", + ], + pvList: [1751, 5168, 4882, 5301, 4721, 4885, 1901, 1003], + uvList: null, + ipList: [207, 566, 565, 631, 579, 496, 222, 152], + }, + msg: "一切ok", + }, + }, + { + url: "logs/views", + method: ["GET"], + body: { + code: "00000", + data: { + todayUvCount: 169, + totalUvCount: 19985, + uvGrowthRate: -0.57, + todayPvCount: 1629, + totalPvCount: 286086, + pvGrowthRate: -0.65, + }, + msg: "一切ok", + }, + }, ]); diff --git a/mock/statistics.mock.ts b/mock/statistics.mock.ts deleted file mode 100644 index ab360dde..00000000 --- a/mock/statistics.mock.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { defineMock } from "./base"; - -export default defineMock([ - { - url: "statistics/visits/trend", - method: ["GET"], - body: { - code: "00000", - data: { - dates: [ - "2024-06-30", - "2024-07-01", - "2024-07-02", - "2024-07-03", - "2024-07-04", - "2024-07-05", - "2024-07-06", - "2024-07-07", - ], - pvList: [1751, 5168, 4882, 5301, 4721, 4885, 1901, 1003], - uvList: null, - ipList: [207, 566, 565, 631, 579, 496, 222, 152], - }, - msg: "一切ok", - }, - }, - { - url: "statistics/visits/overview", - method: ["GET"], - body: { - code: "00000", - data: { - todayUvCount: 169, - totalUvCount: 19985, - uvGrowthRate: -0.57, - todayPvCount: 1629, - totalPvCount: 286086, - pvGrowthRate: -0.65, - }, - msg: "一切ok", - }, - }, -]); diff --git a/package.json b/package.json index 7f56bce6..848fda4d 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", - "@stomp/stompjs": "^7.3.0", "@vueuse/core": "^14.2.1", "@wangeditor-next/editor": "^5.6.49", "@wangeditor-next/editor-for-vue": "^5.1.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 011307c2..523ae886 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ dependencies: '@element-plus/icons-vue': specifier: ^2.3.2 version: 2.3.2(vue@3.5.30) - '@stomp/stompjs': - specifier: ^7.3.0 - version: 7.3.0 '@vueuse/core': specifier: ^14.2.1 version: 14.2.1(vue@3.5.30) @@ -1396,10 +1393,6 @@ packages: resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} dev: true - /@stomp/stompjs@7.3.0: - resolution: {integrity: sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==} - dev: false - /@sxzz/popperjs-es@2.11.8: resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} dev: false diff --git a/src/api/system/log.ts b/src/api/system/log.ts index 01908542..34cdb336 100644 --- a/src/api/system/log.ts +++ b/src/api/system/log.ts @@ -1,5 +1,11 @@ -import request from "@/utils/request"; -import type { LogQueryParams, LogItem } from "@/types/api"; +import request from "@/utils/request"; +import type { + LogQueryParams, + LogItem, + VisitTrendQueryParams, + VisitTrendDetail, + VisitStatsDetail, +} from "@/types/api"; const LOG_BASE_URL = "/api/v1/logs"; @@ -12,6 +18,23 @@ const LogAPI = { params: queryParams, }); }, + + /** 获取访问趋势统计 */ + getVisitTrend(queryParams: VisitTrendQueryParams) { + return request({ + url: `${LOG_BASE_URL}/views/trend`, + method: "get", + params: queryParams, + }); + }, + + /** 获取访问概览统计 */ + getVisitOverview() { + return request({ + url: `${LOG_BASE_URL}/views`, + method: "get", + }); + }, }; export default LogAPI; diff --git a/src/api/system/statistics.ts b/src/api/system/statistics.ts deleted file mode 100644 index 901abf84..00000000 --- a/src/api/system/statistics.ts +++ /dev/null @@ -1,24 +0,0 @@ -import request from "@/utils/request"; -import type { VisitTrendQueryParams, VisitTrendDetail, VisitStatsDetail } from "@/types/api"; - -const STATISTICS_BASE_URL = "/api/v1/logs"; - -const StatisticsAPI = { - /** 获取访问趋势统计 */ - getVisitTrend(queryParams: VisitTrendQueryParams) { - return request({ - url: `${STATISTICS_BASE_URL}/views/trend`, - method: "get", - params: queryParams, - }); - }, - /** 获取访问概览统计 */ - getVisitOverview() { - return request({ - url: `${STATISTICS_BASE_URL}/views`, - method: "get", - }); - }, -}; - -export default StatisticsAPI; diff --git a/src/components/NoticeDropdown/useNotice.ts b/src/components/NoticeDropdown/useNotice.ts index 6dd184b2..f3032c1c 100644 --- a/src/components/NoticeDropdown/useNotice.ts +++ b/src/components/NoticeDropdown/useNotice.ts @@ -4,13 +4,15 @@ import { ref, onMounted, onBeforeUnmount } from "vue"; import type { NoticeItem, NoticeDetail, NoticeQueryParams } from "@/types/api"; import NoticeAPI from "@/api/system/notice"; -import { useStomp } from "@/composables"; +import { useSse } from "@/composables"; import router from "@/router"; const PAGE_SIZE = 5; +const NOTICE_EVENT = "notice"; + export function useNotice() { - const { subscribe, unsubscribe, isConnected } = useStomp(); + const { on } = useSse(); // 状态 const list = ref([]); @@ -18,7 +20,7 @@ export function useNotice() { const detail = ref(null); const dialogVisible = ref(false); - let subscribed = false; + let unsubscribe: (() => void) | null = null; // ============================================ // 数据获取 @@ -40,7 +42,6 @@ export function useNotice() { detail.value = await NoticeAPI.getDetail(id); dialogVisible.value = true; - // 从列表中移除已读项 const idx = list.value.findIndex((item: NoticeItem) => item.id === id); if (idx >= 0) list.value.splice(idx, 1); if (unreadTotal.value > 0) unreadTotal.value -= 1; @@ -60,18 +61,16 @@ export function useNotice() { } // ============================================ - // WebSocket 订阅 + // SSE 订阅 // ============================================ function setupSubscription() { - if (subscribed || !isConnected.value) return; + if (unsubscribe) return; - subscribe("/user/queue/message", (message: any) => { + unsubscribe = on(NOTICE_EVENT, (data: any) => { try { - const data = JSON.parse(message.body || "{}"); if (!data.id) return; - // 避免重复 if (list.value.some((item: NoticeItem) => item.id === data.id)) return; unreadTotal.value += 1; @@ -98,7 +97,19 @@ export function useNotice() { } }); - subscribed = true; + on("notice-revoke", (data: any) => { + try { + if (!data.id) return; + + const idx = list.value.findIndex((item: NoticeItem) => item.id === data.id); + if (idx >= 0) { + list.value.splice(idx, 1); + if (unreadTotal.value > 0) unreadTotal.value -= 1; + } + } catch (e) { + console.error("处理撤回通知失败", e); + } + }); } // ============================================ @@ -111,8 +122,10 @@ export function useNotice() { }); onBeforeUnmount(() => { - unsubscribe("/user/queue/message"); - subscribed = false; + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } }); return { diff --git a/src/composables/index.ts b/src/composables/index.ts index 1cb27b9e..ac21d71b 100644 --- a/src/composables/index.ts +++ b/src/composables/index.ts @@ -1,7 +1,7 @@ -// WebSocket 服务 -export { setupWebSocket, cleanupWebSocket } from "./websocket"; -export { useStomp, useDictSync, useOnlineCount } from "./websocket"; -export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./websocket"; +// SSE 服务 +export { setupSse, cleanupSseServices } from "./sse"; +export { useSse, useDictSync, useOnlineCount, cleanupSse, SseConnectionState } from "./sse"; +export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./sse"; // 表格相关 export { useTableSelection } from "./useTableSelection"; diff --git a/src/composables/sse/index.ts b/src/composables/sse/index.ts new file mode 100644 index 00000000..74e6226d --- /dev/null +++ b/src/composables/sse/index.ts @@ -0,0 +1,32 @@ +import { useDictSync } from "./useDictSync"; +import { useOnlineCount } from "./useOnlineCount"; +import { cleanupSse } from "./useSse"; + +/** + * 初始化所有 SSE 服务 + */ +export function setupSse() { + const dictSync = useDictSync(); + dictSync.initialize(); + + const onlineCount = useOnlineCount(); + onlineCount.initialize(); +} + +/** + * 清理所有 SSE 连接 + */ +export function cleanupSseServices() { + const dictSync = useDictSync(); + dictSync.cleanup(); + + const onlineCount = useOnlineCount(); + onlineCount.cleanup(); + + cleanupSse(); +} + +export { useDictSync } from "./useDictSync"; +export { useOnlineCount } from "./useOnlineCount"; +export { useSse, cleanupSse, SseConnectionState } from "./useSse"; +export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./useDictSync"; diff --git a/src/composables/sse/useDictSync.ts b/src/composables/sse/useDictSync.ts new file mode 100644 index 00000000..dbbfe326 --- /dev/null +++ b/src/composables/sse/useDictSync.ts @@ -0,0 +1,83 @@ +import { useDictStoreHook } from "@/store/modules/dict"; +import { useSse } from "./useSse"; + +export interface DictChangeMessage { + dictCode: string; + timestamp: number; +} + +export type DictMessage = DictChangeMessage; + +export type DictChangeCallback = (message: DictChangeMessage) => void; + +let singletonInstance: ReturnType | null = null; + +function createDictSyncComposable() { + const dictStore = useDictStoreHook(); + const sse = useSse(); + + const messageCallbacks = ref([]); + + let unsubscribe: (() => void) | null = null; + + const handleDictChangeMessage = (data: DictChangeMessage) => { + const { dictCode } = data; + + if (!dictCode) { + console.warn("[DictSync] 收到无效的字典变更消息:缺少 dictCode"); + return; + } + + dictStore.removeDictItem(dictCode); + + messageCallbacks.value.forEach((callback) => { + try { + callback(data); + } catch (error) { + console.error("[DictSync] 回调函数执行失败:", error); + } + }); + }; + + const initialize = () => { + sse.connect(); + unsubscribe = sse.on("dict", handleDictChangeMessage); + }; + + const cleanup = () => { + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + messageCallbacks.value = []; + }; + + const onDictChange = (callback: DictChangeCallback) => { + messageCallbacks.value.push(callback); + + return () => { + const index = messageCallbacks.value.indexOf(callback); + if (index !== -1) { + messageCallbacks.value.splice(index, 1); + } + }; + }; + + return { + isConnected: sse.isConnected, + connectionState: sse.connectionState, + initialize, + cleanup, + onDictChange, + }; +} + +/** + * 字典同步组合式函数(单例模式) + */ +export function useDictSync() { + if (!singletonInstance) { + singletonInstance = createDictSyncComposable(); + } + return singletonInstance; +} diff --git a/src/composables/sse/useOnlineCount.ts b/src/composables/sse/useOnlineCount.ts new file mode 100644 index 00000000..3ef9c3f7 --- /dev/null +++ b/src/composables/sse/useOnlineCount.ts @@ -0,0 +1,65 @@ +import { ref, onMounted, getCurrentInstance } from "vue"; +import { useSse } from "./useSse"; + +let globalInstance: ReturnType | null = null; + +function createOnlineCountComposable() { + const onlineUserCount = ref(0); + const lastUpdateTime = ref(0); + + const sse = useSse(); + + let unsubscribe: (() => void) | null = null; + + const handleOnlineCountMessage = (count: number) => { + if (count !== undefined && !isNaN(count)) { + onlineUserCount.value = count; + lastUpdateTime.value = Date.now(); + } + }; + + const initialize = () => { + sse.connect(); + unsubscribe = sse.on("online-count", handleOnlineCountMessage); + }; + + const cleanup = () => { + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + onlineUserCount.value = 0; + lastUpdateTime.value = 0; + }; + + return { + onlineUserCount: readonly(onlineUserCount), + lastUpdateTime: readonly(lastUpdateTime), + isConnected: sse.isConnected, + connectionState: sse.connectionState, + initialize, + cleanup, + }; +} + +/** + * 在线用户计数组合式函数(单例模式) + */ +export function useOnlineCount(options: { autoInit?: boolean } = {}) { + const { autoInit = true } = options; + + if (!globalInstance) { + globalInstance = createOnlineCountComposable(); + } + + const instance = getCurrentInstance(); + if (autoInit && instance) { + onMounted(() => { + if (!globalInstance!.isConnected.value) { + globalInstance!.initialize(); + } + }); + } + + return globalInstance; +} diff --git a/src/composables/sse/useSse.ts b/src/composables/sse/useSse.ts new file mode 100644 index 00000000..dd2d0935 --- /dev/null +++ b/src/composables/sse/useSse.ts @@ -0,0 +1,219 @@ +import { AuthStorage } from "@/utils/auth"; + +export interface UseSseOptions { + /** SSE 端点 URL,不传时使用环境变量拼接 */ + url?: string; + /** 是否开启调试日志 */ + debug?: boolean; + /** 连接超时时间,单位毫秒,默认为 10000 */ + connectionTimeout?: number; +} + +type EventHandler = (data: any) => void; + +/** + * SSE 连接状态枚举 + */ +export enum SseConnectionState { + DISCONNECTED = "DISCONNECTED", + CONNECTING = "CONNECTING", + CONNECTED = "CONNECTED", +} + +/** + * 全局 SSE 实例管理 + */ +let globalInstance: ReturnType | null = null; + +/** + * 创建 SSE 连接(内部工厂函数) + */ +function createSseConnection(options: UseSseOptions = {}) { + const baseApi = import.meta.env.VITE_APP_BASE_API || "/dev-api"; + const defaultUrl = `${baseApi}/api/v1/sse/connect`; + const config = { + url: options.url ?? defaultUrl, + debug: options.debug ?? false, + connectionTimeout: options.connectionTimeout ?? 10000, + }; + + const connectionState = ref(SseConnectionState.DISCONNECTED); + const isConnected = computed(() => connectionState.value === SseConnectionState.CONNECTED); + + let eventSource: EventSource | null = null; + let isManualDisconnect = false; + let connectionTimeoutTimer: ReturnType | null = null; + + const eventHandlers = new Map>(); + + const log = config.debug ? (...args: any[]) => console.log("[SSE]", ...args) : () => {}; + const logError = (...args: any[]) => console.error("[SSE]", ...args); + + const clearConnectionTimeout = () => { + if (connectionTimeoutTimer) { + clearTimeout(connectionTimeoutTimer); + connectionTimeoutTimer = null; + } + }; + + const handleOpen = () => { + clearConnectionTimeout(); + connectionState.value = SseConnectionState.CONNECTED; + log("SSE 连接已建立"); + }; + + const handleError = (event: Event) => { + clearConnectionTimeout(); + connectionState.value = SseConnectionState.DISCONNECTED; + logError("SSE 连接错误:", event); + + if (!isManualDisconnect && eventSource) { + log("浏览器将自动重连..."); + } + }; + + const handleMessage = (event: MessageEvent) => { + log("收到消息:", event.data); + const handlers = eventHandlers.get("message"); + if (handlers) { + try { + const data = JSON.parse(event.data); + handlers.forEach((handler) => handler(data)); + } catch { + handlers.forEach((handler) => handler(event.data)); + } + } + }; + + const handleCustomEvent = (eventName: string) => (event: MessageEvent) => { + log(`收到事件[${eventName}]:`, event.data); + const handlers = eventHandlers.get(eventName); + if (handlers) { + try { + const data = JSON.parse(event.data); + handlers.forEach((handler) => handler(data)); + } catch { + handlers.forEach((handler) => handler(event.data)); + } + } + }; + + const connect = () => { + isManualDisconnect = false; + + if (eventSource && connectionState.value === SseConnectionState.CONNECTED) { + log("SSE 已连接,跳过重复连接"); + return; + } + + if (connectionState.value === SseConnectionState.CONNECTING) { + log("SSE 正在连接中,跳过重复连接"); + return; + } + + const token = AuthStorage.getAccessToken(); + if (!token) { + log("未检测到有效令牌,跳过 SSE 连接"); + return; + } + + connectionState.value = SseConnectionState.CONNECTING; + + const separator = config.url.includes("?") ? "&" : "?"; + const fullUrl = `${config.url}${separator}token=${encodeURIComponent(token)}`; + + eventSource = new EventSource(fullUrl); + + connectionTimeoutTimer = setTimeout(() => { + if (connectionState.value === SseConnectionState.CONNECTING) { + log("SSE 连接超时"); + disconnect(); + } + }, config.connectionTimeout); + + eventSource.onopen = handleOpen; + eventSource.onerror = handleError; + eventSource.onmessage = handleMessage; + + log("正在建立 SSE 连接..."); + }; + + const on = (eventName: string, handler: EventHandler): (() => void) => { + if (!eventHandlers.has(eventName)) { + eventHandlers.set(eventName, new Set()); + } + eventHandlers.get(eventName)!.add(handler); + + if (eventName !== "message" && eventSource) { + eventSource.addEventListener(eventName, handleCustomEvent(eventName) as EventListener); + } + + log(`已订阅事件: ${eventName}`); + + return () => { + const handlers = eventHandlers.get(eventName); + if (handlers) { + handlers.delete(handler); + if (handlers.size === 0) { + eventHandlers.delete(eventName); + } + } + log(`已取消订阅事件: ${eventName}`); + }; + }; + + const disconnect = () => { + isManualDisconnect = true; + clearConnectionTimeout(); + + if (eventSource) { + eventSource.close(); + eventSource = null; + log("SSE 连接已断开"); + } + + connectionState.value = SseConnectionState.DISCONNECTED; + }; + + const cleanup = () => { + disconnect(); + eventHandlers.clear(); + log("SSE 资源已清理"); + }; + + return { + connectionState: readonly(connectionState), + isConnected, + connect, + disconnect, + cleanup, + on, + }; +} + +/** + * SSE 连接组合式函数(单例模式) + */ +export function useSse(options: UseSseOptions = {}) { + if (!globalInstance) { + globalInstance = createSseConnection(options); + } + return globalInstance; +} + +/** + * 获取或创建 SSE 实例(用于外部访问) + */ +export function getSseInstance() { + return globalInstance; +} + +/** + * 清理全局 SSE 实例 + */ +export function cleanupSse() { + if (globalInstance) { + globalInstance.cleanup(); + globalInstance = null; + } +} diff --git a/src/composables/websocket/index.ts b/src/composables/websocket/index.ts deleted file mode 100644 index a87c7792..00000000 --- a/src/composables/websocket/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * WebSocket 服务统一管理 - * - * @description - * 提供 WebSocket 服务的统一初始化和清理接口 - * - 字典同步服务 - * - 在线用户统计服务 - * - * @author 有来技术团队 - */ - -import { useDictSync } from "./useDictSync"; -import { useOnlineCount } from "./useOnlineCount"; - -/** - * 初始化所有 WebSocket 服务 - * - * 应在应用启动时调用,统一初始化所有 WebSocket 连接 - * - * @example - * ```ts - * // 在 main.ts 中调用 - * setupWebSocket(); - * ``` - */ -export function setupWebSocket() { - // 初始化字典同步服务 - const dictSync = useDictSync(); - dictSync.initialize(); - - // 初始化在线用户统计服务 - const onlineCount = useOnlineCount(); - onlineCount.initialize(); -} - -/** - * 清理所有 WebSocket 连接 - * - * 应在用户登出时调用,释放所有 WebSocket 资源 - * - * @example - * ```ts - * // 在 user store 的 logout 方法中调用 - * cleanupWebSocket(); - * ``` - */ -export function cleanupWebSocket() { - // 清理字典同步服务 - const dictSync = useDictSync(); - dictSync.cleanup(); - - // 清理在线用户统计服务 - const onlineCount = useOnlineCount(); - onlineCount.cleanup(); -} - -// 导出所有 WebSocket 相关的 composables -export { useDictSync } from "./useDictSync"; -export { useOnlineCount } from "./useOnlineCount"; -export { useStomp } from "./useStomp"; -export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./useDictSync"; diff --git a/src/composables/websocket/useDictSync.ts b/src/composables/websocket/useDictSync.ts deleted file mode 100644 index a6cdc740..00000000 --- a/src/composables/websocket/useDictSync.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { useDictStoreHook } from "@/store/modules/dict"; -import { useStomp } from "./useStomp"; -import type { IMessage } from "@stomp/stompjs"; - -/** - * 字典变更消息结构 - */ -export interface DictChangeMessage { - /** 字典编码 */ - dictCode: string; - /** 时间戳 */ - timestamp: number; -} - -/** - * 字典消息别名(向后兼容) - */ -export type DictMessage = DictChangeMessage; - -/** - * 字典变更事件回调函数类型 - */ -export type DictChangeCallback = (message: DictChangeMessage) => void; - -/** - * 全局单例实例 - */ -let singletonInstance: ReturnType | null = null; - -/** - * 创建字典同步组合式函数(内部工厂函数) - */ -function createDictSyncComposable() { - const dictStore = useDictStoreHook(); - - // 使用优化后的 useStomp - const stomp = useStomp({ - reconnectDelay: 20000, - connectionTimeout: 15000, - useExponentialBackoff: false, - maxReconnectAttempts: 3, - autoRestoreSubscriptions: true, // 自动恢复订阅 - debug: false, - }); - - // 字典主题地址 - const DICT_TOPIC = "/topic/dict"; - - // 消息回调函数列表 - const messageCallbacks = ref([]); - - // 订阅 ID(用于取消订阅) - let subscriptionId: string | null = null; - - /** - * 处理字典变更事件 - */ - const handleDictChangeMessage = (message: IMessage) => { - if (!message.body) { - return; - } - - try { - const data = JSON.parse(message.body) as DictChangeMessage; - const { dictCode } = data; - - if (!dictCode) { - console.warn("[DictSync] 收到无效的字典变更消息:缺少 dictCode"); - return; - } - - // 清除缓存,等待按需加载 - dictStore.removeDictItem(dictCode); - - // 执行所有注册的回调函数 - messageCallbacks.value.forEach((callback) => { - try { - callback(data); - } catch (error) { - console.error("[DictSync] 回调函数执行失败:", error); - } - }); - } catch (error) { - console.error("[DictSync] 解析字典变更消息失败:", error); - } - }; - - /** - * 初始化 WebSocket 连接并订阅字典主题 - */ - const initialize = () => { - // 检查是否配置了 WebSocket 端点 - const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT; - if (!wsEndpoint) { - console.log("[DictSync] 未配置 WebSocket 端点,跳过字典同步功能"); - return; - } - - // console.log("[DictSync] 初始化字典同步服务..."); // 高频日志已禁用 - - // 建立 WebSocket 连接 - stomp.connect(); - - // 订阅字典主题(useStomp 会自动处理重连后的订阅恢复) - subscriptionId = stomp.subscribe(DICT_TOPIC, handleDictChangeMessage); - - // if (subscriptionId) { - // console.log(`[DictSync] 已订阅字典主题: ${DICT_TOPIC}`); - // } else { - // console.log(`[DictSync] 暂存字典主题订阅,等待连接建立后自动订阅`); - // } - }; - - /** - * 关闭 WebSocket 连接并清理资源 - */ - const cleanup = () => { - // 取消订阅(如果有的话) - if (subscriptionId) { - stomp.unsubscribe(subscriptionId); - subscriptionId = null; - } - - // 也可以通过主题地址取消订阅 - stomp.unsubscribeDestination(DICT_TOPIC); - - // 断开连接 - stomp.disconnect(); - - // 清空回调列表 - messageCallbacks.value = []; - }; - - /** - * 注册字典变更回调函数 - * - * @param callback 回调函数 - * @returns 返回一个取消注册的函数 - */ - const onDictChange = (callback: DictChangeCallback) => { - messageCallbacks.value.push(callback); - - // 返回取消注册的函数 - return () => { - const index = messageCallbacks.value.indexOf(callback); - if (index !== -1) { - messageCallbacks.value.splice(index, 1); - } - }; - }; - - return { - // 状态 - isConnected: stomp.isConnected, - connectionState: stomp.connectionState, - - // 方法 - initialize, - cleanup, - onDictChange, - }; -} - -/** - * 字典同步组合式函数(单例模式) - * - * 用于监听后端字典变更并自动同步到前端缓存 - * - * @example - * ```ts - * const dictSync = useDictSync(); - * - * // 初始化(通常在应用启动时调用) - * dictSync.initialize(); - * - * // 注册回调 - * const unsubscribe = dictSync.onDictChange((message) => { - * console.log('字典已更新:', message.dictCode); - * }); - * - * // 取消注册 - * unsubscribe(); - * - * // 清理(在应用退出时调用) - * dictSync.cleanup(); - * ``` - */ -export function useDictSync() { - if (!singletonInstance) { - singletonInstance = createDictSyncComposable(); - } - return singletonInstance; -} diff --git a/src/composables/websocket/useOnlineCount.ts b/src/composables/websocket/useOnlineCount.ts deleted file mode 100644 index 5f5734d0..00000000 --- a/src/composables/websocket/useOnlineCount.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { ref, onMounted, onUnmounted, getCurrentInstance } from "vue"; -import { useStomp } from "./useStomp"; -import { AuthStorage } from "@/utils/auth"; - -/** - * 在线用户数量消息结构 - */ -interface OnlineCountMessage { - count?: number; - timestamp?: number; -} - -/** - * 全局单例实例 - */ -let globalInstance: ReturnType | null = null; - -/** - * 创建在线用户计数组合式函数(内部工厂函数) - */ -function createOnlineCountComposable() { - // ==================== 状态管理 ==================== - const onlineUserCount = ref(0); - const lastUpdateTime = ref(0); - - // ==================== WebSocket 客户端 ==================== - const stomp = useStomp({ - reconnectDelay: 15000, - maxReconnectAttempts: 3, - connectionTimeout: 10000, - useExponentialBackoff: true, - autoRestoreSubscriptions: true, // 自动恢复订阅 - debug: false, - }); - - // 在线用户计数主题 - const ONLINE_COUNT_TOPIC = "/topic/online-count"; - - // 订阅 ID - let subscriptionId: string | null = null; - - /** - * 处理在线用户数量消息 - */ - const handleOnlineCountMessage = (message: any) => { - try { - const data = message.body; - const jsonData = JSON.parse(data) as OnlineCountMessage; - - // 支持两种消息格式 - // 1. 直接是数字: 42 - // 2. 对象格式: { count: 42, timestamp: 1234567890 } - const count = typeof jsonData === "number" ? jsonData : jsonData.count; - - if (count !== undefined && !isNaN(count)) { - onlineUserCount.value = count; - lastUpdateTime.value = Date.now(); - } else { - console.warn("[useOnlineCount] 收到无效的在线用户数:", data); - } - } catch (error) { - console.error("[useOnlineCount] 解析在线用户数失败:", error); - } - }; - - /** - * 订阅在线用户计数主题 - */ - const subscribeToOnlineCount = () => { - if (subscriptionId) { - return; - } - - // 订阅在线用户计数主题(useStomp 会处理重连后的订阅恢复) - subscriptionId = stomp.subscribe(ONLINE_COUNT_TOPIC, handleOnlineCountMessage); - }; - - /** - * 初始化 WebSocket 连接并订阅在线用户主题 - */ - const initialize = () => { - // 检查 WebSocket 端点是否配置 - const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT; - if (!wsEndpoint) { - console.log("[useOnlineCount] 未配置 WebSocket 端点,跳过初始化"); - return; - } - - // 检查令牌有效性 - const accessToken = AuthStorage.getAccessToken(); - if (!accessToken) { - console.log("[useOnlineCount] 未检测到有效令牌,跳过初始化"); - return; - } - - // 建立 WebSocket 连接 - stomp.connect(); - - // 订阅主题 - subscribeToOnlineCount(); - }; - - /** - * 关闭 WebSocket 连接并清理资源 - */ - const cleanup = () => { - // 取消订阅 - if (subscriptionId) { - stomp.unsubscribe(subscriptionId); - subscriptionId = null; - } - - // 也可以通过主题地址取消订阅 - stomp.unsubscribeDestination(ONLINE_COUNT_TOPIC); - - // 断开连接 - stomp.disconnect(); - - // 重置状态 - onlineUserCount.value = 0; - lastUpdateTime.value = 0; - }; - - return { - // 状态 - onlineUserCount: readonly(onlineUserCount), - lastUpdateTime: readonly(lastUpdateTime), - isConnected: stomp.isConnected, - connectionState: stomp.connectionState, - - // 方法 - initialize, - cleanup, - }; -} - -/** - * 在线用户计数组合式函数(单例模式) - * - * 用于实时显示系统在线用户数量 - * - * @example - * ```ts - * // 在组件中使用(推荐) - * const { onlineUserCount, isConnected } = useOnlineCount(); - * - * // 手动控制初始化(高级用法) - * const { onlineUserCount, initialize, cleanup } = useOnlineCount({ autoInit: false }); - * onMounted(() => initialize()); - * onUnmounted(() => cleanup()); - * ``` - */ -export function useOnlineCount(options: { autoInit?: boolean } = {}) { - const { autoInit = true } = options; - - // 获取或创建单例实例 - if (!globalInstance) { - globalInstance = createOnlineCountComposable(); - } - - // 组件级自动初始化(仅在组件上下文中生效) - const instance = getCurrentInstance(); - if (autoInit && instance) { - onMounted(() => { - // 防止重复初始化:只有在未连接时才尝试初始化 - if (!globalInstance!.isConnected.value) { - globalInstance!.initialize(); - } - }); - - // 注意:组件卸载时不关闭连接,保持全局连接 - onUnmounted(() => { - // 全局连接由 cleanupWebSocket() 统一管理 - }); - } - - return globalInstance; -} diff --git a/src/composables/websocket/useStomp.ts b/src/composables/websocket/useStomp.ts deleted file mode 100644 index 740c12a7..00000000 --- a/src/composables/websocket/useStomp.ts +++ /dev/null @@ -1,568 +0,0 @@ -import { Client, type IMessage, type StompSubscription } from "@stomp/stompjs"; -import { AuthStorage } from "@/utils/auth"; - -export interface UseStompOptions { - /** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */ - brokerURL?: string; - /** 用于鉴权的 token,不传时使用 getAccessToken() 的返回值 */ - token?: string; - /** 重连延迟,单位毫秒,默认为 15000 */ - reconnectDelay?: number; - /** 连接超时时间,单位毫秒,默认为 10000 */ - connectionTimeout?: number; - /** 是否开启指数退避重连策略 */ - useExponentialBackoff?: boolean; - /** 最大重连次数,默认为 3 */ - maxReconnectAttempts?: number; - /** 最大重连延迟,单位毫秒,默认为 60000 */ - maxReconnectDelay?: number; - /** 是否开启调试日志 */ - debug?: boolean; - /** 是否在重连时自动恢复订阅,默认为 true */ - autoRestoreSubscriptions?: boolean; - /** - * 心跳接收间隔,单位毫秒,默认为 4000 - * 注意:标签页失活时,浏览器会节流定时器,建议设置较长的间隔(如 10000)以减少失活影响 - */ - heartbeatIncoming?: number; - /** - * 心跳发送间隔,单位毫秒,默认为 4000 - * 注意:标签页失活时,浏览器会节流定时器,建议设置较长的间隔(如 10000)以减少失活影响 - */ - heartbeatOutgoing?: number; -} - -/** - * 订阅配置信息 - */ -interface SubscriptionConfig { - destination: string; - callback: (message: IMessage) => void; -} - -/** - * 连接状态枚举 - */ -enum ConnectionState { - DISCONNECTED = "DISCONNECTED", - CONNECTING = "CONNECTING", - CONNECTED = "CONNECTED", - RECONNECTING = "RECONNECTING", -} - -/** - * STOMP WebSocket 连接管理组合式函数 - * - * 核心功能: - * - 自动连接管理(连接、断开、重连) - * - 订阅管理(订阅、取消订阅、自动恢复) - * - 心跳检测 - * - Token 自动刷新 - * - * @param options 配置选项 - * @returns STOMP 客户端操作接口 - */ -export function useStomp(options: UseStompOptions = {}) { - // ==================== 配置初始化 ==================== - const defaultBrokerURL = import.meta.env.VITE_APP_WS_ENDPOINT || ""; - - const config = { - brokerURL: ref(options.brokerURL ?? defaultBrokerURL), - reconnectDelay: options.reconnectDelay ?? 15000, - connectionTimeout: options.connectionTimeout ?? 10000, - useExponentialBackoff: options.useExponentialBackoff ?? false, - maxReconnectAttempts: options.maxReconnectAttempts ?? 3, - maxReconnectDelay: options.maxReconnectDelay ?? 60000, - autoRestoreSubscriptions: options.autoRestoreSubscriptions ?? true, - debug: options.debug ?? false, - heartbeatIncoming: options.heartbeatIncoming ?? 4000, - heartbeatOutgoing: options.heartbeatOutgoing ?? 4000, - }; - - // ==================== 状态管理 ==================== - const connectionState = ref(ConnectionState.DISCONNECTED); - const isConnected = computed(() => connectionState.value === ConnectionState.CONNECTED); - const reconnectAttempts = ref(0); - - // ==================== 定时器管理 ==================== - let reconnectTimer: ReturnType | null = null; - let connectionTimeoutTimer: ReturnType | null = null; - - // ==================== 订阅管理 ==================== - // 活动订阅:存储当前 STOMP 订阅对象 - const activeSubscriptions = new Map(); - // 订阅配置注册表:用于自动恢复订阅 - const subscriptionRegistry = new Map(); - - // ==================== 客户端实例 ==================== - const stompClient = ref(null); - let isManualDisconnect = false; - - // ==================== 工具函数 ==================== - - /** - * 清理所有定时器 - */ - const clearAllTimers = () => { - if (reconnectTimer) { - clearTimeout(reconnectTimer); - reconnectTimer = null; - } - if (connectionTimeoutTimer) { - clearTimeout(connectionTimeoutTimer); - connectionTimeoutTimer = null; - } - }; - - /** - * 日志输出(支持调试模式控制) - */ - const log = config.debug ? (...args: any[]) => console.log("[useStomp]", ...args) : () => {}; - const logWarn = (...args: any[]) => console.warn("[useStomp]", ...args); - const logError = (...args: any[]) => console.error("[useStomp]", ...args); - - /** - * 恢复所有订阅 - */ - const restoreSubscriptions = () => { - if (!config.autoRestoreSubscriptions || subscriptionRegistry.size === 0) { - return; - } - - log(`开始恢复 ${subscriptionRegistry.size} 个订阅...`); - - for (const [destination, subscriptionConfig] of subscriptionRegistry.entries()) { - try { - performSubscribe(destination, subscriptionConfig.callback); - } catch (error) { - logError(`恢复订阅 ${destination} 失败:`, error); - } - } - }; - - /** - * 初始化 STOMP 客户端 - */ - const initializeClient = () => { - // 如果客户端已存在且处于活动状态,直接返回 - if (stompClient.value && (stompClient.value.active || stompClient.value.connected)) { - log("STOMP 客户端已存在且处于活动状态,跳过初始化"); - return; - } - - // 检查 WebSocket 端点是否配置 - if (!config.brokerURL.value) { - logWarn("WebSocket 连接失败: 未配置 WebSocket 端点 URL"); - return; - } - - // 每次连接前重新获取最新令牌 - const accessToken = AuthStorage.getAccessToken(); - if (!accessToken) { - logWarn("WebSocket 连接失败:授权令牌为空,请先登录"); - return; - } - - // 清理旧客户端 - if (stompClient.value) { - try { - stompClient.value.deactivate(); - } catch (error) { - logWarn("清理旧客户端时出错:", error); - } - stompClient.value = null; - } - - // 创建 STOMP 客户端 - stompClient.value = new Client({ - brokerURL: config.brokerURL.value, - connectHeaders: { - Authorization: `Bearer ${accessToken}`, - }, - debug: config.debug ? (msg) => console.log("[STOMP]", msg) : () => {}, - reconnectDelay: 0, // 禁用内置重连,使用自定义重连逻辑 - heartbeatIncoming: config.heartbeatIncoming, - heartbeatOutgoing: config.heartbeatOutgoing, - }); - - // ==================== 事件监听器 ==================== - - // 连接成功 - stompClient.value.onConnect = () => { - connectionState.value = ConnectionState.CONNECTED; - reconnectAttempts.value = 0; - clearAllTimers(); - - log("✅ WebSocket 连接已建立"); - - // 自动恢复订阅 - restoreSubscriptions(); - }; - - // 连接断开 - stompClient.value.onDisconnect = () => { - connectionState.value = ConnectionState.DISCONNECTED; - log("❌ WebSocket 连接已断开"); - - // 清空活动订阅(但保留订阅配置用于恢复) - activeSubscriptions.clear(); - - // 如果不是手动断开且未达到最大重连次数,则尝试重连 - if (!isManualDisconnect && reconnectAttempts.value < config.maxReconnectAttempts) { - scheduleReconnect(); - } - }; - - // WebSocket 关闭 - stompClient.value.onWebSocketClose = (event) => { - connectionState.value = ConnectionState.DISCONNECTED; - log(`WebSocket 已关闭: code=${event?.code}, reason=${event?.reason}`); - - // 如果是手动断开,不重连 - if (isManualDisconnect) { - log("手动断开连接,不进行重连"); - return; - } - - // 对于异常关闭,尝试重连 - if ( - event?.code && - [1000, 1006, 1008, 1011].includes(event.code) && - reconnectAttempts.value < config.maxReconnectAttempts - ) { - log("检测到连接异常关闭,将尝试重连"); - scheduleReconnect(); - } - }; - - // STOMP 错误 - stompClient.value.onStompError = (frame) => { - logError("STOMP 错误:", frame.headers, frame.body); - connectionState.value = ConnectionState.DISCONNECTED; - - // 检查是否是授权错误 - const isAuthError = - frame.headers?.message?.includes("Unauthorized") || - frame.body?.includes("Unauthorized") || - frame.body?.includes("Token") || - frame.body?.includes("401"); - - if (isAuthError) { - logWarn("WebSocket 授权错误,停止重连"); - isManualDisconnect = true; // 授权错误不进行重连 - } - }; - }; - - /** - * 调度重连任务 - */ - const scheduleReconnect = () => { - // 如果正在连接或手动断开,不重连 - if (connectionState.value === ConnectionState.CONNECTING || isManualDisconnect) { - return; - } - - // 检查是否达到最大重连次数 - if (reconnectAttempts.value >= config.maxReconnectAttempts) { - logError(`已达到最大重连次数 (${config.maxReconnectAttempts}),停止重连`); - return; - } - - reconnectAttempts.value++; - connectionState.value = ConnectionState.RECONNECTING; - - // 计算重连延迟(支持指数退避) - const delay = config.useExponentialBackoff - ? Math.min( - config.reconnectDelay * Math.pow(2, reconnectAttempts.value - 1), - config.maxReconnectDelay - ) - : config.reconnectDelay; - - log(`准备重连 (${reconnectAttempts.value}/${config.maxReconnectAttempts}),延迟 ${delay}ms`); - - // 清除之前的重连计时器 - if (reconnectTimer) { - clearTimeout(reconnectTimer); - } - - // 设置重连计时器 - reconnectTimer = setTimeout(() => { - if (connectionState.value !== ConnectionState.CONNECTED && !isManualDisconnect) { - log(`开始第 ${reconnectAttempts.value} 次重连...`); - connect(); - } - }, delay); - }; - - // 监听 brokerURL 的变化,自动重新初始化 - watch(config.brokerURL, (newURL, oldURL) => { - if (newURL !== oldURL) { - log(`WebSocket 端点已更改: ${oldURL} -> ${newURL}`); - - // 断开当前连接 - if (stompClient.value && stompClient.value.connected) { - stompClient.value.deactivate(); - } - - // 重新初始化客户端 - initializeClient(); - } - }); - - // 初始化客户端 - initializeClient(); - - // ==================== 标签页可见性监听 ==================== - - /** - * 处理标签页可见性变化 - * 当标签页从失活变为激活时,检查连接状态并尝试重连 - */ - const handleVisibilityChange = () => { - if (document.hidden) { - log("标签页已失活"); - } else { - log("标签页已激活,检查WebSocket连接状态..."); - - // 标签页激活时,检查连接状态 - if (stompClient.value && !stompClient.value.connected && !isManualDisconnect) { - logWarn("检测到WebSocket连接已断开,尝试重新连接..."); - // 重置重连次数,给予更多重连机会 - reconnectAttempts.value = 0; - connect(); - } - } - }; - - // 监听标签页可见性变化 - if (typeof document !== "undefined") { - document.addEventListener("visibilitychange", handleVisibilityChange); - } - - // 清理函数:移除事件监听器 - const cleanup = () => { - if (typeof document !== "undefined") { - document.removeEventListener("visibilitychange", handleVisibilityChange); - } - disconnect(); - }; - - // ==================== 公共接口 ==================== - - /** - * 建立 WebSocket 连接 - */ - const connect = () => { - // 重置手动断开标志 - isManualDisconnect = false; - - // 检查是否配置了 WebSocket 端点 - if (!config.brokerURL.value) { - logError("WebSocket 连接失败: 未配置 WebSocket 端点 URL"); - return; - } - - // 防止重复连接 - if (connectionState.value === ConnectionState.CONNECTING) { - log("WebSocket 正在连接中,跳过重复连接请求"); - return; - } - - // 如果客户端不存在,先初始化 - if (!stompClient.value) { - initializeClient(); - } - - if (!stompClient.value) { - logError("STOMP 客户端初始化失败"); - return; - } - - // 避免重复连接:检查是否已连接 - if (stompClient.value.connected) { - log("WebSocket 已连接,跳过重复连接"); - connectionState.value = ConnectionState.CONNECTED; - return; - } - - // 设置连接状态 - connectionState.value = ConnectionState.CONNECTING; - - // 设置连接超时 - if (connectionTimeoutTimer) { - clearTimeout(connectionTimeoutTimer); - } - - connectionTimeoutTimer = setTimeout(() => { - if (connectionState.value === ConnectionState.CONNECTING) { - logWarn("WebSocket 连接超时"); - connectionState.value = ConnectionState.DISCONNECTED; - - // 超时后尝试重连 - if (!isManualDisconnect && reconnectAttempts.value < config.maxReconnectAttempts) { - scheduleReconnect(); - } - } - }, config.connectionTimeout); - - try { - stompClient.value.activate(); - log("正在建立 WebSocket 连接..."); - } catch (error) { - logError("激活 WebSocket 连接失败:", error); - connectionState.value = ConnectionState.DISCONNECTED; - } - }; - - /** - * 执行订阅操作(内部方法) - */ - const performSubscribe = (destination: string, callback: (message: IMessage) => void): string => { - if (!stompClient.value || !stompClient.value.connected) { - logWarn(`尝试订阅 ${destination} 失败: 客户端未连接`); - return ""; - } - - try { - const subscription = stompClient.value.subscribe(destination, callback); - const subscriptionId = subscription.id; - activeSubscriptions.set(subscriptionId, subscription); - log(`✓ 订阅成功: ${destination} (ID: ${subscriptionId})`); - return subscriptionId; - } catch (error) { - logError(`订阅 ${destination} 失败:`, error); - return ""; - } - }; - - /** - * 订阅指定主题 - * - * @param destination 目标主题地址(如:/topic/message) - * @param callback 接收到消息时的回调函数 - * @returns 订阅 ID,用于后续取消订阅 - */ - const subscribe = (destination: string, callback: (message: IMessage) => void): string => { - // 保存订阅配置到注册表,用于断线重连后自动恢复 - subscriptionRegistry.set(destination, { destination, callback }); - - // 如果已连接,立即订阅 - if (stompClient.value?.connected) { - return performSubscribe(destination, callback); - } - - log(`暂存订阅配置: ${destination},将在连接建立后自动订阅`); - return ""; - }; - - /** - * 取消订阅 - * - * @param subscriptionId 订阅 ID(由 subscribe 方法返回) - */ - const unsubscribe = (subscriptionId: string) => { - const subscription = activeSubscriptions.get(subscriptionId); - if (subscription) { - try { - subscription.unsubscribe(); - activeSubscriptions.delete(subscriptionId); - log(`✓ 已取消订阅: ${subscriptionId}`); - } catch (error) { - logWarn(`取消订阅 ${subscriptionId} 时出错:`, error); - } - } - }; - - /** - * 取消指定主题的订阅(从注册表中移除) - * - * @param destination 主题地址 - */ - const unsubscribeDestination = (destination: string) => { - // 从注册表中移除 - subscriptionRegistry.delete(destination); - - // 取消所有匹配该主题的活动订阅 - for (const [id, subscription] of activeSubscriptions.entries()) { - // 注意:STOMP 的 subscription 对象没有直接暴露 destination, - // 这里简化处理,实际使用时可能需要额外维护 id -> destination 的映射 - try { - subscription.unsubscribe(); - activeSubscriptions.delete(id); - } catch (error) { - logWarn(`取消订阅 ${id} 时出错:`, error); - } - } - - log(`✓ 已移除主题订阅配置: ${destination}`); - }; - - /** - * 断开 WebSocket 连接 - * - * @param clearSubscriptions 是否清除订阅注册表(默认为 true) - */ - const disconnect = (clearSubscriptions = true) => { - // 设置手动断开标志 - isManualDisconnect = true; - - // 清除所有定时器 - clearAllTimers(); - - // 取消所有活动订阅 - for (const [id, subscription] of activeSubscriptions.entries()) { - try { - subscription.unsubscribe(); - } catch (error) { - logWarn(`取消订阅 ${id} 时出错:`, error); - } - } - activeSubscriptions.clear(); - - // 可选:清除订阅注册表 - if (clearSubscriptions) { - subscriptionRegistry.clear(); - log("已清除所有订阅配置"); - } - - // 断开连接 - if (stompClient.value) { - try { - if (stompClient.value.connected || stompClient.value.active) { - stompClient.value.deactivate(); - log("✓ WebSocket 连接已主动断开"); - } - } catch (error) { - logError("断开 WebSocket 连接时出错:", error); - } - stompClient.value = null; - } - - connectionState.value = ConnectionState.DISCONNECTED; - reconnectAttempts.value = 0; - }; - - // ==================== 返回公共接口 ==================== - return { - // 状态 - connectionState: readonly(connectionState), - isConnected, - reconnectAttempts: readonly(reconnectAttempts), - - // 连接管理 - connect, - disconnect, - cleanup, // 清理资源(包括移除事件监听器) - - // 订阅管理 - subscribe, - unsubscribe, - unsubscribeDestination, - - // 统计信息 - getActiveSubscriptionCount: () => activeSubscriptions.size, - getRegisteredSubscriptionCount: () => subscriptionRegistry.size, - }; -} diff --git a/src/main.ts b/src/main.ts index 16789dd9..dd657854 100644 --- a/src/main.ts +++ b/src/main.ts @@ -34,7 +34,7 @@ import { configureVxeTable } from "@/plugins/vxe-table"; import { setupPermissionGuard } from "@/router/guards/permission"; // ===== 业务服务 ===== -import { setupWebSocket } from "@/composables"; +import { setupSse } from "@/composables"; // 创建 Vue 应用实例 const app = createApp(App); @@ -56,8 +56,8 @@ app.use(InstallCodeMirror); // 4️⃣ 路由守卫 setupPermissionGuard(); -// 5️⃣ WebSocket 初始化 -setupWebSocket(); +// 5️⃣ SSE 初始化 +setupSse(); // 6️⃣ 挂载应用 app.mount("#app"); diff --git a/src/store/modules/user.ts b/src/store/modules/user.ts index b92d32dd..2aa40351 100644 --- a/src/store/modules/user.ts +++ b/src/store/modules/user.ts @@ -8,7 +8,7 @@ import { AuthStorage } from "@/utils/auth"; import { usePermissionStoreHook } from "@/store/modules/permission"; import { useDictStoreHook } from "@/store/modules/dict"; import { useTagsViewStore } from "@/store"; -import { cleanupWebSocket } from "@/composables"; +import { cleanupSseServices } from "@/composables"; export const useUserStore = defineStore("user", () => { // 用户信息 @@ -76,8 +76,8 @@ export const useUserStore = defineStore("user", () => { useDictStoreHook().clearDictCache(); useTagsViewStore().delAllViews(); - // 3. 清理 WebSocket 连接 - cleanupWebSocket(); + // 3. 清理 SSE 连接 + cleanupSseServices(); } /** diff --git a/src/types/api/index.ts b/src/types/api/index.ts index f0f0f07a..adc9b31e 100644 --- a/src/types/api/index.ts +++ b/src/types/api/index.ts @@ -13,7 +13,6 @@ export * from "./dept"; export * from "./dict"; export * from "./config"; export * from "./log"; -export * from "./statistics"; export * from "./notice"; export * from "./tenant"; export * from "./tenant-plan"; diff --git a/src/types/api/log.ts b/src/types/api/log.ts index 7d74c7ae..0b4fe63f 100644 --- a/src/types/api/log.ts +++ b/src/types/api/log.ts @@ -51,3 +51,39 @@ export interface LogItem { /** 操作时间 */ createTime?: string; } + +/** 访问趋势查询参数 */ +export interface VisitTrendQueryParams { + /** 开始日期 */ + startDate: string; + /** 结束日期 */ + endDate: string; +} + +/** 访问趋势视图对象 */ +export interface VisitTrendDetail { + /** 日期列表 */ + dates: string[]; + /** 浏览量(PV)列表 */ + pvList: number[]; + /** 访客数(UV)列表 */ + uvList: number[]; + /** IP数列表 */ + ipList: number[]; +} + +/** 访问量统计视图对象 */ +export interface VisitStatsDetail { + /** 今日独立访客数(UV) */ + todayUvCount: number; + /** 累计独立访客数(UV) */ + totalUvCount: number; + /** 独立访客增长率 */ + uvGrowthRate: number; + /** 今日页面浏览量(PV) */ + todayPvCount: number; + /** 累计页面浏览量(PV) */ + totalPvCount: number; + /** 页面浏览量增长率 */ + pvGrowthRate: number; +} diff --git a/src/types/api/statistics.ts b/src/types/api/statistics.ts deleted file mode 100644 index 8e8a1593..00000000 --- a/src/types/api/statistics.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Statistics 统计类型定义 - */ - -/** 访问趋势查询参数 */ -export interface VisitTrendQueryParams { - /** 开始日期 */ - startDate: string; - /** 结束日期 */ - endDate: string; -} - -/** 访问趋势视图对象 */ -export interface VisitTrendDetail { - /** 日期列表 */ - dates: string[]; - /** 浏览量(PV)列表 */ - pvList: number[]; - /** 访客数(UV)列表 */ - uvList: number[]; - /** IP数列表 */ - ipList: number[]; -} - -/** 访问量统计视图对象 */ -export interface VisitStatsDetail { - /** 今日独立访客数(UV) */ - todayUvCount: number; - /** 累计独立访客数(UV) */ - totalUvCount: number; - /** 独立访客增长率 */ - uvGrowthRate: number; - /** 今日页面浏览量(PV) */ - todayPvCount: number; - /** 累计页面浏览量(PV) */ - totalPvCount: number; - /** 页面浏览量增长率 */ - pvGrowthRate: number; -} diff --git a/src/views/dashboard/index.vue b/src/views/dashboard/index.vue index b9948d5d..316ec1e9 100644 --- a/src/views/dashboard/index.vue +++ b/src/views/dashboard/index.vue @@ -130,25 +130,19 @@ @@ -391,7 +385,7 @@ defineOptions({ import { dayjs } from "element-plus"; import { ref } from "vue"; import { useRouter } from "vue-router"; -import StatisticsAPI from "@/api/system/statistics"; +import LogAPI from "@/api/system/log"; import type { VisitStatsDetail, VisitTrendDetail } from "@/types/api"; import { useUserStore } from "@/store/modules/user"; import { formatGrowthRate } from "@/utils"; @@ -413,23 +407,13 @@ const formattedTime = computed(() => { return useDateFormat(lastUpdateTime, "HH:mm:ss").value; }); -const wsStatusText = computed(() => { +const sseStatusText = computed(() => { if (!isConnected.value) { - return connectionState.value === "CONNECTING" || connectionState.value === "RECONNECTING" - ? "连接中" - : "未连接"; + return connectionState.value === "CONNECTING" ? "连接中" : "未连接"; } return "已连接"; }); -const wsStatusClass = computed(() => { - if (isConnected.value) - return "text-[--el-color-success] bg-[--el-color-success-light-9] border-[--el-color-success-light-7]"; - return connectionState.value === "CONNECTING" || connectionState.value === "RECONNECTING" - ? "text-[--el-color-warning] bg-[--el-color-warning-light-9] border-[--el-color-warning-light-7]" - : "text-[--el-color-danger] bg-[--el-color-danger-light-9] border-[--el-color-danger-light-7]"; -}); - const userStore = useUserStore(); // 当前时间(用于计算问候语) @@ -540,7 +524,7 @@ const visitTrendChartOptions = ref(); * 获取访客统计数据 */ const fetchVisitStatsData = () => { - StatisticsAPI.getVisitOverview() + LogAPI.getVisitOverview() .then((data) => { visitStatsData.value = data; }) @@ -558,7 +542,7 @@ const fetchVisitTrendData = () => { .toDate(); const endDate = new Date(); - StatisticsAPI.getVisitTrend({ + LogAPI.getVisitTrend({ startDate: dayjs(startDate).format("YYYY-MM-DD"), endDate: dayjs(endDate).format("YYYY-MM-DD"), }).then((data) => { diff --git a/src/views/demo/dict-sync.vue b/src/views/demo/dict-sync.vue index 41213f3a..f78c427c 100644 --- a/src/views/demo/dict-sync.vue +++ b/src/views/demo/dict-sync.vue @@ -3,15 +3,16 @@ - 本示例展示WebSocket实时更新字典缓存的效果。您可以编辑"男"性别字典项,保存后后端将通过WebSocket通知所有客户端刷新缓存。 + 本示例展示 SSE 实时更新字典缓存的效果。您可以编辑"男"性别字典项,保存后后端将通过 SSE + 通知所有客户端刷新缓存。 @@ -161,16 +162,16 @@ const dictForm = ref(null); // 选中的性别 const selectedGender = ref(""); -// 初始化WebSocket -const dictWebSocket = useDictSync(); +// 初始化 SSE +const dictSse = useDictSync(); // 获取连接状态 -const wsConnected = computed(() => dictWebSocket.isConnected); +const sseConnected = computed(() => dictSse.isConnected.value); -// WebSocket连接状态显示文本 -const wsStatusText = computed(() => (wsConnected.value ? "已连接" : "未连接")); +// SSE 连接状态显示文本 +const sseStatusText = computed(() => (sseConnected.value ? "已连接" : "未连接")); -// 保存WebSocket清理函数 +// 保存 SSE 清理函数 let unregisterCallback: (() => void) | null = null; // 当前选中字典的缓存状态 @@ -179,13 +180,13 @@ const dictCacheStatus = computed(() => { return dictStore.getDictItems(DICT_CODE).length > 0; }); -// 设置WebSocket -const setupWebSocket = () => { - // 初始化WebSocket连接 - dictWebSocket.initialize(); +// 设置 SSE +const setupSse = () => { + // 初始化 SSE 连接 + dictSse.initialize(); // 注册字典消息回调 - unregisterCallback = dictWebSocket.onDictChange((message: DictMessage) => { + unregisterCallback = dictSse.onDictChange((message: DictMessage) => { // 只有当消息是关于性别字典的更新时才处理 if (message.dictCode === DICT_CODE) { // 更新最后更新时间 @@ -224,7 +225,7 @@ const saveDict = async () => { // 更新时间 lastUpdateTime.value = useDateFormat(new Date(), "YYYY-MM-DD HH:mm:ss").value; - ElMessage.success("保存成功,后端将通过WebSocket通知所有客户端"); + ElMessage.success("保存成功,后端将通过 SSE 通知所有客户端"); saving.value = false; }; @@ -235,11 +236,11 @@ onMounted(async () => { await dictStore.loadDictItems(DICT_CODE); // 初始化选中性别为男 selectedGender.value = "1"; - // 设置WebSocket - setupWebSocket(); + // 设置 SSE + setupSse(); }); -// 组件卸载时清理WebSocket +// 组件卸载时清理 SSE onUnmounted(() => { unregisterCallback?.(); }); diff --git a/vite.config.ts b/vite.config.ts index 2a4477fe..61cfb254 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -107,8 +107,8 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => { "nprogress", "sortablejs", "qs", + "vxe-table", "path-browserify", - "@stomp/stompjs", "@element-plus/icons-vue", "element-plus/es", "element-plus/es/locale/lang/en",