fix: 修复提交冲突引发的问题

This commit is contained in:
Ray.Hao
2026-03-24 08:18:40 +08:00
parent 4eea3ed6cb
commit dd52225378
25 changed files with 573 additions and 1199 deletions

View File

@@ -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

View File

@@ -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",
},
},
]);

View File

@@ -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",
},
},
]);

View File

@@ -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
View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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";

View 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";

View 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;
}

View 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;
}

View 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;
}
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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();
// 5WebSocket 初始化
setupWebSocket();
// 5SSE 初始化
setupSse();
// 6⃣ 挂载应用
app.mount("#app");

View File

@@ -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();
}
/**

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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) => {

View File

@@ -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?.();
});

View File

@@ -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",