refactor: ♻️ 提取 ROLE_ROOT 常量到 constants 目录并全局统一引用

This commit is contained in:
Ray.Hao
2025-05-20 10:34:44 +08:00
parent 2a3d2543ee
commit 7df7e1f47b
16 changed files with 63 additions and 64 deletions

11
src/composables/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* 全局组合式函数入口文件
* 导出所有可用的组合式函数
*/
// 导出核心组合式函数
export { useStomp } from "./useStomp";
// 导出业务服务组合式函数
export { useDictSync } from "./useDictSync";
export { useOnlineCount } from "./useOnlineCount";

View File

@@ -0,0 +1,197 @@
import { useDictStoreHook } from "@/store/modules/dict.store";
import { useStomp } from "./useStomp";
import { IMessage } from "@stomp/stompjs";
import { ref } from "vue";
// 字典消息类型
export interface DictMessage {
dictCode: string;
timestamp: number;
}
// 字典事件回调类型
export type DictMessageCallback = (_message: DictMessage) => void;
// 全局单例实例
let instance: ReturnType<typeof createDictSyncHook> | null = null;
/**
* 创建字典同步组合式函数
* 负责监听后端字典变更并同步到前端
*/
function createDictSyncHook() {
const dictStore = useDictStoreHook();
// 使用现有的useStomp配置适合字典场景的重连参数
const { isConnected, connect, subscribe, unsubscribe, disconnect } = useStomp({
reconnectDelay: 20000, // 字典更新重连时间
connectionTimeout: 15000, // 连接超时阈值
useExponentialBackoff: false, // 使用固定间隔重连策略
maxReconnectAttempts: 3, // 最多重连3次
});
// 存储订阅ID
const subscriptionIds = ref<string[]>([]);
// 已订阅的主题
const subscribedTopics = ref<Set<string>>(new Set());
// 消息回调函数列表
const messageCallbacks = ref<DictMessageCallback[]>([]);
/**
* 注册字典消息回调
* @param callback 回调函数
*/
const onDictMessage = (callback: DictMessageCallback) => {
messageCallbacks.value.push(callback);
return () => {
// 返回取消注册的函数
const index = messageCallbacks.value.indexOf(callback);
if (index !== -1) {
messageCallbacks.value.splice(index, 1);
}
};
};
/**
* 初始化WebSocket
*/
const initWebSocket = async () => {
try {
// 检查是否配置了WebSocket端点
const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT;
if (!wsEndpoint) {
console.log("[WebSocket] 未配置WebSocket端点,跳过连接");
return;
}
// 连接WebSocket
connect();
// 设置字典订阅
setupDictSubscription();
} catch (error) {
console.error("[WebSocket] 初始化失败:", error);
}
};
/**
* 关闭WebSocket
*/
const closeWebSocket = () => {
// 取消所有订阅
subscriptionIds.value.forEach((id) => {
unsubscribe(id);
});
subscriptionIds.value = [];
subscribedTopics.value.clear();
// 断开连接
disconnect();
};
/**
* 设置字典订阅
*/
const setupDictSubscription = () => {
const topic = "/topic/dict";
// 防止重复订阅
if (subscribedTopics.value.has(topic)) {
console.log(`跳过重复订阅: ${topic}`);
return;
}
console.log(`开始尝试订阅字典主题: ${topic}`);
// 使用简化的重试逻辑依赖useStomp的连接管理
const attemptSubscribe = () => {
if (!isConnected.value) {
console.log("等待WebSocket连接建立...");
// 10秒后再次尝试
setTimeout(attemptSubscribe, 10000);
return;
}
// 检查是否已订阅
if (subscribedTopics.value.has(topic)) {
return;
}
console.log(`连接已建立,开始订阅: ${topic}`);
// 订阅字典更新
const subId = subscribe(topic, (message: IMessage) => {
handleDictEvent(message);
});
if (subId) {
subscriptionIds.value.push(subId);
subscribedTopics.value.add(topic);
console.log(`字典主题订阅成功: ${topic}`);
} else {
console.warn(`字典主题订阅失败: ${topic}`);
}
};
// 开始尝试订阅
attemptSubscribe();
};
/**
* 处理字典事件
* @param message STOMP消息
*/
const handleDictEvent = (message: IMessage) => {
if (!message.body) return;
try {
// 记录接收到的消息
console.log(`收到字典更新消息: ${message.body}`);
// 尝试解析消息
const parsedData = JSON.parse(message.body) as DictMessage;
const dictCode = parsedData.dictCode;
if (!dictCode) return;
// 清除缓存,等待按需加载
dictStore.removeDictItem(dictCode);
console.log(`字典缓存已清除: ${dictCode}`);
// 调用所有注册的回调函数
messageCallbacks.value.forEach((callback) => {
try {
callback(parsedData);
} catch (callbackError) {
console.error("[WebSocket] 回调执行失败:", callbackError);
}
});
// 显示提示消息
console.info(`字典 ${dictCode} 已变更,将在下次使用时自动加载`);
} catch (error) {
console.error("[WebSocket] 解析消息失败:", error);
}
};
return {
isConnected,
initWebSocket,
closeWebSocket,
handleDictEvent,
onDictMessage,
};
}
/**
* 字典同步组合式函数
* 用于监听后端字典变更并同步到前端
*/
export function useDictSync() {
if (!instance) {
instance = createDictSyncHook();
}
return instance;
}

View File

@@ -0,0 +1,181 @@
import { ref, onMounted, onUnmounted, watch } from "vue";
import { useStomp } from "./useStomp";
import { ElMessage } from "element-plus";
import { Storage } from "@/utils/storage";
import { ACCESS_TOKEN_KEY } from "@/constants/cache-keys";
/**
* 在线用户计数组合式函数
* 用于订阅后端推送的在线用户数量变化
*/
export function useOnlineCount() {
// 在线用户数量
const onlineUserCount = ref(0);
// 最后更新时间戳
const lastUpdateTime = ref(0);
// 连接状态
const isConnected = ref(false);
// 连接正在尝试中
const isConnecting = ref(false);
// 使用Stomp客户端 - 配置使用指数退避策略
const {
connect,
subscribe,
unsubscribe,
disconnect,
isConnected: stompConnected,
} = useStomp({
reconnectDelay: 15000, // 重连基础延迟
maxReconnectAttempts: 3, // 重连次数上限
connectionTimeout: 10000, // 连接超时
useExponentialBackoff: true, // 启用指数退避
});
// 订阅ID
let subscriptionId = "";
// 连接超时计时器
let connectionTimeoutTimer: any = null;
// 监听Stomp连接状态
watch(stompConnected, (connected) => {
if (connected && isConnecting.value) {
isConnected.value = true;
isConnecting.value = false;
// 一旦连接成功,立即订阅主题
subscribeToOnlineCount();
console.log("WebSocket连接成功已订阅在线用户计数主题");
}
});
/**
* 订阅在线用户计数主题
*/
const subscribeToOnlineCount = () => {
if (!stompConnected.value) {
// 10秒后重试订阅
setTimeout(subscribeToOnlineCount, 10000);
return;
}
// 如果已经订阅,先取消订阅
if (subscriptionId) {
unsubscribe(subscriptionId);
}
// 订阅在线用户计数主题
subscriptionId = subscribe("/topic/online-count", (message) => {
try {
const data = message.body;
const jsonData = JSON.parse(data);
const count = typeof jsonData === "number" ? jsonData : jsonData.count;
if (!isNaN(count)) {
onlineUserCount.value = count;
lastUpdateTime.value = Date.now();
}
} catch (error) {
console.error("解析在线用户数量失败:", error);
}
});
};
/**
* 初始化WebSocket连接并订阅在线用户主题
*/
const initWebSocket = () => {
if (isConnecting.value) return;
// 检查WebSocket端点是否配置
const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT;
if (!wsEndpoint) {
console.log("未配置WebSocket端点(VITE_APP_WS_ENDPOINT),跳过WebSocket连接");
return;
}
// 检查是否有可用的令牌
const hasToken = !!Storage.get(ACCESS_TOKEN_KEY, "");
if (!hasToken) {
console.log("没有检测到有效令牌不尝试WebSocket连接");
return;
}
isConnecting.value = true;
// 连接WebSocket
connect();
// 设置连接超时显示UI提示
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = setTimeout(() => {
if (!isConnected.value) {
console.warn("WebSocket连接超时将自动尝试重连");
ElMessage.warning("正在尝试连接服务器,请稍候...");
// 超时后尝试重新连接
closeWebSocket();
setTimeout(() => {
// 再次检查令牌有效性
if (Storage.get(ACCESS_TOKEN_KEY, "")) {
initWebSocket();
} else {
console.log("令牌无效,放弃重连");
}
}, 3000);
}
}, 10000); // 较长的UI提示超时
// 监听连接状态变化,连接成功后清除超时计时器
const unwatch = watch(stompConnected, (connected) => {
if (connected) {
clearTimeout(connectionTimeoutTimer);
unwatch();
}
});
};
/**
* 关闭WebSocket连接
*/
const closeWebSocket = () => {
if (subscriptionId) {
unsubscribe(subscriptionId);
subscriptionId = "";
}
// 清除连接超时计时器
if (connectionTimeoutTimer) {
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = null;
}
disconnect();
isConnected.value = false;
isConnecting.value = false;
};
// 组件挂载时初始化WebSocket
onMounted(() => {
initWebSocket();
});
// 组件卸载时关闭WebSocket
onUnmounted(() => {
closeWebSocket();
});
return {
onlineUserCount,
lastUpdateTime,
isConnected,
isConnecting,
initWebSocket,
closeWebSocket,
};
}

316
src/composables/useStomp.ts Normal file
View File

@@ -0,0 +1,316 @@
import { Client, IMessage, StompSubscription } from "@stomp/stompjs";
import { Storage } from "@/utils/storage";
import { ACCESS_TOKEN_KEY } from "@/constants/cache-keys";
import { ref, watch } from "vue";
export interface UseStompOptions {
/** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */
brokerURL?: string;
/** 用于鉴权的 token不传时使用 getAccessToken() 的返回值 */
token?: string;
/** 重连延迟,单位毫秒,默认为 8000 */
reconnectDelay?: number;
/** 连接超时时间,单位毫秒,默认为 10000 */
connectionTimeout?: number;
/** 是否开启指数退避重连策略 */
useExponentialBackoff?: boolean;
/** 最大重连次数,默认为 5 */
maxReconnectAttempts?: number;
/** 最大重连延迟,单位毫秒,默认为 60000 */
maxReconnectDelay?: number;
/** 是否开启调试日志 */
debug?: boolean;
}
/**
* STOMP WebSocket连接组合式函数
* 用于管理WebSocket连接的建立、断开、重连和消息订阅
*/
export function useStomp(options: UseStompOptions = {}) {
// 默认值brokerURL 从环境变量中获取token 从 getAccessToken() 获取
const defaultBrokerURL = import.meta.env.VITE_APP_WS_ENDPOINT || "";
const brokerURL = ref(options.brokerURL ?? defaultBrokerURL);
// 默认配置参数
const reconnectDelay = options.reconnectDelay ?? 15000; // 默认15秒重连间隔
const connectionTimeout = options.connectionTimeout ?? 10000;
const useExponentialBackoff = options.useExponentialBackoff ?? false;
const maxReconnectAttempts = options.maxReconnectAttempts ?? 3; // 最多重连3次
const maxReconnectDelay = options.maxReconnectDelay ?? 60000;
// 连接状态标记
const isConnected = ref(false);
// 重连尝试次数
const reconnectCount = ref(0);
// 重连计时器
let reconnectTimer: any = null;
// 连接超时计时器
let connectionTimeoutTimer: any = null;
// 存储所有订阅
const subscriptions = new Map<string, StompSubscription>();
// 用于保存 STOMP 客户端的实例
let client = ref<Client | null>(null);
/**
* 初始化 STOMP 客户端
*/
const initializeClient = () => {
if (client.value) {
return;
}
// 检查WebSocket端点是否配置
if (!brokerURL.value) {
console.error("WebSocket连接失败: 未配置WebSocket端点URL");
return;
}
// 每次连接前重新获取最新令牌不依赖之前的token值
const currentToken = Storage.get(ACCESS_TOKEN_KEY, "");
// 检查令牌是否为空,如果为空则不进行连接
if (!currentToken) {
console.error("WebSocket连接失败授权令牌为空请先登录");
return;
}
// 创建 STOMP 客户端
client.value = new Client({
brokerURL: brokerURL.value,
connectHeaders: {
Authorization: `Bearer ${currentToken}`,
},
debug: options.debug ? console.log : () => {},
reconnectDelay: useExponentialBackoff ? 0 : reconnectDelay, // 禁用内置重连机制
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
// 设置连接监听器
client.value.onConnect = () => {
isConnected.value = true;
reconnectCount.value = 0;
clearTimeout(connectionTimeoutTimer);
console.log("WebSocket连接已建立");
};
// 设置断开连接监听器
client.value.onDisconnect = () => {
isConnected.value = false;
console.log("WebSocket连接已断开");
// 如果使用自定义指数退避重连策略,则在这里处理
if (useExponentialBackoff && reconnectCount.value < maxReconnectAttempts) {
handleReconnect();
}
};
// 设置 Web Socket 关闭监听器
client.value.onWebSocketClose = (event) => {
isConnected.value = false;
console.log(`WebSocket已关闭: ${event?.code} ${event?.reason}`);
// 如果是授权问题导致的关闭,尝试重新获取令牌
if (event?.code === 1000 || event?.code === 1006 || event?.code === 1008) {
console.log("可能是授权问题导致连接关闭,尝试重新建立连接");
// 等待一段时间后再尝试重连,避免立即重连
setTimeout(() => {
// 强制重新初始化客户端,获取最新令牌
client.value = null;
// 检查当前是否有有效令牌
const freshToken = Storage.get(ACCESS_TOKEN_KEY, "");
if (freshToken) {
initializeClient();
connect();
} else {
console.warn("没有有效令牌暂不重连WebSocket");
}
}, 3000);
}
};
// 设置错误监听器
client.value.onStompError = (frame) => {
console.error("STOMP错误:", frame.headers, frame.body);
// 检查是否是授权错误
if (
frame.headers?.message?.includes("Unauthorized") ||
frame.body?.includes("Unauthorized") ||
frame.body?.includes("Token")
) {
console.warn("WebSocket授权错误请检查登录状态");
}
};
};
/**
* 处理重连逻辑
*/
const handleReconnect = () => {
if (reconnectCount.value >= maxReconnectAttempts) {
console.error(`已达到最大重连次数(${maxReconnectAttempts}),停止重连`);
return;
}
reconnectCount.value++;
console.log(`尝试重连(${reconnectCount.value}/${maxReconnectAttempts})...`);
// 使用指数退避策略增加重连间隔
const delay = useExponentialBackoff
? Math.min(reconnectDelay * Math.pow(2, reconnectCount.value - 1), maxReconnectDelay)
: reconnectDelay;
// 清除之前的计时器
if (reconnectTimer) {
clearTimeout(reconnectTimer);
}
// 设置重连计时器
reconnectTimer = setTimeout(() => {
if (!isConnected.value && client.value) {
client.value.activate();
}
}, delay);
};
// 监听 brokerURL 的变化,若地址改变则重新初始化
watch(brokerURL, (newURL, oldURL) => {
if (newURL !== oldURL) {
console.log(`brokerURL changed from ${oldURL} to ${newURL}`);
// 断开当前连接,重新激活客户端
if (client.value && client.value.connected) {
client.value.deactivate();
}
brokerURL.value = newURL;
initializeClient(); // 重新初始化客户端
}
});
// 初始化客户端
initializeClient();
/**
* 激活连接(如果已经连接或正在激活则直接返回)
*/
const connect = () => {
// 检查是否有配置WebSocket端点
if (!brokerURL.value) {
console.error("WebSocket连接失败: 未配置WebSocket端点URL");
return;
}
if (!client.value) {
initializeClient();
}
if (!client.value) {
console.error("STOMP客户端初始化失败");
return;
}
// 避免重复连接:检查是否已连接或正在连接
if (client.value.connected) {
console.log("WebSocket已经连接,跳过重复连接");
return;
}
if (client.value.active) {
console.log("WebSocket连接正在进行中,跳过重复连接请求");
return;
}
// 设置连接超时
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = setTimeout(() => {
if (!isConnected.value) {
console.warn("WebSocket连接超时");
if (useExponentialBackoff) {
handleReconnect();
}
}
}, connectionTimeout);
client.value.activate();
};
/**
* 订阅指定主题
* @param destination 目标主题地址
* @param callback 接收到消息时的回调函数
* @returns 返回订阅 id用于后续取消订阅
*/
const subscribe = (destination: string, callback: (_message: IMessage) => void): string => {
if (!client.value || !client.value.connected) {
console.warn(`尝试订阅 ${destination} 失败: 客户端未连接`);
return "";
}
try {
const subscription = client.value.subscribe(destination, callback);
const subscriptionId = subscription.id;
subscriptions.set(subscriptionId, subscription);
console.log(`订阅成功: ${destination}, ID: ${subscriptionId}`);
return subscriptionId;
} catch (error) {
console.error(`订阅 ${destination} 失败:`, error);
return "";
}
};
/**
* 取消订阅
* @param subscriptionId 订阅 id
*/
const unsubscribe = (subscriptionId: string) => {
const subscription = subscriptions.get(subscriptionId);
if (subscription) {
subscription.unsubscribe();
subscriptions.delete(subscriptionId);
console.log(`已取消订阅: ${subscriptionId}`);
}
};
/**
* 断开WebSocket连接
*/
const disconnect = () => {
if (client.value && client.value.connected) {
// 清除所有订阅
for (const [id, subscription] of subscriptions.entries()) {
subscription.unsubscribe();
subscriptions.delete(id);
}
// 断开连接
client.value.deactivate();
console.log("WebSocket连接已断开");
}
// 清除重连计时器
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// 清除连接超时计时器
if (connectionTimeoutTimer) {
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = null;
}
isConnected.value = false;
reconnectCount.value = 0;
};
return {
isConnected,
connect,
subscribe,
unsubscribe,
disconnect,
};
}