mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-27 06:41:12 +00:00
feat(ai): 新增AI助手功能及相关组件
refactor(websocket): 重构WebSocket服务管理及字典同步功能 style(login): 优化登录页面样式及背景图 perf(menu): 改进菜单项展开状态监听逻辑 build: 更新依赖并添加prettier和lint-staged配置 docs: 更新README和注释说明 chore: 清理无用代码和文件
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import AiCommandApi from "@/api/ai";
|
||||
|
||||
/**
|
||||
* AI 操作处理器(简化版)
|
||||
*
|
||||
* 可以是简单函数,也可以是配置对象
|
||||
*/
|
||||
export type AiActionHandler<T = any> =
|
||||
| ((args: T) => Promise<void> | void)
|
||||
| {
|
||||
/** 执行函数 */
|
||||
execute: (args: T) => Promise<void> | void;
|
||||
/** 是否需要确认(默认 true) */
|
||||
needConfirm?: boolean;
|
||||
/** 确认消息(支持函数或字符串) */
|
||||
confirmMessage?: string | ((args: T) => string);
|
||||
/** 成功消息(支持函数或字符串) */
|
||||
successMessage?: string | ((args: T) => string);
|
||||
/** 是否调用后端 API(默认 false,如果为 true 则自动调用 executeCommand) */
|
||||
callBackendApi?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* AI 操作配置
|
||||
*/
|
||||
export interface UseAiActionOptions {
|
||||
/** 操作映射表:函数名 -> 处理器 */
|
||||
actionHandlers?: Record<string, AiActionHandler>;
|
||||
/** 数据刷新函数(操作完成后调用) */
|
||||
onRefresh?: () => Promise<void> | void;
|
||||
/** 自动搜索处理函数 */
|
||||
onAutoSearch?: (keywords: string) => void;
|
||||
/** 当前路由路径(用于执行命令时传递) */
|
||||
currentRoute?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 操作 Composable
|
||||
*
|
||||
* 统一处理 AI 助手传递的操作,支持:
|
||||
* - 自动搜索(通过 keywords + autoSearch 参数)
|
||||
* - 执行 AI 操作(通过 aiAction 参数)
|
||||
* - 配置化的操作处理器
|
||||
*/
|
||||
export function useAiAction(options: UseAiActionOptions = {}) {
|
||||
const route = useRoute();
|
||||
const { actionHandlers = {}, onRefresh, onAutoSearch, currentRoute = route.path } = options;
|
||||
|
||||
// 用于跟踪是否已卸载,防止在卸载后执行回调
|
||||
let isUnmounted = false;
|
||||
|
||||
/**
|
||||
* 执行 AI 操作(统一处理确认、执行、反馈流程)
|
||||
*/
|
||||
async function executeAiAction(action: any) {
|
||||
if (isUnmounted) return;
|
||||
|
||||
// 兼容两种入参:{ functionName, arguments } 或 { functionCall: { name, arguments } }
|
||||
const fnCall = action.functionCall ?? {
|
||||
name: action.functionName,
|
||||
arguments: action.arguments,
|
||||
};
|
||||
|
||||
if (!fnCall?.name) {
|
||||
ElMessage.warning("未识别的 AI 操作");
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找对应的处理器
|
||||
const handler = actionHandlers[fnCall.name];
|
||||
if (!handler) {
|
||||
ElMessage.warning(`暂不支持操作: ${fnCall.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 判断处理器类型(函数 or 配置对象)
|
||||
const isSimpleFunction = typeof handler === "function";
|
||||
|
||||
if (isSimpleFunction) {
|
||||
// 简单函数形式:直接执行
|
||||
await handler(fnCall.arguments);
|
||||
} else {
|
||||
// 配置对象形式:统一处理确认、执行、反馈
|
||||
const config = handler;
|
||||
|
||||
// 1. 确认阶段(默认需要确认)
|
||||
if (config.needConfirm !== false) {
|
||||
const confirmMsg =
|
||||
typeof config.confirmMessage === "function"
|
||||
? config.confirmMessage(fnCall.arguments)
|
||||
: config.confirmMessage || "确认执行此操作吗?";
|
||||
|
||||
await ElMessageBox.confirm(confirmMsg, "AI 助手操作确认", {
|
||||
confirmButtonText: "确认执行",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 执行阶段
|
||||
if (config.callBackendApi) {
|
||||
// 自动调用后端 API
|
||||
await AiCommandApi.executeCommand({
|
||||
originalCommand: action.originalCommand || "",
|
||||
confirmMode: "manual",
|
||||
userConfirmed: true,
|
||||
currentRoute,
|
||||
functionCall: {
|
||||
name: fnCall.name,
|
||||
arguments: fnCall.arguments,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 执行自定义函数
|
||||
await config.execute(fnCall.arguments);
|
||||
}
|
||||
|
||||
// 3. 成功反馈
|
||||
const successMsg =
|
||||
typeof config.successMessage === "function"
|
||||
? config.successMessage(fnCall.arguments)
|
||||
: config.successMessage || "操作执行成功";
|
||||
ElMessage.success(successMsg);
|
||||
}
|
||||
|
||||
// 4. 刷新数据
|
||||
if (onRefresh) {
|
||||
await onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
// 处理取消操作
|
||||
if (error === "cancel") {
|
||||
ElMessage.info("已取消操作");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("AI 操作执行失败:", error);
|
||||
ElMessage.error(error.message || "操作执行失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行后端命令(通用方法)
|
||||
*/
|
||||
async function executeCommand(
|
||||
functionName: string,
|
||||
args: any,
|
||||
options: {
|
||||
originalCommand?: string;
|
||||
confirmMode?: "auto" | "manual";
|
||||
needConfirm?: boolean;
|
||||
confirmMessage?: string;
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
originalCommand = "",
|
||||
confirmMode = "manual",
|
||||
needConfirm = false,
|
||||
confirmMessage,
|
||||
} = options;
|
||||
|
||||
// 如果需要确认,先显示确认对话框
|
||||
if (needConfirm && confirmMessage) {
|
||||
try {
|
||||
await ElMessageBox.confirm(confirmMessage, "AI 助手操作确认", {
|
||||
confirmButtonText: "确认执行",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
} catch {
|
||||
ElMessage.info("已取消操作");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await AiCommandApi.executeCommand({
|
||||
originalCommand,
|
||||
confirmMode,
|
||||
userConfirmed: true,
|
||||
currentRoute,
|
||||
functionCall: {
|
||||
name: functionName,
|
||||
arguments: args,
|
||||
},
|
||||
});
|
||||
|
||||
ElMessage.success("操作执行成功");
|
||||
} catch (error: any) {
|
||||
if (error !== "cancel") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理自动搜索
|
||||
*/
|
||||
function handleAutoSearch(keywords: string) {
|
||||
if (onAutoSearch) {
|
||||
onAutoSearch(keywords);
|
||||
} else {
|
||||
ElMessage.info(`AI 助手已为您自动搜索:${keywords}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化:处理 URL 参数中的 AI 操作
|
||||
*
|
||||
* 注意:此方法只处理 AI 相关参数,不负责页面数据的初始加载
|
||||
* 页面数据加载应由组件的 onMounted 钩子自行处理
|
||||
*/
|
||||
async function init() {
|
||||
if (isUnmounted) return;
|
||||
|
||||
// 检查是否有 AI 助手传递的参数
|
||||
const keywords = route.query.keywords as string;
|
||||
const autoSearch = route.query.autoSearch as string;
|
||||
const aiActionParam = route.query.aiAction as string;
|
||||
|
||||
// 如果没有任何 AI 参数,直接返回
|
||||
if (!keywords && !autoSearch && !aiActionParam) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 在 nextTick 中执行,确保页面数据已加载
|
||||
nextTick(async () => {
|
||||
if (isUnmounted) return;
|
||||
|
||||
// 1. 处理自动搜索
|
||||
if (autoSearch === "true" && keywords) {
|
||||
handleAutoSearch(keywords);
|
||||
}
|
||||
|
||||
// 2. 处理 AI 操作
|
||||
if (aiActionParam) {
|
||||
try {
|
||||
const aiAction = JSON.parse(decodeURIComponent(aiActionParam));
|
||||
await executeAiAction(aiAction);
|
||||
} catch (error) {
|
||||
console.error("解析 AI 操作失败:", error);
|
||||
ElMessage.error("AI 操作参数解析失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 组件挂载时自动初始化
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
|
||||
// 组件卸载时清理
|
||||
onBeforeUnmount(() => {
|
||||
isUnmounted = true;
|
||||
});
|
||||
|
||||
return {
|
||||
executeAiAction,
|
||||
executeCommand,
|
||||
handleAutoSearch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { useStomp } from "./websocket/useStomp";
|
||||
export { useDictSync } from "./websocket/useDictSync";
|
||||
export type { DictMessage } from "./websocket/useDictSync";
|
||||
export { useOnlineCount } from "./websocket/useOnlineCount";
|
||||
|
||||
export { useAiAction } from "./ai/useAiAction";
|
||||
export type { UseAiActionOptions, AiActionHandler } from "./ai/useAiAction";
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useDictStoreHook } from "@/store/modules/dict-store";
|
||||
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,
|
||||
|
||||
// 别名方法(向后兼容)
|
||||
initWebSocket: initialize,
|
||||
closeWebSocket: cleanup,
|
||||
onDictMessage: onDictChange,
|
||||
|
||||
// 用于测试和调试
|
||||
handleDictChangeMessage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 字典同步组合式函数(单例模式)
|
||||
*
|
||||
* 用于监听后端字典变更并自动同步到前端缓存
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { ref, onMounted, onUnmounted, getCurrentInstance } from "vue";
|
||||
import { useStomp } from "./useStomp";
|
||||
import { registerWebSocketInstance } from "@/utils/websocket";
|
||||
import { Auth } 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;
|
||||
|
||||
// 注册到全局实例管理器
|
||||
registerWebSocketInstance("onlineCount", stomp);
|
||||
|
||||
/**
|
||||
* 处理在线用户数量消息
|
||||
*/
|
||||
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 = Auth.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,
|
||||
|
||||
// 别名方法(向后兼容)
|
||||
initWebSocket: initialize,
|
||||
closeWebSocket: cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在线用户计数组合式函数(单例模式)
|
||||
*
|
||||
* 用于实时显示系统在线用户数量
|
||||
*
|
||||
* @param options 配置选项
|
||||
* @param options.autoInit 是否在组件挂载时自动初始化(默认 true)
|
||||
*
|
||||
* @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();
|
||||
}
|
||||
|
||||
// 只在组件上下文中且 autoInit 为 true 时使用生命周期钩子
|
||||
const instance = getCurrentInstance();
|
||||
if (autoInit && instance) {
|
||||
onMounted(() => {
|
||||
// 只有在未连接时才尝试初始化
|
||||
if (!globalInstance!.isConnected.value) {
|
||||
globalInstance!.initialize();
|
||||
}
|
||||
});
|
||||
|
||||
// 注意:不在卸载时关闭连接,保持全局连接
|
||||
onUnmounted(() => {});
|
||||
}
|
||||
|
||||
return globalInstance;
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
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 = (...args: any[]) => {
|
||||
if (config.debug) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user