From 854d37dcdc516ef06929016b43fd9931bd566ebf Mon Sep 17 00:00:00 2001 From: zhangtao <9480807882@qq.com> Date: Tue, 16 Dec 2025 01:29:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E6=96=B0=E5=A2=9EAI=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E5=8A=9F=E8=83=BD=E5=8F=8A=E7=9B=B8=E5=85=B3=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor(websocket): 重构WebSocket服务管理及字典同步功能 style(login): 优化登录页面样式及背景图 perf(menu): 改进菜单项展开状态监听逻辑 build: 更新依赖并添加prettier和lint-staged配置 docs: 更新README和注释说明 chore: 清理无用代码和文件 --- frontend/eslint.config.ts | 30 +- frontend/package.json | 11 +- frontend/src/App.vue | 15 +- frontend/src/api/ai/index.ts | 180 +++++ frontend/src/assets/icons/ai.svg | 1 + frontend/src/assets/images/login-bg.svg | 63 ++ frontend/src/assets/images/login-bg1.svg | 71 ++ frontend/src/components/AiAssistant/index.vue | 672 ++++++++++++++++++ frontend/src/composables/ai/useAiAction.ts | 269 +++++++ frontend/src/composables/index.ts | 8 + .../src/composables/websocket/useDictSync.ts | 201 ++++++ .../composables/websocket/useOnlineCount.ts | 187 +++++ .../src/composables/websocket/useStomp.ts | 578 +++++++++++++++ .../src/layouts/components/AppMain/index.vue | 5 + .../src/layouts/components/Menu/BasicMenu.vue | 12 +- .../layouts/components/Menu/MixTopMenu.vue | 3 +- frontend/src/settings.ts | 3 +- frontend/src/store/modules/settings.store.ts | 32 + frontend/src/types/global.d.ts | 2 + frontend/src/utils/websocket.ts | 114 +++ .../module_system/auth/components/Login.vue | 10 +- .../views/module_system/auth/index copy.vue | 162 +++++ .../src/views/module_system/auth/index.vue | 587 ++++++++++++--- frontend/tsconfig.json | 2 +- frontend/vite.config.ts | 4 +- 25 files changed, 3118 insertions(+), 104 deletions(-) create mode 100644 frontend/src/api/ai/index.ts create mode 100644 frontend/src/assets/icons/ai.svg create mode 100644 frontend/src/assets/images/login-bg.svg create mode 100644 frontend/src/assets/images/login-bg1.svg create mode 100644 frontend/src/components/AiAssistant/index.vue create mode 100644 frontend/src/composables/ai/useAiAction.ts create mode 100644 frontend/src/composables/index.ts create mode 100644 frontend/src/composables/websocket/useDictSync.ts create mode 100644 frontend/src/composables/websocket/useOnlineCount.ts create mode 100644 frontend/src/composables/websocket/useStomp.ts create mode 100644 frontend/src/utils/websocket.ts create mode 100644 frontend/src/views/module_system/auth/index copy.vue diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts index 6266d434..77230ea3 100644 --- a/frontend/eslint.config.ts +++ b/frontend/eslint.config.ts @@ -1,11 +1,15 @@ // https://eslint.org/docs/latest/use/configure/configuration-files-new import eslint from "@eslint/js"; -import pluginVue from "eslint-plugin-vue"; -import * as typescriptEslint from "typescript-eslint"; -import vueParser from "vue-eslint-parser"; import globals from "globals"; +// TypeScript支持 +import * as typescriptEslint from "typescript-eslint"; +// Vue支持 +import pluginVue from "eslint-plugin-vue"; +import vueParser from "vue-eslint-parser"; +// 代码风格与格式化 import configPrettier from "eslint-config-prettier"; +import prettierPlugin from "eslint-plugin-prettier"; // 解析自动导入配置 import fs from "node:fs"; @@ -59,6 +63,7 @@ export default [ "**/*.min.*", "**/auto-imports.d.ts", "**/components.d.ts", + "**/types/**/*.d.ts", ], }, @@ -135,6 +140,7 @@ export default [ sourceType: "module", parser: typescriptEslint.parser, extraFileExtensions: [".vue"], + tsconfigRootDir: __dirname, }, }, rules: { @@ -177,8 +183,10 @@ export default [ languageOptions: { parser: typescriptEslint.parser, parserOptions: { - project: true, - tsconfigRootDir: import.meta.dirname, + ecmaVersion: "latest", + sourceType: "module", + project: "./tsconfig.json", + tsconfigRootDir: __dirname, }, }, rules: { @@ -215,5 +223,15 @@ export default [ }, // Prettier 集成(必须放在最后) - configPrettier, + { + plugins: { + prettier: prettierPlugin, // 将 Prettier 的输出作为 ESLint 的问题来报告 + }, + rules: { + ...configPrettier.rules, + "prettier/prettier": ["error", {}, { usePrettierrc: true }], + "arrow-body-style": "off", + "prefer-arrow-callback": "off", + }, + }, ]; diff --git a/frontend/package.json b/frontend/package.json index 8b673af5..6ae4b517 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,14 +1,14 @@ { - "name": "fastapi-admin", + "name": "fastapiadmin", "description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板", - "version": "2.0.0", + "version": "2.2.0", "private": true, "type": "module", "scripts": { "i": "pnpm install", "dev": "vite", "prod": "vite --mode prod", - "build": "vite build", + "build": "vue-tsc --noEmit & vite build", "build:pro": "pnpm vite build --mode pro", "build:gitee": "pnpm vite build --mode gitee", "build:dev": "pnpm vite build --mode dev", @@ -112,6 +112,11 @@ "eslint-plugin-vue": "^10.4.0", "fs-extra": "^11.2.0", "husky": "^9.1.7", + "lint-staged": "^15.5.2", + "postcss": "^8.5.6", + "postcss-html": "^1.8.0", + "postcss-scss": "^4.0.9", + "prettier": "^3.6.2", "sass": "^1.89.2", "stylelint": "^16.25.0", "stylelint-config-html": "^1.1.0", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7a5f3ff6..21f4d667 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,23 +8,36 @@ class="wh-full" > + + + + + diff --git a/frontend/src/composables/ai/useAiAction.ts b/frontend/src/composables/ai/useAiAction.ts new file mode 100644 index 00000000..1b505716 --- /dev/null +++ b/frontend/src/composables/ai/useAiAction.ts @@ -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 = + | ((args: T) => Promise | void) + | { + /** 执行函数 */ + execute: (args: T) => Promise | 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; + /** 数据刷新函数(操作完成后调用) */ + onRefresh?: () => Promise | 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, + }; +} diff --git a/frontend/src/composables/index.ts b/frontend/src/composables/index.ts new file mode 100644 index 00000000..1fd36224 --- /dev/null +++ b/frontend/src/composables/index.ts @@ -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"; + diff --git a/frontend/src/composables/websocket/useDictSync.ts b/frontend/src/composables/websocket/useDictSync.ts new file mode 100644 index 00000000..1c0d1875 --- /dev/null +++ b/frontend/src/composables/websocket/useDictSync.ts @@ -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 | null = null; + +/** + * 创建字典同步组合式函数(内部工厂函数) + */ +function createDictSyncComposable() { + const dictStore = useDictStoreHook(); + + // 使用优化后的 useStomp + const stomp = useStomp({ + reconnectDelay: 20000, + connectionTimeout: 15000, + useExponentialBackoff: false, + maxReconnectAttempts: 3, + autoRestoreSubscriptions: true, // 自动恢复订阅 + debug: false, + }); + + // 字典主题地址 + const DICT_TOPIC = "/topic/dict"; + + // 消息回调函数列表 + const messageCallbacks = ref([]); + + // 订阅 ID(用于取消订阅) + let subscriptionId: string | null = null; + + /** + * 处理字典变更事件 + */ + const handleDictChangeMessage = (message: IMessage) => { + if (!message.body) { + return; + } + + try { + const data = JSON.parse(message.body) as DictChangeMessage; + const { dictCode } = data; + + if (!dictCode) { + console.warn("[DictSync] 收到无效的字典变更消息:缺少 dictCode"); + return; + } + + // 清除缓存,等待按需加载 + dictStore.removeDictItem(dictCode); + + // 执行所有注册的回调函数 + messageCallbacks.value.forEach((callback) => { + try { + callback(data); + } catch (error) { + console.error("[DictSync] 回调函数执行失败:", error); + } + }); + } catch (error) { + console.error("[DictSync] 解析字典变更消息失败:", error); + } + }; + + /** + * 初始化 WebSocket 连接并订阅字典主题 + */ + const initialize = () => { + // 检查是否配置了 WebSocket 端点 + const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT; + if (!wsEndpoint) { + console.log("[DictSync] 未配置 WebSocket 端点,跳过字典同步功能"); + return; + } + + // console.log("[DictSync] 初始化字典同步服务..."); // 高频日志已禁用 + + // 建立 WebSocket 连接 + stomp.connect(); + + // 订阅字典主题(useStomp 会自动处理重连后的订阅恢复) + subscriptionId = stomp.subscribe(DICT_TOPIC, handleDictChangeMessage); + + // if (subscriptionId) { + // console.log(`[DictSync] 已订阅字典主题: ${DICT_TOPIC}`); + // } else { + // console.log(`[DictSync] 暂存字典主题订阅,等待连接建立后自动订阅`); + // } + }; + + /** + * 关闭 WebSocket 连接并清理资源 + */ + const cleanup = () => { + // 取消订阅(如果有的话) + if (subscriptionId) { + stomp.unsubscribe(subscriptionId); + subscriptionId = null; + } + + // 也可以通过主题地址取消订阅 + stomp.unsubscribeDestination(DICT_TOPIC); + + // 断开连接 + stomp.disconnect(); + + // 清空回调列表 + messageCallbacks.value = []; + }; + + /** + * 注册字典变更回调函数 + * + * @param callback 回调函数 + * @returns 返回一个取消注册的函数 + */ + const onDictChange = (callback: DictChangeCallback) => { + messageCallbacks.value.push(callback); + + // 返回取消注册的函数 + return () => { + const index = messageCallbacks.value.indexOf(callback); + if (index !== -1) { + messageCallbacks.value.splice(index, 1); + } + }; + }; + + return { + // 状态 + isConnected: stomp.isConnected, + connectionState: stomp.connectionState, + + // 方法 + initialize, + cleanup, + onDictChange, + + // 别名方法(向后兼容) + 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; +} diff --git a/frontend/src/composables/websocket/useOnlineCount.ts b/frontend/src/composables/websocket/useOnlineCount.ts new file mode 100644 index 00000000..516cef4a --- /dev/null +++ b/frontend/src/composables/websocket/useOnlineCount.ts @@ -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 | 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; +} diff --git a/frontend/src/composables/websocket/useStomp.ts b/frontend/src/composables/websocket/useStomp.ts new file mode 100644 index 00000000..43f05dbf --- /dev/null +++ b/frontend/src/composables/websocket/useStomp.ts @@ -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.DISCONNECTED); + const isConnected = computed(() => connectionState.value === ConnectionState.CONNECTED); + const reconnectAttempts = ref(0); + + // ==================== 定时器管理 ==================== + let reconnectTimer: ReturnType | null = null; + let connectionTimeoutTimer: ReturnType | null = null; + + // ==================== 订阅管理 ==================== + // 活动订阅:存储当前 STOMP 订阅对象 + const activeSubscriptions = new Map(); + // 订阅配置注册表:用于自动恢复订阅 + const subscriptionRegistry = new Map(); + + // ==================== 客户端实例 ==================== + const stompClient = ref(null); + let isManualDisconnect = false; + + // ==================== 工具函数 ==================== + + /** + * 清理所有定时器 + */ + const clearAllTimers = () => { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (connectionTimeoutTimer) { + clearTimeout(connectionTimeoutTimer); + connectionTimeoutTimer = null; + } + }; + + /** + * 日志输出(支持调试模式控制) + */ + const log = (...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, + }; +} diff --git a/frontend/src/layouts/components/AppMain/index.vue b/frontend/src/layouts/components/AppMain/index.vue index 8510dc64..8a94a5ee 100644 --- a/frontend/src/layouts/components/AppMain/index.vue +++ b/frontend/src/layouts/components/AppMain/index.vue @@ -9,6 +9,11 @@ + + + +
+ diff --git a/frontend/src/layouts/components/Menu/BasicMenu.vue b/frontend/src/layouts/components/Menu/BasicMenu.vue index c9ddff26..20be8155 100644 --- a/frontend/src/layouts/components/Menu/BasicMenu.vue +++ b/frontend/src/layouts/components/Menu/BasicMenu.vue @@ -132,6 +132,16 @@ const onMenuClose = (index: string) => { expandedMenuIndexes.value = expandedMenuIndexes.value.filter((item) => item !== index); }; +/** + * 监听展开的菜单项变化,更新父菜单样式 + */ +watch( + () => expandedMenuIndexes.value, + () => { + updateParentMenuStyles(); + } +); + /** * 监听菜单模式变化:当菜单模式切换为水平模式时,关闭所有展开的菜单项, * 避免在水平模式下菜单项显示错位。 @@ -220,7 +230,7 @@ function updateParentMenuStyles() { } } } catch (error) { - console.error("Error updating parent menu styles:", error); + console.error("更新父菜单样式时出错:", error); } }); } diff --git a/frontend/src/layouts/components/Menu/MixTopMenu.vue b/frontend/src/layouts/components/Menu/MixTopMenu.vue index c32cec94..c3c9bdc6 100644 --- a/frontend/src/layouts/components/Menu/MixTopMenu.vue +++ b/frontend/src/layouts/components/Menu/MixTopMenu.vue @@ -102,8 +102,7 @@ const updateMenuState = (topMenuPath: string, skipNavigation = false) => { // 确保路径有效且不相同才更新,避免重复操作 if (topMenuPath && appStore.activeTopMenuPath) { appStore.activeTopMenu(topMenuPath); // 设置激活的顶部菜单 - // 只有当路由映射表中存在该路径时才更新侧边菜单 - permissionStore.setMixLayoutSideMenus(topMenuPath); // 设置混合布局左侧菜单 + permissionStore.setMixLayoutSideMenus(topMenuPath); // 只有当路由映射表中存在该路径时才更新侧边菜单。 设置混合布局左侧菜单 } // 如果是点击菜单且状态已变更,才进行导航 diff --git a/frontend/src/settings.ts b/frontend/src/settings.ts index 2c022be0..42b7fa61 100644 --- a/frontend/src/settings.ts +++ b/frontend/src/settings.ts @@ -34,7 +34,6 @@ export const defaultSettings: AppSettings = { // 主题颜色 - 修改此值时需同步修改 src/styles/variables.scss themeColor: "#4080FF", // 是否显示水印 (修改默认开启水印) - // showWatermark: false, showWatermark: true, // 水印内容 watermarkContent: pkg.name, @@ -44,6 +43,8 @@ export const defaultSettings: AppSettings = { guideVisible: false, /** 是否启动引导 */ showGuide: true, + // 是否启用 AI 助手 + enableAiAssistant: false, }; // 主题色预设 - 现代化配色方案 diff --git a/frontend/src/store/modules/settings.store.ts b/frontend/src/store/modules/settings.store.ts index 09d30456..add6ac47 100644 --- a/frontend/src/store/modules/settings.store.ts +++ b/frontend/src/store/modules/settings.store.ts @@ -13,6 +13,7 @@ interface SettingsState { showWatermark: boolean; showSettings: boolean; showGuide: boolean; // 引导功能开关 + enableAiAssistant: boolean; // 桌面端工具显示设置 showMenuSearch: boolean; @@ -44,14 +45,25 @@ export const useSettingsStore = defineStore("setting", () => { defaultSettings.showTagsView ); const showAppLogo = useStorage(SETTINGS_KEYS.SHOW_APP_LOGO, defaultSettings.showAppLogo); + // 是否显示水印 const showWatermark = useStorage( SETTINGS_KEYS.SHOW_WATERMARK, defaultSettings.showWatermark ); + + // 是否启用 AI 助手 + const enableAiAssistant = useStorage( + "vea:ui:enable_ai_assistant", + defaultSettings.enableAiAssistant + ); + + // 是否显示系统设置 const showSettings = useStorage( SETTINGS_KEYS.SHOW_SETTINGS, defaultSettings.showSettings ); + + // 是否显示引导功能 const showGuide = useStorage(SETTINGS_KEYS.SHOW_GUIDE, defaultSettings.showGuide); // 引导功能开关 // 🎯 桌面端工具设置 - 持久化 @@ -59,18 +71,26 @@ export const useSettingsStore = defineStore("setting", () => { SETTINGS_KEYS.SHOW_MENU_SEARCH, defaultSettings.showMenuSearch ); + + // 是否显示全屏切换 const showFullscreen = useStorage( SETTINGS_KEYS.SHOW_FULLSCREEN, defaultSettings.showFullscreen ); + + // 是否显示布局大小选择 const showSizeSelect = useStorage( SETTINGS_KEYS.SHOW_SIZE_SELECT, defaultSettings.showSizeSelect ); + + // 是否显示语言选择 const showLangSelect = useStorage( SETTINGS_KEYS.SHOW_LANG_SELECT, defaultSettings.showLangSelect ); + + // 是否显示通知 const showNotification = useStorage( SETTINGS_KEYS.SHOW_NOTIFICATION, defaultSettings.showNotification @@ -81,8 +101,12 @@ export const useSettingsStore = defineStore("setting", () => { SETTINGS_KEYS.SIDEBAR_COLOR_SCHEME, defaultSettings.sidebarColorScheme ); + + // 布局设置 const layout = useStorage(SETTINGS_KEYS.LAYOUT, defaultSettings.layout as LayoutMode); + // 主题颜色 const themeColor = useStorage(SETTINGS_KEYS.THEME_COLOR, defaultSettings.themeColor); + // 主题模式 const theme = useStorage(SETTINGS_KEYS.THEME, defaultSettings.theme); // 🎯 设置项映射 @@ -99,6 +123,7 @@ export const useSettingsStore = defineStore("setting", () => { showNotification, sidebarColorScheme, layout, + enableAiAssistant, } as const; // 🎯 监听器 - 主题变化 @@ -134,14 +159,17 @@ export const useSettingsStore = defineStore("setting", () => { theme.value = newTheme; } + // 更新主题颜色 function updateThemeColor(newColor: string): void { themeColor.value = newColor; } + // 更新侧边栏配色方案 function updateSidebarColorScheme(newScheme: string): void { sidebarColorScheme.value = newScheme; } + // 更新布局 function updateLayout(newLayout: LayoutMode): void { layout.value = newLayout; } @@ -151,10 +179,12 @@ export const useSettingsStore = defineStore("setting", () => { settingsVisible.value = !settingsVisible.value; } + // 显示设置面板 function showSettingsPanel(): void { settingsVisible.value = true; } + // 隐藏设置面板 function hideSettingsPanel(): void { settingsVisible.value = false; } @@ -167,6 +197,7 @@ export const useSettingsStore = defineStore("setting", () => { showWatermark.value = defaultSettings.showWatermark; showSettings.value = defaultSettings.showSettings; showGuide.value = defaultSettings.showGuide; + enableAiAssistant.value = defaultSettings.enableAiAssistant; // 桌面端工具设置 showMenuSearch.value = defaultSettings.showMenuSearch; @@ -192,6 +223,7 @@ export const useSettingsStore = defineStore("setting", () => { showWatermark, showSettings, showGuide, + enableAiAssistant, // 🎯 桌面端工具状态 showMenuSearch, diff --git a/frontend/src/types/global.d.ts b/frontend/src/types/global.d.ts index 98cd33e5..809775e8 100644 --- a/frontend/src/types/global.d.ts +++ b/frontend/src/types/global.d.ts @@ -98,6 +98,8 @@ declare global { guideVisible: boolean; /** 是否启动引导 */ showGuide: boolean; + /** 是否启用AI助手 */ + enableAiAssistant: boolean; } /** diff --git a/frontend/src/utils/websocket.ts b/frontend/src/utils/websocket.ts new file mode 100644 index 00000000..4c0b19a6 --- /dev/null +++ b/frontend/src/utils/websocket.ts @@ -0,0 +1,114 @@ +/** + * WebSocket 服务管理 + * + * @description + * 统一管理应用中的所有 WebSocket 连接 + * - 字典同步 WebSocket + * - 在线用户计数 WebSocket + * - 其他业务 WebSocket + * + * @author 有来技术团队 + */ + +import { useDictSync } from "@/composables"; +import { Auth } from "@/utils/auth"; + +/** + * WebSocket 服务实例约定接口 + */ +type WebSocketService = { + disconnect?: () => void; + closeWebSocket?: () => void; + cleanup?: () => void; + [key: string]: any; +}; + +/** + * 全局 WebSocket 实例管理 + */ +const websocketInstances = new Map(); + +/** + * 防止重复初始化的状态标记 + */ +let isInitialized = false; +let dictWebSocketInstance: ReturnType | null = null; + +/** + * 注册 WebSocket 实例 + */ +export function registerWebSocketInstance(key: string, instance: WebSocketService) { + websocketInstances.set(key, instance); +} + +/** + * 获取 WebSocket 实例 + */ +export function getWebSocketInstance(key: string) { + return websocketInstances.get(key); +} + +/** + * 初始化 WebSocket 服务 + */ +export function setupWebSocket() { + if (isInitialized) { + console.warn("[WebSocket] 已初始化,跳过重复初始化"); + return; + } + + if (!Auth.getAccessToken()) { + console.warn("[WebSocket] 未登录,跳过 WebSocket 初始化"); + return; + } + + try { + dictWebSocketInstance = useDictSync(); + registerWebSocketInstance("dict-sync", dictWebSocketInstance); + isInitialized = true; + console.log("[WebSocket] 初始化成功"); + } catch (error) { + console.error("[WebSocket] 初始化失败:", error); + } +} + +/** + * 清理所有 WebSocket 连接 + */ +export function cleanupWebSocket() { + console.log("[WebSocket] 开始清理连接..."); + + websocketInstances.forEach((instance, key) => { + try { + if (instance.disconnect) { + instance.disconnect(); + } else if (instance.closeWebSocket) { + instance.closeWebSocket(); + } else if (instance.cleanup) { + instance.cleanup(); + } + console.log(`[WebSocket] ${key} 已断开`); + } catch (error) { + console.error(`[WebSocket] ${key} 清理失败:`, error); + } + }); + + websocketInstances.clear(); + dictWebSocketInstance = null; + isInitialized = false; + console.log("[WebSocket] 清理完成"); +} + +/** + * 重新初始化 WebSocket + */ +export function reinitializeWebSocket() { + cleanupWebSocket(); + setupWebSocket(); +} + +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => { + cleanupWebSocket(); + }); +} diff --git a/frontend/src/views/module_system/auth/components/Login.vue b/frontend/src/views/module_system/auth/components/Login.vue index b892e58b..3b780849 100644 --- a/frontend/src/views/module_system/auth/components/Login.vue +++ b/frontend/src/views/module_system/auth/components/Login.vue @@ -38,22 +38,24 @@ -
+
-
- +
+ - + + 点击获取验证码
diff --git a/frontend/src/views/module_system/auth/index copy.vue b/frontend/src/views/module_system/auth/index copy.vue new file mode 100644 index 00000000..c01cd8d4 --- /dev/null +++ b/frontend/src/views/module_system/auth/index copy.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/frontend/src/views/module_system/auth/index.vue b/frontend/src/views/module_system/auth/index.vue index c01cd8d4..6a91201d 100644 --- a/frontend/src/views/module_system/auth/index.vue +++ b/frontend/src/views/module_system/auth/index.vue @@ -1,14 +1,7 @@ @@ -101,62 +120,464 @@ const loginPreset = reactive<{ username: string; password: string }>({ password: "123456", }); +let notificationInstance: ReturnType | null = null; + +const showVoteNotification = () => { + notificationInstance = ElNotification({ + title: "⭐ FastapiAdmin 完全开源 · 期待您的 Star 支持 🙏", + message: `项目持续迭代中,若对您有所帮助,欢迎点亮 Star 支持! +
Github仓库 → +
Gitee仓库 →`, + type: "success", + position: "bottom-left", + duration: 0, + dangerouslyUseHTMLString: true, + }); +}; + onMounted(() => { + setTimeout(showVoteNotification, 500); configStore.getConfig(); }); + +onBeforeUnmount(() => { + if (notificationInstance) { + notificationInstance.close(); + notificationInstance = null; + } +}); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 14bcc8c9..9275d89b 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -28,6 +28,6 @@ "types": ["node", "vite/client", "element-plus/global"] }, - "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "eslint.config.ts", "uno.config.ts"], + "include": ["src/types/**/*.d.ts", "src/**/*.ts", "src/**/*.vue", "vite.config.ts", "eslint.config.ts", "uno.config.ts"], "exclude": ["node_modules", "dist"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ed425911..be56435f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,5 +1,5 @@ import vue from "@vitejs/plugin-vue"; -import { type ConfigEnv, loadEnv, defineConfig } from "vite"; +import { type ConfigEnv, type UserConfig, loadEnv, defineConfig } from "vite"; import AutoImport from "unplugin-auto-import/vite"; import Components from "unplugin-vue-components/vite"; @@ -18,7 +18,7 @@ const __APP_INFO__ = { const pathSrc = resolve(__dirname, "src"); // Vite配置 https://cn.vitejs.dev/config -export default defineConfig(({ mode }: ConfigEnv) => { +export default defineConfig(({ mode }: ConfigEnv): UserConfig => { const env = loadEnv(mode, process.cwd()); const isProduction = mode === "production";