fix: 修复提交冲突引发的问题
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -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",
|
||||
|
||||
7
pnpm-lock.yaml
generated
7
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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<any, VisitTrendDetail>({
|
||||
url: `${LOG_BASE_URL}/views/trend`,
|
||||
method: "get",
|
||||
params: queryParams,
|
||||
});
|
||||
},
|
||||
|
||||
/** 获取访问概览统计 */
|
||||
getVisitOverview() {
|
||||
return request<any, VisitStatsDetail>({
|
||||
url: `${LOG_BASE_URL}/views`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default LogAPI;
|
||||
|
||||
@@ -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<any, VisitTrendDetail>({
|
||||
url: `${STATISTICS_BASE_URL}/views/trend`,
|
||||
method: "get",
|
||||
params: queryParams,
|
||||
});
|
||||
},
|
||||
/** 获取访问概览统计 */
|
||||
getVisitOverview() {
|
||||
return request<any, VisitStatsDetail>({
|
||||
url: `${STATISTICS_BASE_URL}/views`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default StatisticsAPI;
|
||||
@@ -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<NoticeItem[]>([]);
|
||||
@@ -18,7 +20,7 @@ export function useNotice() {
|
||||
const detail = ref<NoticeDetail | null>(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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
32
src/composables/sse/index.ts
Normal file
32
src/composables/sse/index.ts
Normal file
@@ -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";
|
||||
83
src/composables/sse/useDictSync.ts
Normal file
83
src/composables/sse/useDictSync.ts
Normal file
@@ -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<typeof createDictSyncComposable> | null = null;
|
||||
|
||||
function createDictSyncComposable() {
|
||||
const dictStore = useDictStoreHook();
|
||||
const sse = useSse();
|
||||
|
||||
const messageCallbacks = ref<DictChangeCallback[]>([]);
|
||||
|
||||
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;
|
||||
}
|
||||
65
src/composables/sse/useOnlineCount.ts
Normal file
65
src/composables/sse/useOnlineCount.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ref, onMounted, getCurrentInstance } from "vue";
|
||||
import { useSse } from "./useSse";
|
||||
|
||||
let globalInstance: ReturnType<typeof createOnlineCountComposable> | 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;
|
||||
}
|
||||
219
src/composables/sse/useSse.ts
Normal file
219
src/composables/sse/useSse.ts
Normal file
@@ -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<typeof createSseConnection> | 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>(SseConnectionState.DISCONNECTED);
|
||||
const isConnected = computed(() => connectionState.value === SseConnectionState.CONNECTED);
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let isManualDisconnect = false;
|
||||
let connectionTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const eventHandlers = new Map<string, Set<EventHandler>>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<typeof createDictSyncComposable> | 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<DictChangeCallback[]>([]);
|
||||
|
||||
// 订阅 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;
|
||||
}
|
||||
@@ -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<typeof createOnlineCountComposable> | 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;
|
||||
}
|
||||
@@ -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>(ConnectionState.DISCONNECTED);
|
||||
const isConnected = computed(() => connectionState.value === ConnectionState.CONNECTED);
|
||||
const reconnectAttempts = ref(0);
|
||||
|
||||
// ==================== 定时器管理 ====================
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let connectionTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ==================== 订阅管理 ====================
|
||||
// 活动订阅:存储当前 STOMP 订阅对象
|
||||
const activeSubscriptions = new Map<string, StompSubscription>();
|
||||
// 订阅配置注册表:用于自动恢复订阅
|
||||
const subscriptionRegistry = new Map<string, SubscriptionConfig>();
|
||||
|
||||
// ==================== 客户端实例 ====================
|
||||
const stompClient = ref<Client | null>(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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -130,25 +130,19 @@
|
||||
<template #header>
|
||||
<div class="flex-x-between">
|
||||
<span class="text-xs font-medium text-[--el-text-color-secondary]">在线用户</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-0.5 text-xs leading-5 rounded-full border select-none"
|
||||
:class="wsStatusClass"
|
||||
>
|
||||
<el-icon class="text-sm">
|
||||
<Loading
|
||||
v-if="
|
||||
!isConnected &&
|
||||
(connectionState === 'CONNECTING' || connectionState === 'RECONNECTING')
|
||||
"
|
||||
/>
|
||||
<CircleCheck v-else-if="isConnected" />
|
||||
<CircleClose v-else />
|
||||
</el-icon>
|
||||
<span class="text-[--el-text-color-secondary]">WebSocket</span>
|
||||
<span class="font-medium">{{ wsStatusText }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<el-tag
|
||||
:type="
|
||||
isConnected ? 'success' : connectionState === 'CONNECTING' ? 'warning' : 'danger'
|
||||
"
|
||||
size="small"
|
||||
>
|
||||
<el-icon class="mr-1">
|
||||
<Loading v-if="!isConnected && connectionState === 'CONNECTING'" />
|
||||
<CircleCheck v-else-if="isConnected" />
|
||||
<CircleClose v-else />
|
||||
</el-icon>
|
||||
SSE {{ sseStatusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>字典WebSocket实时更新演示</span>
|
||||
<el-tag :type="wsConnected ? 'success' : 'danger'" size="small" class="ml-2">
|
||||
WebSocket {{ wsStatusText }}
|
||||
<span>字典 SSE 实时更新演示</span>
|
||||
<el-tag :type="sseConnected ? 'success' : 'danger'" size="small" class="ml-2">
|
||||
SSE {{ sseStatusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert type="info" :closable="false" class="mb-4">
|
||||
本示例展示WebSocket实时更新字典缓存的效果。您可以编辑"男"性别字典项,保存后后端将通过WebSocket通知所有客户端刷新缓存。
|
||||
本示例展示 SSE 实时更新字典缓存的效果。您可以编辑"男"性别字典项,保存后后端将通过 SSE
|
||||
通知所有客户端刷新缓存。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="16">
|
||||
@@ -161,16 +162,16 @@ const dictForm = ref<DictItemForm | null>(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?.();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user