mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-21 20:55:14 +00:00
@@ -1,11 +1,15 @@
|
|||||||
// https://eslint.org/docs/latest/use/configure/configuration-files-new
|
// https://eslint.org/docs/latest/use/configure/configuration-files-new
|
||||||
|
|
||||||
import eslint from "@eslint/js";
|
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";
|
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 configPrettier from "eslint-config-prettier";
|
||||||
|
import prettierPlugin from "eslint-plugin-prettier";
|
||||||
|
|
||||||
// 解析自动导入配置
|
// 解析自动导入配置
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
@@ -59,6 +63,7 @@ export default [
|
|||||||
"**/*.min.*",
|
"**/*.min.*",
|
||||||
"**/auto-imports.d.ts",
|
"**/auto-imports.d.ts",
|
||||||
"**/components.d.ts",
|
"**/components.d.ts",
|
||||||
|
"**/types/**/*.d.ts",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -135,6 +140,7 @@ export default [
|
|||||||
sourceType: "module",
|
sourceType: "module",
|
||||||
parser: typescriptEslint.parser,
|
parser: typescriptEslint.parser,
|
||||||
extraFileExtensions: [".vue"],
|
extraFileExtensions: [".vue"],
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
@@ -177,8 +183,10 @@ export default [
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: typescriptEslint.parser,
|
parser: typescriptEslint.parser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: true,
|
ecmaVersion: "latest",
|
||||||
tsconfigRootDir: import.meta.dirname,
|
sourceType: "module",
|
||||||
|
project: "./tsconfig.json",
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
@@ -215,5 +223,15 @@ export default [
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Prettier 集成(必须放在最后)
|
// Prettier 集成(必须放在最后)
|
||||||
configPrettier,
|
{
|
||||||
|
plugins: {
|
||||||
|
prettier: prettierPlugin, // 将 Prettier 的输出作为 ESLint 的问题来报告
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...configPrettier.rules,
|
||||||
|
"prettier/prettier": ["error", {}, { usePrettierrc: true }],
|
||||||
|
"arrow-body-style": "off",
|
||||||
|
"prefer-arrow-callback": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "fastapi-admin",
|
"name": "fastapiadmin",
|
||||||
"description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板",
|
"description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板",
|
||||||
"version": "2.0.0",
|
"version": "2.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"i": "pnpm install",
|
"i": "pnpm install",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"prod": "vite --mode prod",
|
"prod": "vite --mode prod",
|
||||||
"build": "vite build",
|
"build": "vue-tsc --noEmit & vite build",
|
||||||
"build:pro": "pnpm vite build --mode pro",
|
"build:pro": "pnpm vite build --mode pro",
|
||||||
"build:gitee": "pnpm vite build --mode gitee",
|
"build:gitee": "pnpm vite build --mode gitee",
|
||||||
"build:dev": "pnpm vite build --mode dev",
|
"build:dev": "pnpm vite build --mode dev",
|
||||||
@@ -112,6 +112,11 @@
|
|||||||
"eslint-plugin-vue": "^10.4.0",
|
"eslint-plugin-vue": "^10.4.0",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
"husky": "^9.1.7",
|
"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",
|
"sass": "^1.89.2",
|
||||||
"stylelint": "^16.25.0",
|
"stylelint": "^16.25.0",
|
||||||
"stylelint-config-html": "^1.1.0",
|
"stylelint-config-html": "^1.1.0",
|
||||||
|
|||||||
+14
-1
@@ -8,23 +8,36 @@
|
|||||||
class="wh-full"
|
class="wh-full"
|
||||||
>
|
>
|
||||||
<router-view />
|
<router-view />
|
||||||
|
|
||||||
|
<!-- AI 助手 -->
|
||||||
|
<AiAssistant v-if="enableAiAssistant" />
|
||||||
</el-watermark>
|
</el-watermark>
|
||||||
</el-config-provider>
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAppStore, useSettingsStore } from "@/store";
|
import { useAppStore, useSettingsStore, useUserStore } from "@/store";
|
||||||
import { defaultSettings } from "@/settings";
|
import { defaultSettings } from "@/settings";
|
||||||
import { ThemeMode } from "@/enums/settings/theme.enum";
|
import { ThemeMode } from "@/enums/settings/theme.enum";
|
||||||
import { ComponentSize } from "@/enums/settings/layout.enum";
|
import { ComponentSize } from "@/enums/settings/layout.enum";
|
||||||
|
import AiAssistant from "@/components/AiAssistant/index.vue";
|
||||||
|
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const locale = computed(() => appStore.locale);
|
const locale = computed(() => appStore.locale);
|
||||||
const size = computed(() => appStore.size as ComponentSize);
|
const size = computed(() => appStore.size as ComponentSize);
|
||||||
const showWatermark = computed(() => settingsStore.showWatermark);
|
const showWatermark = computed(() => settingsStore.showWatermark);
|
||||||
|
|
||||||
|
// 只有在启用 AI 助手且用户已登录时才显示
|
||||||
|
// 使用 userInfo 作为响应式依赖,当用户退出登录时会自动更新
|
||||||
|
const enableAiAssistant = computed(() => {
|
||||||
|
const isEnabled = settingsStore.enableAiAssistant;
|
||||||
|
const isLoggedIn = userStore.basicInfo && Object.keys(userStore.basicInfo).length > 0;
|
||||||
|
return isEnabled && isLoggedIn;
|
||||||
|
});
|
||||||
|
|
||||||
// 明亮/暗黑主题水印字体颜色适配
|
// 明亮/暗黑主题水印字体颜色适配
|
||||||
const fontColor = computed(() => {
|
const fontColor = computed(() => {
|
||||||
return settingsStore.theme === ThemeMode.DARK ? "rgba(255, 255, 255, .15)" : "rgba(0, 0, 0, .15)";
|
return settingsStore.theme === ThemeMode.DARK ? "rgba(255, 255, 255, .15)" : "rgba(0, 0, 0, .15)";
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 命令请求参数
|
||||||
|
*/
|
||||||
|
export interface AiCommandRequest {
|
||||||
|
/** 用户输入的自然语言命令 */
|
||||||
|
command: string;
|
||||||
|
/** 当前页面路由(用于上下文) */
|
||||||
|
currentRoute?: string;
|
||||||
|
/** 当前激活的组件名称 */
|
||||||
|
currentComponent?: string;
|
||||||
|
/** 额外上下文信息 */
|
||||||
|
context?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 函数调用参数
|
||||||
|
*/
|
||||||
|
export interface FunctionCall {
|
||||||
|
/** 函数名称 */
|
||||||
|
name: string;
|
||||||
|
/** 函数描述 */
|
||||||
|
description?: string;
|
||||||
|
/** 参数对象 */
|
||||||
|
arguments: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 命令解析响应
|
||||||
|
*/
|
||||||
|
export interface AiCommandResponse {
|
||||||
|
/** 解析日志ID(用于关联执行记录) */
|
||||||
|
parseLogId?: string;
|
||||||
|
/** 是否成功解析 */
|
||||||
|
success: boolean;
|
||||||
|
/** 解析后的函数调用列表 */
|
||||||
|
functionCalls: FunctionCall[];
|
||||||
|
/** AI 的理解和说明 */
|
||||||
|
explanation?: string;
|
||||||
|
/** 置信度 (0-1) */
|
||||||
|
confidence?: number;
|
||||||
|
/** 错误信息 */
|
||||||
|
error?: string;
|
||||||
|
/** 原始 LLM 响应(用于调试) */
|
||||||
|
rawResponse?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 命令执行请求
|
||||||
|
*/
|
||||||
|
export interface AiExecuteRequest {
|
||||||
|
/** 关联的解析日志ID */
|
||||||
|
parseLogId?: string;
|
||||||
|
/** 原始命令(用于审计) */
|
||||||
|
originalCommand?: string;
|
||||||
|
/** 要执行的函数调用 */
|
||||||
|
functionCall: FunctionCall;
|
||||||
|
/** 确认模式:auto=自动执行, manual=需要用户确认 */
|
||||||
|
confirmMode?: "auto" | "manual";
|
||||||
|
/** 用户确认标志 */
|
||||||
|
userConfirmed?: boolean;
|
||||||
|
/** 幂等性令牌(防止重复执行) */
|
||||||
|
idempotencyKey?: string;
|
||||||
|
/** 当前页面路由 */
|
||||||
|
currentRoute?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 命令执行响应
|
||||||
|
*/
|
||||||
|
export interface AiExecuteResponse {
|
||||||
|
/** 是否执行成功 */
|
||||||
|
success: boolean;
|
||||||
|
/** 执行结果数据 */
|
||||||
|
data?: any;
|
||||||
|
/** 执行结果说明 */
|
||||||
|
message?: string;
|
||||||
|
/** 影响的记录数 */
|
||||||
|
affectedRows?: number;
|
||||||
|
/** 错误信息 */
|
||||||
|
error?: string;
|
||||||
|
/** 记录ID(用于追踪) */
|
||||||
|
recordId?: string;
|
||||||
|
/** 需要用户确认 */
|
||||||
|
requiresConfirmation?: boolean;
|
||||||
|
/** 确认提示信息 */
|
||||||
|
confirmationPrompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiCommandRecordPageQuery extends PageQuery {
|
||||||
|
keywords?: string;
|
||||||
|
executeStatus?: number;
|
||||||
|
parseStatus?: number;
|
||||||
|
userId?: number;
|
||||||
|
aiProvider?: string;
|
||||||
|
aiModel?: string;
|
||||||
|
functionName?: string;
|
||||||
|
createTime?: [string, string];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiCommandRecordVO {
|
||||||
|
id: string;
|
||||||
|
userId: number;
|
||||||
|
username: string;
|
||||||
|
originalCommand: string;
|
||||||
|
aiProvider?: string;
|
||||||
|
aiModel?: string;
|
||||||
|
parseStatus?: number;
|
||||||
|
functionCalls?: string;
|
||||||
|
explanation?: string;
|
||||||
|
confidence?: number;
|
||||||
|
parseErrorMessage?: string;
|
||||||
|
inputTokens?: number;
|
||||||
|
outputTokens?: number;
|
||||||
|
parseDurationMs?: number;
|
||||||
|
functionName?: string;
|
||||||
|
functionArguments?: string;
|
||||||
|
executeStatus?: number;
|
||||||
|
executeErrorMessage?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
createTime?: string;
|
||||||
|
updateTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 命令 API
|
||||||
|
*/
|
||||||
|
class AiCommandApi {
|
||||||
|
/**
|
||||||
|
* 解析自然语言命令
|
||||||
|
*
|
||||||
|
* @param data 命令请求参数
|
||||||
|
* @returns 解析结果
|
||||||
|
*/
|
||||||
|
static parseCommand(data: AiCommandRequest): Promise<AiCommandResponse> {
|
||||||
|
return request<any, AiCommandResponse>({
|
||||||
|
url: "/api/v1/ai/command/parse",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行已解析的命令
|
||||||
|
*
|
||||||
|
* @param data 执行请求参数
|
||||||
|
* @returns 执行结果数据(成功时返回,失败时抛出异常)
|
||||||
|
*/
|
||||||
|
static executeCommand(data: AiExecuteRequest): Promise<any> {
|
||||||
|
return request<any, any>({
|
||||||
|
url: "/api/v1/ai/command/execute",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取命令记录分页列表
|
||||||
|
*/
|
||||||
|
static getCommandRecordPage(queryParams: AiCommandRecordPageQuery) {
|
||||||
|
return request<any, PageResult<AiCommandRecordVO[]>>({
|
||||||
|
url: "/api/v1/ai/command/records",
|
||||||
|
method: "get",
|
||||||
|
params: queryParams,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 撤销命令执行(如果支持)
|
||||||
|
*/
|
||||||
|
static rollbackCommand(logId: string) {
|
||||||
|
return request({
|
||||||
|
url: `/api/v1/ai/command/rollback/${logId}`,
|
||||||
|
method: "post",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AiCommandApi;
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 1400 800">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
#bg-layer { fill: url(#bgDark); }
|
||||||
|
#soft-glow { fill: url(#glowDark); }
|
||||||
|
.accent-arc { stroke: rgba(118, 156, 255, 0.35); }
|
||||||
|
.accent-dot { fill: rgba(154, 188, 255, 0.45); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<linearGradient id="bgLight" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#f3f7ff" />
|
||||||
|
<stop offset="60%" stop-color="#e3edff" />
|
||||||
|
<stop offset="100%" stop-color="#d6e7ff" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="bgDark" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0b1324" />
|
||||||
|
<stop offset="60%" stop-color="#162135" />
|
||||||
|
<stop offset="100%" stop-color="#1e2c44" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<radialGradient id="glowLight" cx="20%" cy="15%" r="60%">
|
||||||
|
<stop offset="0%" stop-color="rgba(64,128,255,0.35)" />
|
||||||
|
<stop offset="40%" stop-color="rgba(64,128,255,0.18)" />
|
||||||
|
<stop offset="100%" stop-color="rgba(64,128,255,0)" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<radialGradient id="glowDark" cx="20%" cy="15%" r="60%">
|
||||||
|
<stop offset="0%" stop-color="rgba(98,142,255,0.4)" />
|
||||||
|
<stop offset="50%" stop-color="rgba(98,142,255,0.18)" />
|
||||||
|
<stop offset="100%" stop-color="rgba(98,142,255,0)" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<radialGradient id="glowSecondary" cx="80%" cy="70%" r="55%">
|
||||||
|
<stop offset="0%" stop-color="rgba(22,93,255,0.3)" />
|
||||||
|
<stop offset="50%" stop-color="rgba(22,93,255,0.12)" />
|
||||||
|
<stop offset="100%" stop-color="rgba(22,93,255,0)" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<linearGradient id="meshLight" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="rgba(255,255,255,0.6)" />
|
||||||
|
<stop offset="35%" stop-color="rgba(255,255,255,0.2)" />
|
||||||
|
<stop offset="100%" stop-color="rgba(255,255,255,0)" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect id="bg-layer" width="100%" height="100%" fill="url(#bgLight)" />
|
||||||
|
<rect id="soft-glow" width="100%" height="100%" fill="url(#glowLight)" />
|
||||||
|
<rect width="100%" height="100%" fill="url(#glowSecondary)" />
|
||||||
|
<rect width="100%" height="100%" fill="url(#meshLight)" />
|
||||||
|
|
||||||
|
<!-- 柔和块面光影,替代明显线条 -->
|
||||||
|
<g opacity="0.45">
|
||||||
|
<rect x="-40" y="520" width="520" height="220" rx="180" fill="rgba(255,255,255,0.25)" />
|
||||||
|
<rect x="760" y="90" width="520" height="210" rx="180" fill="rgba(255,255,255,0.22)" />
|
||||||
|
<rect x="420" y="620" width="560" height="190" rx="180" fill="rgba(255,255,255,0.18)" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 去掉点状噪声,仅保留大区域柔光 -->
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,71 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" baseProfile="full" width="100%" height="100%" viewBox="0 0 1400 800">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--blue: rgba(64, 158, 255, 0.08);
|
||||||
|
--grey: rgba(144, 147, 153, 0.05);
|
||||||
|
--orange: rgba(230, 162, 60, 0.06);
|
||||||
|
--green: rgba(144, 238, 144, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--blue: rgba(64, 158, 255, 0.04);
|
||||||
|
--grey: rgba(144, 147, 153, 0.03);
|
||||||
|
--orange: rgba(230, 162, 60, 0.04);
|
||||||
|
--green: rgba(144, 238, 144, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- 左侧波浪 -->
|
||||||
|
<path d="M-50 550 Q200 500 450 550 T950 530"
|
||||||
|
fill="none"
|
||||||
|
stroke="#409EFF"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-opacity="0.05"
|
||||||
|
stroke-linecap="round">
|
||||||
|
</path>
|
||||||
|
|
||||||
|
<!-- 右侧波浪 -->
|
||||||
|
<path d="M450 650 Q800 620 1150 660 T1550 630"
|
||||||
|
fill="none"
|
||||||
|
stroke="#909399"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-opacity="0.03"
|
||||||
|
stroke-linecap="round">
|
||||||
|
</path>
|
||||||
|
|
||||||
|
<!-- 右下方圆形 -->
|
||||||
|
<circle cx="950" cy="400" r="70"
|
||||||
|
fill="var(--blue)"
|
||||||
|
stroke="#409EFF"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-opacity="0.05">
|
||||||
|
</circle>
|
||||||
|
|
||||||
|
<!-- 左上方半球形 -->
|
||||||
|
<g transform="rotate(-10, 300, 180)">
|
||||||
|
<path d="M 180 180 A 120 120 0 1 1 420 180 Q420 195 405 195 L 310 195 L 195 195 Q180 195 180 180"
|
||||||
|
fill="var(--orange)"
|
||||||
|
stroke="#E6A23C"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-opacity="0.05">
|
||||||
|
</path>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- 左下方三角形 -->
|
||||||
|
<path d="M300 600 L380 520 L420 650 Z"
|
||||||
|
fill="var(--green)"
|
||||||
|
stroke="#909399"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-opacity="0.04">
|
||||||
|
</path>
|
||||||
|
|
||||||
|
<!-- 旋转方块 -->
|
||||||
|
<rect x="1000" y="420" rx="10" ry="10" width="60" height="60" fill="rgba(169, 174, 184, 0.1)" stroke="rgba(169, 174, 184, 0.2)" stroke-width="1" opacity="0.5">
|
||||||
|
<animateTransform attributeType="XML" attributeName="transform"
|
||||||
|
begin="0s" dur="30s" type="rotate"
|
||||||
|
from="0 1450 550" to="360 1450 550"
|
||||||
|
repeatCount="indefinite"/>
|
||||||
|
</rect>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,672 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 悬浮按钮 -->
|
||||||
|
<div class="ai-assistant">
|
||||||
|
<!-- AI 助手图标按钮 -->
|
||||||
|
<el-button
|
||||||
|
v-if="!dialogVisible"
|
||||||
|
class="ai-fab-button"
|
||||||
|
type="primary"
|
||||||
|
circle
|
||||||
|
size="large"
|
||||||
|
@click="handleOpen"
|
||||||
|
>
|
||||||
|
<div class="i-svg:ai ai-icon" />
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<!-- AI 对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
title="AI 智能助手"
|
||||||
|
width="600px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
draggable
|
||||||
|
class="ai-assistant-dialog"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="dialog-header">
|
||||||
|
<div class="i-svg:ai header-icon" />
|
||||||
|
<span class="title">AI 智能助手</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 命令输入 -->
|
||||||
|
<div class="command-input">
|
||||||
|
<el-input
|
||||||
|
v-model="command"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="试试说:修改test用户的姓名为测试人员 或者:跳转到用户管理 按 Ctrl+Enter 快速发送"
|
||||||
|
:disabled="loading"
|
||||||
|
@keydown.ctrl.enter="handleExecute"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 快捷命令示例 -->
|
||||||
|
<div class="quick-commands">
|
||||||
|
<div class="section-title">💡 试试这些命令:</div>
|
||||||
|
<el-tag
|
||||||
|
v-for="example in examples"
|
||||||
|
:key="example"
|
||||||
|
class="command-tag"
|
||||||
|
@click="command = example"
|
||||||
|
>
|
||||||
|
{{ example }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI 响应结果 -->
|
||||||
|
<div v-if="response" class="ai-response">
|
||||||
|
<el-alert :title="response.explanation" type="success" :closable="false" show-icon />
|
||||||
|
|
||||||
|
<!-- 将要执行的操作 -->
|
||||||
|
<div v-if="response.action" class="action-preview">
|
||||||
|
<div class="action-title">🎯 将要执行:</div>
|
||||||
|
<div class="action-content">
|
||||||
|
<div v-if="response.action.type === 'navigate'">
|
||||||
|
<el-icon><Position /></el-icon>
|
||||||
|
跳转到:
|
||||||
|
<strong>{{ response.action.pageName }}</strong>
|
||||||
|
<span v-if="response.action.query" class="query-info">
|
||||||
|
并搜索:
|
||||||
|
<el-tag type="warning" size="small">{{ response.action.query }}</el-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="response.action.type === 'navigate-and-execute'">
|
||||||
|
<el-icon><Position /></el-icon>
|
||||||
|
跳转至:
|
||||||
|
<strong>{{ response.action.pageName }}</strong>
|
||||||
|
<span v-if="response.action.query" class="query-info">
|
||||||
|
并搜索:
|
||||||
|
<el-tag type="warning" size="small">{{ response.action.query }}</el-tag>
|
||||||
|
</span>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<el-icon><Tools /></el-icon>
|
||||||
|
执行:
|
||||||
|
<strong>{{ response.action.functionCall.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="response.action.type === 'execute'">
|
||||||
|
<el-icon><Tools /></el-icon>
|
||||||
|
执行:
|
||||||
|
<strong>{{ response.action.functionName }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleExecute">
|
||||||
|
<el-icon><MagicStick /></el-icon>
|
||||||
|
执行命令
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import AiCommandApi from "@/api/ai";
|
||||||
|
|
||||||
|
type ToolFunctionCall = {
|
||||||
|
name: string;
|
||||||
|
arguments: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 统一的动作描述(区分“跳转”、“跳转+执行”、“仅执行”三种场景)
|
||||||
|
type AiAction =
|
||||||
|
| {
|
||||||
|
type: "navigate";
|
||||||
|
path: string;
|
||||||
|
pageName: string;
|
||||||
|
query?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "navigate-and-execute";
|
||||||
|
path: string;
|
||||||
|
pageName: string;
|
||||||
|
query?: string;
|
||||||
|
functionCall: ToolFunctionCall;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "execute";
|
||||||
|
functionName: string;
|
||||||
|
functionCall: ToolFunctionCall;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AiResponse = {
|
||||||
|
explanation: string;
|
||||||
|
action: AiAction | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const command = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const response = ref<AiResponse | null>(null);
|
||||||
|
|
||||||
|
// 快捷命令示例
|
||||||
|
const examples = [
|
||||||
|
"修改test用户的姓名为测试人员",
|
||||||
|
"获取姓名为张三的用户信息",
|
||||||
|
"跳转到用户管理",
|
||||||
|
"打开角色管理页面",
|
||||||
|
];
|
||||||
|
|
||||||
|
// 打开对话框
|
||||||
|
const handleOpen = () => {
|
||||||
|
dialogVisible.value = true;
|
||||||
|
command.value = "";
|
||||||
|
response.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
const handleClose = () => {
|
||||||
|
dialogVisible.value = false;
|
||||||
|
command.value = "";
|
||||||
|
response.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行命令
|
||||||
|
const handleExecute = async () => {
|
||||||
|
const rawCommand = command.value.trim();
|
||||||
|
if (!rawCommand) {
|
||||||
|
ElMessage.warning("请输入命令");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先检测无需调用 AI 的纯跳转命令
|
||||||
|
const directNavigation = tryDirectNavigate(rawCommand);
|
||||||
|
if (directNavigation && directNavigation.action) {
|
||||||
|
response.value = directNavigation;
|
||||||
|
await executeAction(directNavigation.action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用 AI API 解析命令
|
||||||
|
const result = await AiCommandApi.parseCommand({
|
||||||
|
command: rawCommand,
|
||||||
|
currentRoute: router.currentRoute.value.path,
|
||||||
|
currentComponent: router.currentRoute.value.name as string,
|
||||||
|
context: {
|
||||||
|
userRoles: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
ElMessage.error(result.error || "命令解析失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 AI 返回的操作类型
|
||||||
|
const action = parseAction(result, rawCommand);
|
||||||
|
response.value = {
|
||||||
|
explanation: result.explanation ?? "命令解析成功,准备执行操作",
|
||||||
|
action,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 等待用户确认后执行
|
||||||
|
if (action) {
|
||||||
|
await executeAction(action);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("AI 命令执行失败:", error);
|
||||||
|
ElMessage.error(error.message || "命令执行失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 路由配置映射表(支持扩展)
|
||||||
|
const routeConfig = [
|
||||||
|
{ keywords: ["用户", "user", "user list"], path: "/system/user", name: "用户管理" },
|
||||||
|
{ keywords: ["角色", "role"], path: "/system/role", name: "角色管理" },
|
||||||
|
{ keywords: ["菜单", "menu"], path: "/system/menu", name: "菜单管理" },
|
||||||
|
{ keywords: ["部门", "dept"], path: "/system/dept", name: "部门管理" },
|
||||||
|
{ keywords: ["字典", "dict"], path: "/system/dict", name: "字典管理" },
|
||||||
|
{ keywords: ["日志", "log"], path: "/system/log", name: "系统日志" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 根据函数名推断路由(如 getUserInfo -> /system/user)
|
||||||
|
const normalizeText = (text: string) => text.replace(/\s+/g, " ").trim().toLowerCase();
|
||||||
|
|
||||||
|
const inferRouteFromFunction = (functionName: string) => {
|
||||||
|
const fnLower = normalizeText(functionName);
|
||||||
|
for (const config of routeConfig) {
|
||||||
|
// 检查函数名是否包含关键词(如 getUserInfo 包含 user)
|
||||||
|
if (config.keywords.some((kw) => fnLower.includes(kw.toLowerCase()))) {
|
||||||
|
return { path: config.path, name: config.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据命令文本匹配路由
|
||||||
|
const matchRouteFromCommand = (cmd: string) => {
|
||||||
|
const normalized = normalizeText(cmd);
|
||||||
|
for (const config of routeConfig) {
|
||||||
|
if (config.keywords.some((kw) => normalized.includes(kw.toLowerCase()))) {
|
||||||
|
return { path: config.path, name: config.name };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractKeywordFromCommand = (cmd: string): string => {
|
||||||
|
const normalized = normalizeText(cmd);
|
||||||
|
// 从 routeConfig 动态获取所有数据类型关键词
|
||||||
|
const allKeywords = routeConfig.flatMap((config) =>
|
||||||
|
config.keywords.map((kw) => kw.toLowerCase())
|
||||||
|
);
|
||||||
|
const keywordsPattern = allKeywords.join("|");
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
new RegExp(`(?:查询|获取|搜索|查找|找).*?([^\\s,,。]+?)(?:的)?(?:${keywordsPattern})`, "i"),
|
||||||
|
new RegExp(`(?:${keywordsPattern}).*?([^\\s,,。]+?)(?:的|信息|详情)?`, "i"),
|
||||||
|
new RegExp(
|
||||||
|
`(?:姓名为|名字叫|叫做|名称为|名是|为)([^\\s,,。]+?)(?:的)?(?:${keywordsPattern})?`,
|
||||||
|
"i"
|
||||||
|
),
|
||||||
|
new RegExp(`([^\\s,,。]+?)(?:的)?(?:${keywordsPattern})(?:信息|详情)?`, "i"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = normalized.match(pattern);
|
||||||
|
if (match && match[1]) {
|
||||||
|
let extracted = match[1].trim();
|
||||||
|
extracted = extracted.replace(/姓名为|名字叫|叫做|名称为|名是|为|的|信息|详情/g, "");
|
||||||
|
if (
|
||||||
|
extracted &&
|
||||||
|
!allKeywords.some((type) => extracted.toLowerCase().includes(type.toLowerCase()))
|
||||||
|
) {
|
||||||
|
return extracted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryDirectNavigate = (rawCommand: string): AiResponse | null => {
|
||||||
|
const navigationIntents = ["跳转", "打开", "进入", "前往", "去", "浏览", "查看"];
|
||||||
|
const operationIntents = [
|
||||||
|
"修改",
|
||||||
|
"更新",
|
||||||
|
"变更",
|
||||||
|
"删除",
|
||||||
|
"添加",
|
||||||
|
"创建",
|
||||||
|
"设置",
|
||||||
|
"获取",
|
||||||
|
"查询",
|
||||||
|
"搜索",
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasNavigationIntent = navigationIntents.some((keyword) => rawCommand.includes(keyword));
|
||||||
|
const hasOperationIntent = operationIntents.some((keyword) => rawCommand.includes(keyword));
|
||||||
|
|
||||||
|
if (!hasNavigationIntent || hasOperationIntent) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeInfo = matchRouteFromCommand(rawCommand);
|
||||||
|
if (!routeInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = extractKeywordFromCommand(rawCommand);
|
||||||
|
const action: AiAction = {
|
||||||
|
type: "navigate",
|
||||||
|
path: routeInfo.path,
|
||||||
|
pageName: routeInfo.name,
|
||||||
|
query: keyword || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
explanation: `检测到跳转命令,正在前往 ${routeInfo.name}`,
|
||||||
|
action,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 解析 AI 返回的操作类型
|
||||||
|
const parseAction = (result: any, rawCommand: string): AiAction | null => {
|
||||||
|
const cmd = normalizeText(rawCommand);
|
||||||
|
const primaryCall = result.functionCalls?.[0];
|
||||||
|
const functionName = primaryCall?.name;
|
||||||
|
|
||||||
|
// 优先从函数名推断路由,其次从命令文本匹配
|
||||||
|
let routeInfo = functionName ? inferRouteFromFunction(functionName) : null;
|
||||||
|
if (!routeInfo) {
|
||||||
|
routeInfo = matchRouteFromCommand(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
const routePath = routeInfo?.path || "";
|
||||||
|
const pageName = routeInfo?.name || "";
|
||||||
|
const keyword = extractKeywordFromCommand(cmd);
|
||||||
|
|
||||||
|
if (primaryCall && functionName) {
|
||||||
|
const fnNameLower = functionName.toLowerCase();
|
||||||
|
|
||||||
|
// 1) 查询类函数(query/search/list/get)-> 跳转并执行筛选操作
|
||||||
|
const isQueryFunction =
|
||||||
|
fnNameLower.includes("query") ||
|
||||||
|
fnNameLower.includes("search") ||
|
||||||
|
fnNameLower.includes("list") ||
|
||||||
|
fnNameLower.includes("get");
|
||||||
|
|
||||||
|
if (isQueryFunction) {
|
||||||
|
// 统一使用 keywords 参数(约定大于配置)
|
||||||
|
const args = (primaryCall.arguments || {}) as Record<string, unknown>;
|
||||||
|
const keywords =
|
||||||
|
typeof args.keywords === "string" && args.keywords.trim().length > 0
|
||||||
|
? args.keywords
|
||||||
|
: keyword;
|
||||||
|
|
||||||
|
if (routePath) {
|
||||||
|
return {
|
||||||
|
type: "navigate-and-execute",
|
||||||
|
path: routePath,
|
||||||
|
pageName,
|
||||||
|
functionCall: primaryCall,
|
||||||
|
query: keywords || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 其他操作类函数(修改/删除/创建/更新等)-> 跳转并执行
|
||||||
|
const isModifyFunction =
|
||||||
|
fnNameLower.includes("update") ||
|
||||||
|
fnNameLower.includes("modify") ||
|
||||||
|
fnNameLower.includes("edit") ||
|
||||||
|
fnNameLower.includes("delete") ||
|
||||||
|
fnNameLower.includes("remove") ||
|
||||||
|
fnNameLower.includes("create") ||
|
||||||
|
fnNameLower.includes("add") ||
|
||||||
|
fnNameLower.includes("save");
|
||||||
|
|
||||||
|
if (isModifyFunction && routePath) {
|
||||||
|
return {
|
||||||
|
type: "navigate-and-execute",
|
||||||
|
path: routePath,
|
||||||
|
pageName,
|
||||||
|
functionCall: primaryCall,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 其他未匹配的函数,如果有路由则跳转,否则执行
|
||||||
|
if (routePath) {
|
||||||
|
return {
|
||||||
|
type: "navigate-and-execute",
|
||||||
|
path: routePath,
|
||||||
|
pageName,
|
||||||
|
functionCall: primaryCall,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "execute",
|
||||||
|
functionName,
|
||||||
|
functionCall: primaryCall,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 无函数调用,仅跳转
|
||||||
|
if (routePath) {
|
||||||
|
return {
|
||||||
|
type: "navigate",
|
||||||
|
path: routePath,
|
||||||
|
pageName,
|
||||||
|
query: keyword || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定时器引用(用于清理)
|
||||||
|
let navigationTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let executeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// 执行操作
|
||||||
|
const executeAction = async (action: AiAction) => {
|
||||||
|
// 🎯 新增:跳转并执行操作
|
||||||
|
if (action.type === "navigate-and-execute") {
|
||||||
|
ElMessage.success(`正在跳转到 ${action.pageName} 并执行操作...`);
|
||||||
|
|
||||||
|
// 清理之前的定时器
|
||||||
|
if (navigationTimer) {
|
||||||
|
clearTimeout(navigationTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转并传递待执行的操作信息
|
||||||
|
navigationTimer = setTimeout(() => {
|
||||||
|
navigationTimer = null;
|
||||||
|
const queryParams: any = {
|
||||||
|
// 通过 URL 参数传递 AI 操作信息
|
||||||
|
aiAction: encodeURIComponent(
|
||||||
|
JSON.stringify({
|
||||||
|
functionName: action.functionCall.name,
|
||||||
|
arguments: action.functionCall.arguments,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果有查询关键字,也一并传递
|
||||||
|
if (action.query) {
|
||||||
|
queryParams.keywords = action.query;
|
||||||
|
queryParams.autoSearch = "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
path: action.path,
|
||||||
|
query: queryParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
handleClose();
|
||||||
|
}, 800);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.type === "navigate") {
|
||||||
|
// 检查是否已经在目标页面
|
||||||
|
const currentPath = router.currentRoute.value.path;
|
||||||
|
|
||||||
|
if (currentPath === action.path) {
|
||||||
|
// 如果已经在目标页面
|
||||||
|
if (action.query) {
|
||||||
|
// 有查询关键字,直接在当前页面执行搜索
|
||||||
|
ElMessage.info(`您已在 ${action.pageName} 页面,为您执行搜索:${action.query}`);
|
||||||
|
|
||||||
|
// 触发路由更新,让页面执行搜索
|
||||||
|
router.replace({
|
||||||
|
path: action.path,
|
||||||
|
query: {
|
||||||
|
keywords: action.query,
|
||||||
|
autoSearch: "true",
|
||||||
|
_t: Date.now().toString(), // 添加时间戳强制刷新
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 没有查询关键字,只是跳转,给出提示
|
||||||
|
ElMessage.warning(`您已经在 ${action.pageName} 页面了`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不在目标页面,正常跳转
|
||||||
|
ElMessage.success(`正在跳转到 ${action.pageName}...`);
|
||||||
|
|
||||||
|
// 清理之前的定时器
|
||||||
|
if (navigationTimer) {
|
||||||
|
clearTimeout(navigationTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟一下让用户看到提示
|
||||||
|
navigationTimer = setTimeout(() => {
|
||||||
|
navigationTimer = null;
|
||||||
|
// 跳转并传递查询参数
|
||||||
|
router.push({
|
||||||
|
path: action.path,
|
||||||
|
query: action.query
|
||||||
|
? {
|
||||||
|
keywords: action.query, // 传递关键字参数
|
||||||
|
autoSearch: "true", // 标记自动搜索
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 关闭对话框
|
||||||
|
handleClose();
|
||||||
|
}, 1000);
|
||||||
|
} else if (action.type === "execute") {
|
||||||
|
// 执行函数调用
|
||||||
|
ElMessage.info("功能开发中,请前往 AI 命令助手页面体验完整功能");
|
||||||
|
|
||||||
|
// 清理之前的定时器
|
||||||
|
if (executeTimer) {
|
||||||
|
clearTimeout(executeTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可以跳转到完整的 AI 命令页面
|
||||||
|
executeTimer = setTimeout(() => {
|
||||||
|
executeTimer = null;
|
||||||
|
router.push("/function/ai-command");
|
||||||
|
handleClose();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件卸载时清理定时器
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (navigationTimer) {
|
||||||
|
clearTimeout(navigationTimer);
|
||||||
|
navigationTimer = null;
|
||||||
|
}
|
||||||
|
if (executeTimer) {
|
||||||
|
clearTimeout(executeTimer);
|
||||||
|
executeTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.ai-assistant {
|
||||||
|
.ai-fab-button {
|
||||||
|
position: fixed;
|
||||||
|
right: 30px;
|
||||||
|
bottom: 80px;
|
||||||
|
z-index: 9999;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
box-shadow: 0 4px 12px rgba(2, 119, 252, 0.4);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 6px 20px rgba(2, 119, 252, 0.6);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-assistant-dialog {
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-input {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-commands {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-tag {
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-response {
|
||||||
|
margin-top: 16px;
|
||||||
|
|
||||||
|
.action-preview {
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.action-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-info {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
</transition>
|
</transition>
|
||||||
</template>
|
</template>
|
||||||
</router-view>
|
</router-view>
|
||||||
|
|
||||||
|
<!-- 返回顶部按钮 -->
|
||||||
|
<el-backtop target=".app-main">
|
||||||
|
<div class="i-svg:backtop w-6 h-6" />
|
||||||
|
</el-backtop>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,16 @@ const onMenuClose = (index: string) => {
|
|||||||
expandedMenuIndexes.value = expandedMenuIndexes.value.filter((item) => item !== index);
|
expandedMenuIndexes.value = expandedMenuIndexes.value.filter((item) => item !== index);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听展开的菜单项变化,更新父菜单样式
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => expandedMenuIndexes.value,
|
||||||
|
() => {
|
||||||
|
updateParentMenuStyles();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 监听菜单模式变化:当菜单模式切换为水平模式时,关闭所有展开的菜单项,
|
* 监听菜单模式变化:当菜单模式切换为水平模式时,关闭所有展开的菜单项,
|
||||||
* 避免在水平模式下菜单项显示错位。
|
* 避免在水平模式下菜单项显示错位。
|
||||||
@@ -220,7 +230,7 @@ function updateParentMenuStyles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating parent menu styles:", error);
|
console.error("更新父菜单样式时出错:", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,8 +102,7 @@ const updateMenuState = (topMenuPath: string, skipNavigation = false) => {
|
|||||||
// 确保路径有效且不相同才更新,避免重复操作
|
// 确保路径有效且不相同才更新,避免重复操作
|
||||||
if (topMenuPath && appStore.activeTopMenuPath) {
|
if (topMenuPath && appStore.activeTopMenuPath) {
|
||||||
appStore.activeTopMenu(topMenuPath); // 设置激活的顶部菜单
|
appStore.activeTopMenu(topMenuPath); // 设置激活的顶部菜单
|
||||||
// 只有当路由映射表中存在该路径时才更新侧边菜单
|
permissionStore.setMixLayoutSideMenus(topMenuPath); // 只有当路由映射表中存在该路径时才更新侧边菜单。 设置混合布局左侧菜单
|
||||||
permissionStore.setMixLayoutSideMenus(topMenuPath); // 设置混合布局左侧菜单
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是点击菜单且状态已变更,才进行导航
|
// 如果是点击菜单且状态已变更,才进行导航
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export const defaultSettings: AppSettings = {
|
|||||||
// 主题颜色 - 修改此值时需同步修改 src/styles/variables.scss
|
// 主题颜色 - 修改此值时需同步修改 src/styles/variables.scss
|
||||||
themeColor: "#4080FF",
|
themeColor: "#4080FF",
|
||||||
// 是否显示水印 (修改默认开启水印)
|
// 是否显示水印 (修改默认开启水印)
|
||||||
// showWatermark: false,
|
|
||||||
showWatermark: true,
|
showWatermark: true,
|
||||||
// 水印内容
|
// 水印内容
|
||||||
watermarkContent: pkg.name,
|
watermarkContent: pkg.name,
|
||||||
@@ -44,6 +43,8 @@ export const defaultSettings: AppSettings = {
|
|||||||
guideVisible: false,
|
guideVisible: false,
|
||||||
/** 是否启动引导 */
|
/** 是否启动引导 */
|
||||||
showGuide: true,
|
showGuide: true,
|
||||||
|
// 是否启用 AI 助手
|
||||||
|
enableAiAssistant: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 主题色预设 - 现代化配色方案
|
// 主题色预设 - 现代化配色方案
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface SettingsState {
|
|||||||
showWatermark: boolean;
|
showWatermark: boolean;
|
||||||
showSettings: boolean;
|
showSettings: boolean;
|
||||||
showGuide: boolean; // 引导功能开关
|
showGuide: boolean; // 引导功能开关
|
||||||
|
enableAiAssistant: boolean;
|
||||||
|
|
||||||
// 桌面端工具显示设置
|
// 桌面端工具显示设置
|
||||||
showMenuSearch: boolean;
|
showMenuSearch: boolean;
|
||||||
@@ -44,14 +45,25 @@ export const useSettingsStore = defineStore("setting", () => {
|
|||||||
defaultSettings.showTagsView
|
defaultSettings.showTagsView
|
||||||
);
|
);
|
||||||
const showAppLogo = useStorage<boolean>(SETTINGS_KEYS.SHOW_APP_LOGO, defaultSettings.showAppLogo);
|
const showAppLogo = useStorage<boolean>(SETTINGS_KEYS.SHOW_APP_LOGO, defaultSettings.showAppLogo);
|
||||||
|
// 是否显示水印
|
||||||
const showWatermark = useStorage<boolean>(
|
const showWatermark = useStorage<boolean>(
|
||||||
SETTINGS_KEYS.SHOW_WATERMARK,
|
SETTINGS_KEYS.SHOW_WATERMARK,
|
||||||
defaultSettings.showWatermark
|
defaultSettings.showWatermark
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 是否启用 AI 助手
|
||||||
|
const enableAiAssistant = useStorage<boolean>(
|
||||||
|
"vea:ui:enable_ai_assistant",
|
||||||
|
defaultSettings.enableAiAssistant
|
||||||
|
);
|
||||||
|
|
||||||
|
// 是否显示系统设置
|
||||||
const showSettings = useStorage<boolean>(
|
const showSettings = useStorage<boolean>(
|
||||||
SETTINGS_KEYS.SHOW_SETTINGS,
|
SETTINGS_KEYS.SHOW_SETTINGS,
|
||||||
defaultSettings.showSettings
|
defaultSettings.showSettings
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 是否显示引导功能
|
||||||
const showGuide = useStorage<boolean>(SETTINGS_KEYS.SHOW_GUIDE, defaultSettings.showGuide); // 引导功能开关
|
const showGuide = useStorage<boolean>(SETTINGS_KEYS.SHOW_GUIDE, defaultSettings.showGuide); // 引导功能开关
|
||||||
|
|
||||||
// 🎯 桌面端工具设置 - 持久化
|
// 🎯 桌面端工具设置 - 持久化
|
||||||
@@ -59,18 +71,26 @@ export const useSettingsStore = defineStore("setting", () => {
|
|||||||
SETTINGS_KEYS.SHOW_MENU_SEARCH,
|
SETTINGS_KEYS.SHOW_MENU_SEARCH,
|
||||||
defaultSettings.showMenuSearch
|
defaultSettings.showMenuSearch
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 是否显示全屏切换
|
||||||
const showFullscreen = useStorage<boolean>(
|
const showFullscreen = useStorage<boolean>(
|
||||||
SETTINGS_KEYS.SHOW_FULLSCREEN,
|
SETTINGS_KEYS.SHOW_FULLSCREEN,
|
||||||
defaultSettings.showFullscreen
|
defaultSettings.showFullscreen
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 是否显示布局大小选择
|
||||||
const showSizeSelect = useStorage<boolean>(
|
const showSizeSelect = useStorage<boolean>(
|
||||||
SETTINGS_KEYS.SHOW_SIZE_SELECT,
|
SETTINGS_KEYS.SHOW_SIZE_SELECT,
|
||||||
defaultSettings.showSizeSelect
|
defaultSettings.showSizeSelect
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 是否显示语言选择
|
||||||
const showLangSelect = useStorage<boolean>(
|
const showLangSelect = useStorage<boolean>(
|
||||||
SETTINGS_KEYS.SHOW_LANG_SELECT,
|
SETTINGS_KEYS.SHOW_LANG_SELECT,
|
||||||
defaultSettings.showLangSelect
|
defaultSettings.showLangSelect
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 是否显示通知
|
||||||
const showNotification = useStorage<boolean>(
|
const showNotification = useStorage<boolean>(
|
||||||
SETTINGS_KEYS.SHOW_NOTIFICATION,
|
SETTINGS_KEYS.SHOW_NOTIFICATION,
|
||||||
defaultSettings.showNotification
|
defaultSettings.showNotification
|
||||||
@@ -81,8 +101,12 @@ export const useSettingsStore = defineStore("setting", () => {
|
|||||||
SETTINGS_KEYS.SIDEBAR_COLOR_SCHEME,
|
SETTINGS_KEYS.SIDEBAR_COLOR_SCHEME,
|
||||||
defaultSettings.sidebarColorScheme
|
defaultSettings.sidebarColorScheme
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 布局设置
|
||||||
const layout = useStorage<LayoutMode>(SETTINGS_KEYS.LAYOUT, defaultSettings.layout as LayoutMode);
|
const layout = useStorage<LayoutMode>(SETTINGS_KEYS.LAYOUT, defaultSettings.layout as LayoutMode);
|
||||||
|
// 主题颜色
|
||||||
const themeColor = useStorage<string>(SETTINGS_KEYS.THEME_COLOR, defaultSettings.themeColor);
|
const themeColor = useStorage<string>(SETTINGS_KEYS.THEME_COLOR, defaultSettings.themeColor);
|
||||||
|
// 主题模式
|
||||||
const theme = useStorage<ThemeMode>(SETTINGS_KEYS.THEME, defaultSettings.theme);
|
const theme = useStorage<ThemeMode>(SETTINGS_KEYS.THEME, defaultSettings.theme);
|
||||||
|
|
||||||
// 🎯 设置项映射
|
// 🎯 设置项映射
|
||||||
@@ -99,6 +123,7 @@ export const useSettingsStore = defineStore("setting", () => {
|
|||||||
showNotification,
|
showNotification,
|
||||||
sidebarColorScheme,
|
sidebarColorScheme,
|
||||||
layout,
|
layout,
|
||||||
|
enableAiAssistant,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 🎯 监听器 - 主题变化
|
// 🎯 监听器 - 主题变化
|
||||||
@@ -134,14 +159,17 @@ export const useSettingsStore = defineStore("setting", () => {
|
|||||||
theme.value = newTheme;
|
theme.value = newTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新主题颜色
|
||||||
function updateThemeColor(newColor: string): void {
|
function updateThemeColor(newColor: string): void {
|
||||||
themeColor.value = newColor;
|
themeColor.value = newColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新侧边栏配色方案
|
||||||
function updateSidebarColorScheme(newScheme: string): void {
|
function updateSidebarColorScheme(newScheme: string): void {
|
||||||
sidebarColorScheme.value = newScheme;
|
sidebarColorScheme.value = newScheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新布局
|
||||||
function updateLayout(newLayout: LayoutMode): void {
|
function updateLayout(newLayout: LayoutMode): void {
|
||||||
layout.value = newLayout;
|
layout.value = newLayout;
|
||||||
}
|
}
|
||||||
@@ -151,10 +179,12 @@ export const useSettingsStore = defineStore("setting", () => {
|
|||||||
settingsVisible.value = !settingsVisible.value;
|
settingsVisible.value = !settingsVisible.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 显示设置面板
|
||||||
function showSettingsPanel(): void {
|
function showSettingsPanel(): void {
|
||||||
settingsVisible.value = true;
|
settingsVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 隐藏设置面板
|
||||||
function hideSettingsPanel(): void {
|
function hideSettingsPanel(): void {
|
||||||
settingsVisible.value = false;
|
settingsVisible.value = false;
|
||||||
}
|
}
|
||||||
@@ -167,6 +197,7 @@ export const useSettingsStore = defineStore("setting", () => {
|
|||||||
showWatermark.value = defaultSettings.showWatermark;
|
showWatermark.value = defaultSettings.showWatermark;
|
||||||
showSettings.value = defaultSettings.showSettings;
|
showSettings.value = defaultSettings.showSettings;
|
||||||
showGuide.value = defaultSettings.showGuide;
|
showGuide.value = defaultSettings.showGuide;
|
||||||
|
enableAiAssistant.value = defaultSettings.enableAiAssistant;
|
||||||
|
|
||||||
// 桌面端工具设置
|
// 桌面端工具设置
|
||||||
showMenuSearch.value = defaultSettings.showMenuSearch;
|
showMenuSearch.value = defaultSettings.showMenuSearch;
|
||||||
@@ -192,6 +223,7 @@ export const useSettingsStore = defineStore("setting", () => {
|
|||||||
showWatermark,
|
showWatermark,
|
||||||
showSettings,
|
showSettings,
|
||||||
showGuide,
|
showGuide,
|
||||||
|
enableAiAssistant,
|
||||||
|
|
||||||
// 🎯 桌面端工具状态
|
// 🎯 桌面端工具状态
|
||||||
showMenuSearch,
|
showMenuSearch,
|
||||||
|
|||||||
Vendored
+2
@@ -98,6 +98,8 @@ declare global {
|
|||||||
guideVisible: boolean;
|
guideVisible: boolean;
|
||||||
/** 是否启动引导 */
|
/** 是否启动引导 */
|
||||||
showGuide: boolean;
|
showGuide: boolean;
|
||||||
|
/** 是否启用AI助手 */
|
||||||
|
enableAiAssistant: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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<string, WebSocketService>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 防止重复初始化的状态标记
|
||||||
|
*/
|
||||||
|
let isInitialized = false;
|
||||||
|
let dictWebSocketInstance: ReturnType<typeof useDictSync> | 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -38,22 +38,24 @@
|
|||||||
|
|
||||||
<!-- 验证码 -->
|
<!-- 验证码 -->
|
||||||
<el-form-item v-if="captchaState.enable" prop="captcha">
|
<el-form-item v-if="captchaState.enable" prop="captcha">
|
||||||
<div flex>
|
<div flex items-center gap-10px>
|
||||||
<el-input
|
<el-input
|
||||||
v-model.trim="loginForm.captcha"
|
v-model.trim="loginForm.captcha"
|
||||||
:placeholder="t('login.captchaCode')"
|
:placeholder="t('login.captchaCode')"
|
||||||
clearable
|
clearable
|
||||||
|
style="width: 320px"
|
||||||
@keyup.enter="handleLoginSubmit"
|
@keyup.enter="handleLoginSubmit"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<div class="i-svg:captcha" />
|
<div class="i-svg:captcha" />
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<div cursor-pointer flex-center ml-10px>
|
<div cursor-pointer flex-center w-100px>
|
||||||
<el-icon v-if="codeLoading" class="is-loading">
|
<el-icon v-if="codeLoading" class="is-loading" size="20">
|
||||||
<Loading />
|
<Loading />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<el-image v-else object-cover :src="captchaState.img_base" @click="getCaptcha" />
|
<el-image v-else-if="captchaState.img_base" border-rd-4px object-cover :src="captchaState.img_base" @click="getCaptcha" />
|
||||||
|
<el-text v-else type="info" size="small">点击获取验证码</el-text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="login-container"
|
||||||
|
:style="{
|
||||||
|
'background-image': configStore.configData?.sys_login_background?.config_value
|
||||||
|
? `url(${configStore.configData.sys_login_background.config_value})`
|
||||||
|
: '/background.svg',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- 右侧切换主题、语言按钮 -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<el-tooltip :content="t('login.themeToggle')" placement="bottom">
|
||||||
|
<CommonWrapper>
|
||||||
|
<DarkModeSwitch />
|
||||||
|
</CommonWrapper>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip :content="t('login.languageToggle')" placement="bottom">
|
||||||
|
<CommonWrapper>
|
||||||
|
<LangSelect size="text-20px" />
|
||||||
|
</CommonWrapper>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<!-- 登录页主体 -->
|
||||||
|
<div flex-1 flex-center>
|
||||||
|
<div
|
||||||
|
class="p-4xl w-full h-auto sm:w-450px border-rd-10px sm:h-680px shadow-[var(--el-box-shadow-light)] backdrop-blur-3px"
|
||||||
|
>
|
||||||
|
<div w-full flex flex-col items-center>
|
||||||
|
<!-- logo -->
|
||||||
|
<!-- <el-image :src="logo" style="width: 84px" /> -->
|
||||||
|
<el-image :src="configStore.configData.sys_web_logo.config_value" style="width: 140px" />
|
||||||
|
|
||||||
|
<!-- 标题 -->
|
||||||
|
<!-- 添加小图标用于显示提示信息 -->
|
||||||
|
<div class="flex items-center justify-center mb-4">
|
||||||
|
<el-tooltip
|
||||||
|
:content="configStore.configData.sys_web_description.config_value"
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<el-icon class="cursor-help"><QuestionFilled /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
<div class="ml-2 text-xl font-bold">
|
||||||
|
<el-badge
|
||||||
|
:value="`v ${configStore.configData.sys_web_version.config_value}`"
|
||||||
|
type="success"
|
||||||
|
>
|
||||||
|
{{ configStore.configData.sys_web_title.config_value }}
|
||||||
|
</el-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 组件切换 -->
|
||||||
|
<transition name="fade-slide" mode="out-in">
|
||||||
|
<component
|
||||||
|
:is="formComponents[component]"
|
||||||
|
v-model="component"
|
||||||
|
v-model:preset-username="loginPreset.username"
|
||||||
|
v-model:preset-password="loginPreset.password"
|
||||||
|
class="w-90%"
|
||||||
|
/>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 登录页底部版权 -->
|
||||||
|
<el-text size="small" class="py-2.5! fixed bottom-0 text-center">
|
||||||
|
<a :href="configStore.configData.sys_git_code.config_value" target="_blank">
|
||||||
|
{{ configStore.configData.sys_web_copyright.config_value }} |
|
||||||
|
</a>
|
||||||
|
<a :href="configStore.configData.sys_help_doc.config_value" target="_blank">帮助 |</a>
|
||||||
|
<a :href="configStore.configData.sys_web_privacy.config_value" target="_blank">隐私 |</a>
|
||||||
|
<a :href="configStore.configData.sys_web_clause.config_value" target="_blank">条款</a>
|
||||||
|
{{ configStore.configData.sys_keep_record.config_value }}
|
||||||
|
</el-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// import logo from "@/assets/logo.png";
|
||||||
|
// import { defaultSettings } from "@/settings";
|
||||||
|
import CommonWrapper from "@/components/CommonWrapper/index.vue";
|
||||||
|
import DarkModeSwitch from "@/components/DarkModeSwitch/index.vue";
|
||||||
|
import { useConfigStore } from "@/store";
|
||||||
|
|
||||||
|
const configStore = useConfigStore();
|
||||||
|
|
||||||
|
type LayoutMap = "login" | "register" | "resetPwd";
|
||||||
|
|
||||||
|
const t = useI18n().t;
|
||||||
|
|
||||||
|
const component = ref<LayoutMap>("login"); // 切换显示的组件
|
||||||
|
const formComponents = {
|
||||||
|
login: defineAsyncComponent(() => import("./components/Login.vue")),
|
||||||
|
register: defineAsyncComponent(() => import("./components/Register.vue")),
|
||||||
|
resetPwd: defineAsyncComponent(() => import("./components/ResetPwd.vue")),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 预填登录信息(通过具名 v-model 双向绑定传递)
|
||||||
|
const loginPreset = reactive<{ username: string; password: string }>({
|
||||||
|
username: "admin",
|
||||||
|
password: "123456",
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
configStore.getConfig();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.login-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center center;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
top: 10px;
|
||||||
|
right: auto;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
top: 40px;
|
||||||
|
right: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fade-slide */
|
||||||
|
.fade-slide-leave-active,
|
||||||
|
.fade-slide-enter-active {
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="auth-view">
|
||||||
class="login-container"
|
|
||||||
:style="{
|
|
||||||
'background-image': configStore.configData?.sys_login_background?.config_value
|
|
||||||
? `url(${configStore.configData.sys_login_background.config_value})`
|
|
||||||
: '/background.svg',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- 右侧切换主题、语言按钮 -->
|
<!-- 右侧切换主题、语言按钮 -->
|
||||||
<div class="action-bar">
|
<div class="auth-view__toolbar">
|
||||||
<el-tooltip :content="t('login.themeToggle')" placement="bottom">
|
<el-tooltip :content="t('login.themeToggle')" placement="bottom">
|
||||||
<CommonWrapper>
|
<CommonWrapper>
|
||||||
<DarkModeSwitch />
|
<DarkModeSwitch />
|
||||||
@@ -21,34 +14,58 @@
|
|||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<!-- 登录页主体 -->
|
<!-- 登录页主体 -->
|
||||||
<div flex-1 flex-center>
|
<div class="auth-view__wrapper">
|
||||||
<div
|
<!-- 可选:左侧产品介绍区域,如不需要可整段删除,右侧登录表单会自动居中展示 -->
|
||||||
class="p-4xl w-full h-auto sm:w-450px border-rd-10px sm:h-680px shadow-[var(--el-box-shadow-light)] backdrop-blur-3px"
|
<section class="auth-feature">
|
||||||
>
|
<div class="auth-feature__badge">
|
||||||
<div w-full flex flex-col items-center>
|
<span class="auth-feature__dot" />
|
||||||
|
Enterprise Ready
|
||||||
|
</div>
|
||||||
|
<h1 class="auth-feature__title">企业级管理系统</h1>
|
||||||
|
<p class="auth-feature__subtitle">
|
||||||
|
提供安全、高效、可扩展的管理解决方案,助力企业数字化转型与业务增长。
|
||||||
|
</p>
|
||||||
|
<ul class="auth-feature__highlights">
|
||||||
|
<li>
|
||||||
|
<span>⦿</span>
|
||||||
|
统一身份认证与权限管理
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>⦿</span>
|
||||||
|
数据安全与操作审计
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span>⦿</span>
|
||||||
|
灵活扩展与高可用架构
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 登录页主体容器 -->
|
||||||
|
<section class="auth-panel">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="auth-panel__brand">
|
||||||
|
<div class="auth-panel__logo-wrap">
|
||||||
<!-- logo -->
|
<!-- logo -->
|
||||||
<!-- <el-image :src="logo" style="width: 84px" /> -->
|
<!-- <el-image :src="logo" style="width: 84px" /> -->
|
||||||
<el-image :src="configStore.configData.sys_web_logo.config_value" style="width: 140px" />
|
<el-image :src="configStore.configData.sys_web_logo.config_value" class="auth-panel__logo" />
|
||||||
|
</div>
|
||||||
<!-- 标题 -->
|
<div class="auth-panel__meta">
|
||||||
<!-- 添加小图标用于显示提示信息 -->
|
<div class="auth-panel__title-row">
|
||||||
<div class="flex items-center justify-center mb-4">
|
<span class="auth-panel__title">{{ configStore.configData.sys_web_title.config_value }}</span>
|
||||||
<el-tooltip
|
<el-tooltip
|
||||||
:content="configStore.configData.sys_web_description.config_value"
|
:content="configStore.configData.sys_web_description.config_value"
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
>
|
>
|
||||||
<el-icon class="cursor-help"><QuestionFilled /></el-icon>
|
<el-icon class="cursor-help"><QuestionFilled /></el-icon>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<div class="ml-2 text-xl font-bold">
|
</div>
|
||||||
<el-badge
|
<div class="auth-panel__version-row">
|
||||||
:value="`v ${configStore.configData.sys_web_version.config_value}`"
|
<span class="auth-panel__version-label">Version</span>
|
||||||
type="success"
|
<span class="auth-panel__version-pill">v{{ configStore.configData.sys_web_version.config_value }}</span>
|
||||||
>
|
</div>
|
||||||
{{ configStore.configData.sys_web_title.config_value }}
|
|
||||||
</el-badge>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 组件切换 -->
|
<!-- 组件切换 -->
|
||||||
<transition name="fade-slide" mode="out-in">
|
<transition name="fade-slide" mode="out-in">
|
||||||
<component
|
<component
|
||||||
@@ -56,13 +73,13 @@
|
|||||||
v-model="component"
|
v-model="component"
|
||||||
v-model:preset-username="loginPreset.username"
|
v-model:preset-username="loginPreset.username"
|
||||||
v-model:preset-password="loginPreset.password"
|
v-model:preset-password="loginPreset.password"
|
||||||
class="w-90%"
|
class="auth-panel__form"
|
||||||
/>
|
/>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 登录页底部版权 -->
|
<!-- 登录页底部版权 -->
|
||||||
<el-text size="small" class="py-2.5! fixed bottom-0 text-center">
|
<footer class="auth-panel__footer">
|
||||||
|
<el-text size="small">
|
||||||
<a :href="configStore.configData.sys_git_code.config_value" target="_blank">
|
<a :href="configStore.configData.sys_git_code.config_value" target="_blank">
|
||||||
{{ configStore.configData.sys_web_copyright.config_value }} |
|
{{ configStore.configData.sys_web_copyright.config_value }} |
|
||||||
</a>
|
</a>
|
||||||
@@ -71,6 +88,8 @@
|
|||||||
<a :href="configStore.configData.sys_web_clause.config_value" target="_blank">条款</a>
|
<a :href="configStore.configData.sys_web_clause.config_value" target="_blank">条款</a>
|
||||||
{{ configStore.configData.sys_keep_record.config_value }}
|
{{ configStore.configData.sys_keep_record.config_value }}
|
||||||
</el-text>
|
</el-text>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -101,62 +120,464 @@ const loginPreset = reactive<{ username: string; password: string }>({
|
|||||||
password: "123456",
|
password: "123456",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let notificationInstance: ReturnType<typeof ElNotification> | null = null;
|
||||||
|
|
||||||
|
const showVoteNotification = () => {
|
||||||
|
notificationInstance = ElNotification({
|
||||||
|
title: "⭐ FastapiAdmin 完全开源 · 期待您的 Star 支持 🙏",
|
||||||
|
message: `项目持续迭代中,若对您有所帮助,欢迎点亮 Star 支持!
|
||||||
|
<br/><a href="https://github.com/1014TaoTao/FastapiAdmin" target="_blank" style="color: var(--el-color-primary); text-decoration: none; font-weight: 500;">Github仓库 →</a>
|
||||||
|
<br/><a href="https://gitee.com/tao__tao/FastapiAdmin" target="_blank" style="color: var(--el-color-warning); text-decoration: none; font-weight: 500;">Gitee仓库 →</a>`,
|
||||||
|
type: "success",
|
||||||
|
position: "bottom-left",
|
||||||
|
duration: 0,
|
||||||
|
dangerouslyUseHTMLString: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
setTimeout(showVoteNotification, 500);
|
||||||
configStore.getConfig();
|
configStore.getConfig();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (notificationInstance) {
|
||||||
|
notificationInstance.close();
|
||||||
|
notificationInstance = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.login-container {
|
.auth-view {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-repeat: no-repeat;
|
padding: clamp(1rem, 3vw, 2rem);
|
||||||
background-position: center center;
|
overflow: hidden;
|
||||||
background-size: cover;
|
background:
|
||||||
|
radial-gradient(circle at 20% 20%, rgba(64, 128, 255, 0.18), transparent 55%),
|
||||||
|
radial-gradient(circle at 80% 80%, rgba(22, 93, 255, 0.16), transparent 50%);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -2;
|
||||||
|
content: "";
|
||||||
|
background: url("@/assets/images/login-bg.svg") center/cover no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
content: "";
|
||||||
|
background: linear-gradient(120deg, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-bar {
|
.auth-view__toolbar {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-self: flex-end;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.85);
|
||||||
|
border: 1px solid rgba(22, 93, 255, 0.15);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 10px 30px rgba(22, 93, 255, 0.12);
|
||||||
|
transition:
|
||||||
|
transform 0.3s ease,
|
||||||
|
box-shadow 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 16px 40px rgba(22, 93, 255, 0.18);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 10px;
|
top: 12px;
|
||||||
right: 10px;
|
right: 16px;
|
||||||
z-index: 10;
|
z-index: 20;
|
||||||
|
align-self: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background-color: rgba(24, 28, 43, 0.8);
|
||||||
|
border-color: rgba(64, 128, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用内暗黑主题下顶部设置面板的深色样式 */
|
||||||
|
.dark .auth-view__toolbar {
|
||||||
|
background-color: rgba(24, 28, 43, 0.9);
|
||||||
|
border-color: rgba(64, 128, 255, 0.35);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 30px rgba(0, 0, 0, 0.7),
|
||||||
|
0 0 0 1px rgba(90, 140, 255, 0.25) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-view__wrapper {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: clamp(1.5rem, 3vw, 3rem);
|
||||||
|
align-items: stretch;
|
||||||
|
padding: clamp(1.5rem, 2vw, 2.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-feature {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: clamp(1.5rem, 3vw, 3rem);
|
||||||
|
color: rgba(20, 40, 80, 0.95);
|
||||||
|
text-shadow: 0 4px 16px rgba(15, 60, 110, 0.12);
|
||||||
|
animation: featureFade 0.8s ease-out;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
// color: rgba(236, 242, 255, 0.92);
|
||||||
|
color: rgba(160, 190, 255, 0.95);
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.auth-view__wrapper {
|
||||||
|
display: block;
|
||||||
|
padding: 1.25rem 0.75rem 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-feature {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
width: 100%;
|
||||||
|
margin-inline: 0;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 32px rgba(22, 93, 255, 0.18),
|
||||||
|
0 2px 8px rgba(22, 93, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-feature__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: rgba(22, 93, 255, 0.95);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
background: rgba(22, 93, 255, 0.1);
|
||||||
|
border-radius: 999px;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
color: rgba(160, 190, 255, 0.95);
|
||||||
|
background: rgba(64, 128, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-feature__dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: #165dff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 12px rgba(22, 93, 255, 0.7);
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background: #7aa2ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-feature__title {
|
||||||
|
margin: 1.5rem 0 0.5rem;
|
||||||
|
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-feature__subtitle {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: rgba(35, 40, 65, 0.85);
|
||||||
|
|
||||||
|
// @media (prefers-color-scheme: dark) {
|
||||||
|
// color: rgba(220, 230, 255, 0.75);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-feature__highlights {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(32, 37, 60, 0.9);
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
border: 1px solid rgba(64, 128, 255, 0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(22, 93, 255, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
li {
|
||||||
|
color: rgba(230, 236, 255, 0.85);
|
||||||
|
background: rgba(18, 22, 36, 0.7);
|
||||||
|
border-color: rgba(98, 149, 255, 0.18);
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: rgba(122, 162, 255, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
justify-self: end;
|
||||||
|
width: min(520px, 100%);
|
||||||
|
padding: clamp(2rem, 3vw, 2.75rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border: 1px solid rgba(22, 93, 255, 0.1);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow:
|
||||||
|
0 16px 48px rgba(22, 93, 255, 0.12),
|
||||||
|
0 4px 16px rgba(22, 93, 255, 0.08),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.5) inset;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
animation: panelLift 0.7s ease;
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
background: rgba(18, 20, 32, 0.88);
|
||||||
|
border-color: rgba(64, 128, 255, 0.25);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 60px rgba(0, 0, 0, 0.6),
|
||||||
|
0 4px 16px rgba(0, 0, 0, 0.4),
|
||||||
|
0 0 0 1px rgba(90, 140, 255, 0.12) inset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 应用内暗黑主题(例如 html/body 上挂 .dark 类)下的登录表单样式 */
|
||||||
|
.dark .auth-panel {
|
||||||
|
background: rgba(26, 32, 48, 0.9);
|
||||||
|
border-color: rgba(86, 140, 255, 0.28);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 60px rgba(0, 0, 0, 0.58),
|
||||||
|
0 4px 16px rgba(0, 0, 0, 0.36),
|
||||||
|
0 0 0 1px rgba(110, 150, 255, 0.16) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel__brand {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 1px solid rgba(22, 93, 255, 0.06);
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
border-color: rgba(64, 128, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel__logo-wrap {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 1.125rem;
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
background: radial-gradient(circle at 30% 20%, #ffffff, #e6efff);
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow:
|
||||||
|
0 8px 20px rgba(22, 93, 255, 0.16),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.8) inset;
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (prefers-color-scheme: dark) {
|
||||||
top: 10px;
|
background: radial-gradient(circle at 30% 20%, #1f2438, #141827);
|
||||||
right: auto;
|
box-shadow:
|
||||||
left: 10px;
|
0 8px 20px rgba(0, 0, 0, 0.7),
|
||||||
}
|
0 0 0 1px rgba(90, 140, 255, 0.3) inset;
|
||||||
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
top: 40px;
|
|
||||||
right: 40px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* fade-slide */
|
.auth-panel__logo {
|
||||||
.fade-slide-leave-active,
|
flex-shrink: 0;
|
||||||
.fade-slide-enter-active {
|
width: 52px;
|
||||||
transition: all 0.3s;
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel__meta {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel__title-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel__title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel__version-row {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel__version-label {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel__version-pill {
|
||||||
|
padding: 0.1rem 0.55rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: linear-gradient(135deg, rgba(22, 93, 255, 0.12), rgba(64, 150, 255, 0.18));
|
||||||
|
border: 1px solid rgba(22, 93, 255, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel__form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-inline: auto;
|
||||||
|
|
||||||
|
:deep(.el-form-item) {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
box-shadow: 0 0 0 1px var(--el-border-color) inset;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-focus {
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-card) {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel__footer {
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid rgba(22, 93, 255, 0.06);
|
||||||
|
|
||||||
|
a {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
color: rgba(22, 93, 255, 0.85);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(22, 93, 255, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
border-color: rgba(64, 128, 255, 0.12);
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(140, 170, 255, 0.88);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(160, 190, 255, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes featureFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes panelLift {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px) scale(0.98);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-enter-active,
|
||||||
|
.fade-slide-leave-active {
|
||||||
|
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-slide-enter-from {
|
.fade-slide-enter-from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-30px);
|
transform: translateX(-40px) scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-slide-leave-to {
|
.fade-slide-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(30px);
|
transform: translateX(40px) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-enter-to,
|
||||||
|
.fade-slide-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0) scale(1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,6 +28,6 @@
|
|||||||
"types": ["node", "vite/client", "element-plus/global"]
|
"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"]
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import vue from "@vitejs/plugin-vue";
|
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 AutoImport from "unplugin-auto-import/vite";
|
||||||
import Components from "unplugin-vue-components/vite";
|
import Components from "unplugin-vue-components/vite";
|
||||||
@@ -18,7 +18,7 @@ const __APP_INFO__ = {
|
|||||||
const pathSrc = resolve(__dirname, "src");
|
const pathSrc = resolve(__dirname, "src");
|
||||||
|
|
||||||
// Vite配置 https://cn.vitejs.dev/config
|
// Vite配置 https://cn.vitejs.dev/config
|
||||||
export default defineConfig(({ mode }: ConfigEnv) => {
|
export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||||
const env = loadEnv(mode, process.cwd());
|
const env = loadEnv(mode, process.cwd());
|
||||||
const isProduction = mode === "production";
|
const isProduction = mode === "production";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user