Merge pull request #256 from 1014TaoTao/2.2.0

feat(ai): 新增AI助手功能及相关组件
This commit is contained in:
fastapiadmin
2025-12-16 01:33:35 +08:00
committed by GitHub
25 changed files with 3118 additions and 104 deletions
+24 -6
View File
@@ -1,11 +1,15 @@
// https://eslint.org/docs/latest/use/configure/configuration-files-new
import eslint from "@eslint/js";
import pluginVue from "eslint-plugin-vue";
import * as typescriptEslint from "typescript-eslint";
import vueParser from "vue-eslint-parser";
import globals from "globals";
// TypeScript支持
import * as typescriptEslint from "typescript-eslint";
// Vue支持
import pluginVue from "eslint-plugin-vue";
import vueParser from "vue-eslint-parser";
// 代码风格与格式化
import configPrettier from "eslint-config-prettier";
import prettierPlugin from "eslint-plugin-prettier";
// 解析自动导入配置
import fs from "node:fs";
@@ -59,6 +63,7 @@ export default [
"**/*.min.*",
"**/auto-imports.d.ts",
"**/components.d.ts",
"**/types/**/*.d.ts",
],
},
@@ -135,6 +140,7 @@ export default [
sourceType: "module",
parser: typescriptEslint.parser,
extraFileExtensions: [".vue"],
tsconfigRootDir: __dirname,
},
},
rules: {
@@ -177,8 +183,10 @@ export default [
languageOptions: {
parser: typescriptEslint.parser,
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
ecmaVersion: "latest",
sourceType: "module",
project: "./tsconfig.json",
tsconfigRootDir: __dirname,
},
},
rules: {
@@ -215,5 +223,15 @@ export default [
},
// Prettier 集成(必须放在最后)
configPrettier,
{
plugins: {
prettier: prettierPlugin, // 将 Prettier 的输出作为 ESLint 的问题来报告
},
rules: {
...configPrettier.rules,
"prettier/prettier": ["error", {}, { usePrettierrc: true }],
"arrow-body-style": "off",
"prefer-arrow-callback": "off",
},
},
];
+8 -3
View File
@@ -1,14 +1,14 @@
{
"name": "fastapi-admin",
"name": "fastapiadmin",
"description": "Vue3 + Vite + TypeScript + Element-Plus 的后台管理模板",
"version": "2.0.0",
"version": "2.2.0",
"private": true,
"type": "module",
"scripts": {
"i": "pnpm install",
"dev": "vite",
"prod": "vite --mode prod",
"build": "vite build",
"build": "vue-tsc --noEmit & vite build",
"build:pro": "pnpm vite build --mode pro",
"build:gitee": "pnpm vite build --mode gitee",
"build:dev": "pnpm vite build --mode dev",
@@ -112,6 +112,11 @@
"eslint-plugin-vue": "^10.4.0",
"fs-extra": "^11.2.0",
"husky": "^9.1.7",
"lint-staged": "^15.5.2",
"postcss": "^8.5.6",
"postcss-html": "^1.8.0",
"postcss-scss": "^4.0.9",
"prettier": "^3.6.2",
"sass": "^1.89.2",
"stylelint": "^16.25.0",
"stylelint-config-html": "^1.1.0",
+14 -1
View File
@@ -8,23 +8,36 @@
class="wh-full"
>
<router-view />
<!-- AI 助手 -->
<AiAssistant v-if="enableAiAssistant" />
</el-watermark>
</el-config-provider>
</template>
<script setup lang="ts">
import { useAppStore, useSettingsStore } from "@/store";
import { useAppStore, useSettingsStore, useUserStore } from "@/store";
import { defaultSettings } from "@/settings";
import { ThemeMode } from "@/enums/settings/theme.enum";
import { ComponentSize } from "@/enums/settings/layout.enum";
import AiAssistant from "@/components/AiAssistant/index.vue";
const appStore = useAppStore();
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const locale = computed(() => appStore.locale);
const size = computed(() => appStore.size as ComponentSize);
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(() => {
return settingsStore.theme === ThemeMode.DARK ? "rgba(255, 255, 255, .15)" : "rgba(0, 0, 0, .15)";
+180
View File
@@ -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

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

+71
View File
@@ -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用户的姓名为测试人员&#10;或者:跳转到用户管理&#10;按 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>
+269
View File
@@ -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,
};
}
+8
View File
@@ -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>
</template>
</router-view>
<!-- 返回顶部按钮 -->
<el-backtop target=".app-main">
<div class="i-svg:backtop w-6 h-6" />
</el-backtop>
</section>
</template>
@@ -132,6 +132,16 @@ const onMenuClose = (index: string) => {
expandedMenuIndexes.value = expandedMenuIndexes.value.filter((item) => item !== index);
};
/**
* 监听展开的菜单项变化,更新父菜单样式
*/
watch(
() => expandedMenuIndexes.value,
() => {
updateParentMenuStyles();
}
);
/**
* 监听菜单模式变化:当菜单模式切换为水平模式时,关闭所有展开的菜单项,
* 避免在水平模式下菜单项显示错位。
@@ -220,7 +230,7 @@ function updateParentMenuStyles() {
}
}
} catch (error) {
console.error("Error updating parent menu styles:", error);
console.error("更新父菜单样式时出错:", error);
}
});
}
@@ -102,8 +102,7 @@ const updateMenuState = (topMenuPath: string, skipNavigation = false) => {
// 确保路径有效且不相同才更新,避免重复操作
if (topMenuPath && appStore.activeTopMenuPath) {
appStore.activeTopMenu(topMenuPath); // 设置激活的顶部菜单
// 只有当路由映射表中存在该路径时才更新侧边菜单
permissionStore.setMixLayoutSideMenus(topMenuPath); // 设置混合布局左侧菜单
permissionStore.setMixLayoutSideMenus(topMenuPath); // 只有当路由映射表中存在该路径时才更新侧边菜单。 设置混合布局左侧菜单
}
// 如果是点击菜单且状态已变更,才进行导航
+2 -1
View File
@@ -34,7 +34,6 @@ export const defaultSettings: AppSettings = {
// 主题颜色 - 修改此值时需同步修改 src/styles/variables.scss
themeColor: "#4080FF",
// 是否显示水印 (修改默认开启水印)
// showWatermark: false,
showWatermark: true,
// 水印内容
watermarkContent: pkg.name,
@@ -44,6 +43,8 @@ export const defaultSettings: AppSettings = {
guideVisible: false,
/** 是否启动引导 */
showGuide: true,
// 是否启用 AI 助手
enableAiAssistant: false,
};
// 主题色预设 - 现代化配色方案
@@ -13,6 +13,7 @@ interface SettingsState {
showWatermark: boolean;
showSettings: boolean;
showGuide: boolean; // 引导功能开关
enableAiAssistant: boolean;
// 桌面端工具显示设置
showMenuSearch: boolean;
@@ -44,14 +45,25 @@ export const useSettingsStore = defineStore("setting", () => {
defaultSettings.showTagsView
);
const showAppLogo = useStorage<boolean>(SETTINGS_KEYS.SHOW_APP_LOGO, defaultSettings.showAppLogo);
// 是否显示水印
const showWatermark = useStorage<boolean>(
SETTINGS_KEYS.SHOW_WATERMARK,
defaultSettings.showWatermark
);
// 是否启用 AI 助手
const enableAiAssistant = useStorage<boolean>(
"vea:ui:enable_ai_assistant",
defaultSettings.enableAiAssistant
);
// 是否显示系统设置
const showSettings = useStorage<boolean>(
SETTINGS_KEYS.SHOW_SETTINGS,
defaultSettings.showSettings
);
// 是否显示引导功能
const showGuide = useStorage<boolean>(SETTINGS_KEYS.SHOW_GUIDE, defaultSettings.showGuide); // 引导功能开关
// 🎯 桌面端工具设置 - 持久化
@@ -59,18 +71,26 @@ export const useSettingsStore = defineStore("setting", () => {
SETTINGS_KEYS.SHOW_MENU_SEARCH,
defaultSettings.showMenuSearch
);
// 是否显示全屏切换
const showFullscreen = useStorage<boolean>(
SETTINGS_KEYS.SHOW_FULLSCREEN,
defaultSettings.showFullscreen
);
// 是否显示布局大小选择
const showSizeSelect = useStorage<boolean>(
SETTINGS_KEYS.SHOW_SIZE_SELECT,
defaultSettings.showSizeSelect
);
// 是否显示语言选择
const showLangSelect = useStorage<boolean>(
SETTINGS_KEYS.SHOW_LANG_SELECT,
defaultSettings.showLangSelect
);
// 是否显示通知
const showNotification = useStorage<boolean>(
SETTINGS_KEYS.SHOW_NOTIFICATION,
defaultSettings.showNotification
@@ -81,8 +101,12 @@ export const useSettingsStore = defineStore("setting", () => {
SETTINGS_KEYS.SIDEBAR_COLOR_SCHEME,
defaultSettings.sidebarColorScheme
);
// 布局设置
const layout = useStorage<LayoutMode>(SETTINGS_KEYS.LAYOUT, defaultSettings.layout as LayoutMode);
// 主题颜色
const themeColor = useStorage<string>(SETTINGS_KEYS.THEME_COLOR, defaultSettings.themeColor);
// 主题模式
const theme = useStorage<ThemeMode>(SETTINGS_KEYS.THEME, defaultSettings.theme);
// 🎯 设置项映射
@@ -99,6 +123,7 @@ export const useSettingsStore = defineStore("setting", () => {
showNotification,
sidebarColorScheme,
layout,
enableAiAssistant,
} as const;
// 🎯 监听器 - 主题变化
@@ -134,14 +159,17 @@ export const useSettingsStore = defineStore("setting", () => {
theme.value = newTheme;
}
// 更新主题颜色
function updateThemeColor(newColor: string): void {
themeColor.value = newColor;
}
// 更新侧边栏配色方案
function updateSidebarColorScheme(newScheme: string): void {
sidebarColorScheme.value = newScheme;
}
// 更新布局
function updateLayout(newLayout: LayoutMode): void {
layout.value = newLayout;
}
@@ -151,10 +179,12 @@ export const useSettingsStore = defineStore("setting", () => {
settingsVisible.value = !settingsVisible.value;
}
// 显示设置面板
function showSettingsPanel(): void {
settingsVisible.value = true;
}
// 隐藏设置面板
function hideSettingsPanel(): void {
settingsVisible.value = false;
}
@@ -167,6 +197,7 @@ export const useSettingsStore = defineStore("setting", () => {
showWatermark.value = defaultSettings.showWatermark;
showSettings.value = defaultSettings.showSettings;
showGuide.value = defaultSettings.showGuide;
enableAiAssistant.value = defaultSettings.enableAiAssistant;
// 桌面端工具设置
showMenuSearch.value = defaultSettings.showMenuSearch;
@@ -192,6 +223,7 @@ export const useSettingsStore = defineStore("setting", () => {
showWatermark,
showSettings,
showGuide,
enableAiAssistant,
// 🎯 桌面端工具状态
showMenuSearch,
+2
View File
@@ -98,6 +98,8 @@ declare global {
guideVisible: boolean;
/** 是否启动引导 */
showGuide: boolean;
/** 是否启用AI助手 */
enableAiAssistant: boolean;
}
/**
+114
View File
@@ -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">
<div flex>
<div flex items-center gap-10px>
<el-input
v-model.trim="loginForm.captcha"
:placeholder="t('login.captchaCode')"
clearable
style="width: 320px"
@keyup.enter="handleLoginSubmit"
>
<template #prefix>
<div class="i-svg:captcha" />
</template>
</el-input>
<div cursor-pointer flex-center ml-10px>
<el-icon v-if="codeLoading" class="is-loading">
<div cursor-pointer flex-center w-100px>
<el-icon v-if="codeLoading" class="is-loading" size="20">
<Loading />
</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>
</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>
+479 -58
View File
@@ -1,14 +1,7 @@
<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="auth-view">
<!-- 右侧切换主题语言按钮 -->
<div class="action-bar">
<div class="auth-view__toolbar">
<el-tooltip :content="t('login.themeToggle')" placement="bottom">
<CommonWrapper>
<DarkModeSwitch />
@@ -21,34 +14,58 @@
</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>
<div class="auth-view__wrapper">
<!-- 可选左侧产品介绍区域如不需要可整段删除右侧登录表单会自动居中展示 -->
<section class="auth-feature">
<div class="auth-feature__badge">
<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 -->
<!-- <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-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">
<span class="auth-panel__title">{{ configStore.configData.sys_web_title.config_value }}</span>
<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 class="auth-panel__version-row">
<span class="auth-panel__version-label">Version</span>
<span class="auth-panel__version-pill">v{{ configStore.configData.sys_web_version.config_value }}</span>
</div>
</div>
</div>
<!-- 组件切换 -->
<transition name="fade-slide" mode="out-in">
<component
@@ -56,13 +73,13 @@
v-model="component"
v-model:preset-username="loginPreset.username"
v-model:preset-password="loginPreset.password"
class="w-90%"
class="auth-panel__form"
/>
</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">
{{ configStore.configData.sys_web_copyright.config_value }} |
</a>
@@ -71,6 +88,8 @@
<a :href="configStore.configData.sys_web_clause.config_value" target="_blank">条款</a>
{{ configStore.configData.sys_keep_record.config_value }}
</el-text>
</footer>
</section>
</div>
</div>
</template>
@@ -101,62 +120,464 @@ const loginPreset = reactive<{ username: string; password: string }>({
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(() => {
setTimeout(showVoteNotification, 500);
configStore.getConfig();
});
onBeforeUnmount(() => {
if (notificationInstance) {
notificationInstance.close();
notificationInstance = null;
}
});
</script>
<style lang="scss" scoped>
.login-container {
.auth-view {
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;
padding: clamp(1rem, 3vw, 2rem);
overflow: hidden;
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;
top: 10px;
right: 10px;
z-index: 10;
top: 12px;
right: 16px;
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;
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;
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) {
top: 10px;
right: auto;
left: 10px;
}
@media (min-width: 640px) {
top: 40px;
right: 40px;
@media (prefers-color-scheme: dark) {
background: radial-gradient(circle at 30% 20%, #1f2438, #141827);
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(90, 140, 255, 0.3) inset;
}
}
/* fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
.auth-panel__logo {
flex-shrink: 0;
width: 52px;
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 {
opacity: 0;
transform: translateX(-30px);
transform: translateX(-40px) scale(0.95);
}
.fade-slide-leave-to {
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>
+1 -1
View File
@@ -28,6 +28,6 @@
"types": ["node", "vite/client", "element-plus/global"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "eslint.config.ts", "uno.config.ts"],
"include": ["src/types/**/*.d.ts", "src/**/*.ts", "src/**/*.vue", "vite.config.ts", "eslint.config.ts", "uno.config.ts"],
"exclude": ["node_modules", "dist"]
}
+2 -2
View File
@@ -1,5 +1,5 @@
import vue from "@vitejs/plugin-vue";
import { type ConfigEnv, loadEnv, defineConfig } from "vite";
import { type ConfigEnv, type UserConfig, loadEnv, defineConfig } from "vite";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
@@ -18,7 +18,7 @@ const __APP_INFO__ = {
const pathSrc = resolve(__dirname, "src");
// Vite配置 https://cn.vitejs.dev/config
export default defineConfig(({ mode }: ConfigEnv) => {
export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
const env = loadEnv(mode, process.cwd());
const isProduction = mode === "production";