refactor: 项目重构

This commit is contained in:
Ray.Hao
2025-05-24 07:35:46 +08:00
parent cfe041d7d2
commit 32686ad807
51 changed files with 1201 additions and 696 deletions

View File

@@ -5,11 +5,11 @@ VITE_APP_PORT=3000
VITE_APP_BASE_API=/dev-api
# 接口地址
VITE_APP_API_URL=https://api.youlai.tech # 线上
# VITE_APP_API_URL=http://localhost:8989 # 本地
# VITE_APP_API_URL=https://api.youlai.tech # 线上
VITE_APP_API_URL=http://localhost:8989 # 本地
# WebSocket 端点(不配置则关闭),线上 ws://api.youlai.tech/ws ,本地 ws://localhost:8989/ws
VITE_APP_WS_ENDPOINT=
VITE_APP_WS_ENDPOINT=ws://localhost:8989/ws
# 启用 Mock 服务
VITE_MOCK_DEV_SERVER=false

View File

@@ -2,8 +2,7 @@
import eslint from "@eslint/js";
import pluginVue from "eslint-plugin-vue";
import pluginTypeScript from "@typescript-eslint/eslint-plugin";
import parserTypeScript from "@typescript-eslint/parser";
import * as typescriptEslint from "typescript-eslint";
import vueParser from "vue-eslint-parser";
import globals from "globals";
import configPrettier from "eslint-config-prettier";
@@ -52,73 +51,108 @@ const elementPlusComponents = {
};
export default [
// 忽略文件配置
{
ignores: [
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/.nuxt/**",
"**/.output/**",
"**/coverage/**",
"**/*.min.*",
"**/auto-imports.d.ts",
"**/components.d.ts",
],
},
// 基础 JavaScript 配置
eslint.configs.recommended,
// Vue 推荐配置
...pluginVue.configs["flat/recommended"],
// TypeScript 推荐配置
...typescriptEslint.configs.recommended,
// 全局配置
{
// 指定要检查的文件
files: ["**/*.js", "**/*.ts", "**/*.vue"],
ignores: ["node_modules/**", "dist/**", "build/**"],
files: ["**/*.{js,mjs,cjs,ts,mts,cts,vue}"],
languageOptions: {
ecmaVersion: 2022,
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser, // 浏览器环境全局变量
...globals.node, // Node.js 环境全局变量
...globals.es2022, // ES2022 全局对象
...autoImportGlobals, // 自动导入的 API 函数
...elementPlusComponents, // Element Plus 组件
// 全局类型定义,解决 TypeScript 中定义但 ESLint 不识别的问题
PageQuery: "readonly",
PageResult: "readonly",
OptionType: "readonly",
ResponseData: "readonly",
ApiResponse: "readonly",
ExcelResult: "readonly",
TagView: "readonly",
AppSettings: "readonly",
__APP_INFO__: "readonly",
},
parser: parserTypeScript,
parserOptions: {
sourceType: "module",
},
},
plugins: {
vue: pluginVue,
"@typescript-eslint": typescriptEslint.plugin,
},
rules: {
// 全局规则
"no-unused-vars": [
"error",
{
vars: "all",
args: "after-used",
ignoreRestSiblings: true,
argsIgnorePattern: "^_", // 忽略以下划线开头的参数
varsIgnorePattern: "^[A-Z][A-Z0-9_]*$", // 忽略全大写的常量/枚举
},
],
// 禁用未定义变量检查TypeScript 已处理类型检查
// 基础规则
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
// ES6+ 规则
"prefer-const": "error",
"no-var": "error",
"object-shorthand": "error",
// 最佳实践
eqeqeq: ["error", "always", { null: "ignore" }],
"no-multi-spaces": "error",
"no-multiple-empty-lines": ["error", { max: 1, maxBOF: 0, maxEOF: 0 }],
// 禁用与 TypeScript 冲突的规则
"no-unused-vars": "off",
"no-undef": "off",
"no-redeclare": "off",
},
},
// 基础 JavaScript 配置
eslint.configs.recommended,
// Vue 配置
// Vue 文件特定配置
{
files: ["**/*.vue"],
plugins: {
vue: pluginVue,
},
languageOptions: {
parser: vueParser,
parserOptions: {
ecmaVersion: 2022,
ecmaVersion: "latest",
sourceType: "module",
parser: parserTypeScript,
parser: typescriptEslint.parser,
extraFileExtensions: [".vue"],
},
},
processor: pluginVue.processors[".vue"],
rules: {
// Vue 规则
"vue/multi-word-component-names": "off",
"vue/no-v-html": "off",
"vue/require-default-prop": "off",
"vue/require-explicit-emits": "error",
"vue/no-unused-vars": "error",
"vue/no-mutating-props": "error",
"vue/attribute-hyphenation": ["error", "always"],
"vue/v-on-event-hyphenation": ["error", "always"],
"vue/block-order": [
"error",
{
order: ["template", "script", "style"],
},
],
"vue/html-self-closing": [
"error",
{
@@ -127,20 +161,27 @@ export default [
normal: "never",
component: "always",
},
svg: "always",
math: "always",
},
],
"vue/no-unused-vars": "off",
"vue/component-name-in-template-casing": ["error", "PascalCase"],
},
},
// TypeScript 配置
// TypeScript 文件特定配置
{
files: ["**/*.ts", "**/*.tsx", "**/*.vue"],
plugins: {
"@typescript-eslint": pluginTypeScript,
files: ["**/*.{ts,tsx,mts,cts}"],
languageOptions: {
parser: typescriptEslint.parser,
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
// TypeScript 规则
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-object-type": "off",
"@typescript-eslint/ban-ts-comment": "off",
@@ -155,6 +196,32 @@ export default [
varsIgnorePattern: "^[A-Z][A-Z0-9_]*$", // 忽略全大写的常量/枚举
},
],
"@typescript-eslint/consistent-type-imports": [
"error",
{
prefer: "type-imports",
fixStyle: "inline-type-imports",
},
],
"@typescript-eslint/no-import-type-side-effects": "error",
},
},
// .d.ts 文件配置
{
files: ["**/*.d.ts"],
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
},
},
// 测试文件配置
{
files: ["**/__tests__/**", "**/*.{test,spec}.{js,ts,vue}"],
rules: {
"no-console": "off",
"@typescript-eslint/no-explicit-any": "off",
},
},
@@ -167,10 +234,6 @@ export default [
},
},
// Prettier 集成
{
rules: {
...configPrettier.rules,
},
},
// Prettier 集成(必须放在最后)
configPrettier,
];

View File

@@ -25,7 +25,7 @@ const AuthAPI = {
return request<any, LoginResult>({
url: `${AUTH_BASE_URL}/refresh-token`,
method: "post",
params: { refreshToken: refreshToken },
params: { refreshToken },
headers: {
Authorization: "no-auth",
},

View File

@@ -8,7 +8,7 @@ const GeneratorAPI = {
return request<any, PageResult<TablePageVO[]>>({
url: `${GENERATOR_BASE_URL}/table/page`,
method: "get",
params: params,
params,
});
},
@@ -25,7 +25,7 @@ const GeneratorAPI = {
return request({
url: `${GENERATOR_BASE_URL}/${tableName}/config`,
method: "post",
data: data,
data,
});
},

View File

@@ -42,7 +42,7 @@ const FileAPI = {
return request({
url: "/api/v1/files",
method: "delete",
params: { filePath: filePath },
params: { filePath },
});
},
@@ -53,7 +53,7 @@ const FileAPI = {
*/
download(url: string, fileName?: string) {
return request({
url: url,
url,
method: "get",
responseType: "blob",
}).then((res) => {

View File

@@ -25,7 +25,7 @@ const ConfigAPI = {
return request({
url: `${CONFIG_BASE_URL}`,
method: "post",
data: data,
data,
});
},
@@ -34,7 +34,7 @@ const ConfigAPI = {
return request({
url: `${CONFIG_BASE_URL}/${id}`,
method: "put",
data: data,
data,
});
},

View File

@@ -48,7 +48,7 @@ const DeptAPI = {
return request({
url: `${DEPT_BASE_URL}`,
method: "post",
data: data,
data,
});
},
@@ -63,7 +63,7 @@ const DeptAPI = {
return request({
url: `${DEPT_BASE_URL}/${id}`,
method: "put",
data: data,
data,
});
},

View File

@@ -55,7 +55,7 @@ const DictAPI = {
return request({
url: `${DICT_BASE_URL}`,
method: "post",
data: data,
data,
});
},
@@ -69,7 +69,7 @@ const DictAPI = {
return request({
url: `${DICT_BASE_URL}/${id}`,
method: "put",
data: data,
data,
});
},
@@ -119,7 +119,7 @@ const DictAPI = {
return request({
url: `${DICT_BASE_URL}/${dictCode}/items`,
method: "post",
data: data,
data,
});
},
@@ -143,7 +143,7 @@ const DictAPI = {
return request({
url: `${DICT_BASE_URL}/${dictCode}/items/${id}`,
method: "put",
data: data,
data,
});
},

View File

@@ -40,7 +40,7 @@ const MenuAPI = {
return request<any, OptionType[]>({
url: `${MENU_BASE_URL}/options`,
method: "get",
params: { onlyParent: onlyParent },
params: { onlyParent },
});
},
@@ -66,7 +66,7 @@ const MenuAPI = {
return request({
url: `${MENU_BASE_URL}`,
method: "post",
data: data,
data,
});
},
@@ -81,7 +81,7 @@ const MenuAPI = {
return request({
url: `${MENU_BASE_URL}/${id}`,
method: "put",
data: data,
data,
});
},

View File

@@ -35,7 +35,7 @@ const NoticeAPI = {
return request({
url: `${NOTICE_BASE_URL}`,
method: "post",
data: data,
data,
});
},
@@ -49,7 +49,7 @@ const NoticeAPI = {
return request({
url: `${NOTICE_BASE_URL}/${id}`,
method: "put",
data: data,
data,
});
},

View File

@@ -42,7 +42,7 @@ const RoleAPI = {
return request({
url: `${ROLE_BASE_URL}/${roleId}/menus`,
method: "put",
data: data,
data,
});
},
@@ -64,7 +64,7 @@ const RoleAPI = {
return request({
url: `${ROLE_BASE_URL}`,
method: "post",
data: data,
data,
});
},
@@ -78,7 +78,7 @@ const RoleAPI = {
return request({
url: `${ROLE_BASE_URL}/${id}`,
method: "put",
data: data,
data,
});
},

View File

@@ -50,7 +50,7 @@ const UserAPI = {
return request({
url: `${USER_BASE_URL}`,
method: "post",
data: data,
data,
});
},
@@ -64,7 +64,7 @@ const UserAPI = {
return request({
url: `${USER_BASE_URL}/${id}`,
method: "put",
data: data,
data,
});
},
@@ -78,7 +78,7 @@ const UserAPI = {
return request({
url: `${USER_BASE_URL}/${id}/password/reset`,
method: "put",
params: { password: password },
params: { password },
});
},
@@ -129,7 +129,7 @@ const UserAPI = {
return request<any, ExcelResult>({
url: `${USER_BASE_URL}/import`,
method: "post",
params: { deptId: deptId },
params: { deptId },
data: formData,
headers: {
"Content-Type": "multipart/form-data",
@@ -150,7 +150,7 @@ const UserAPI = {
return request({
url: `${USER_BASE_URL}/profile`,
method: "put",
data: data,
data,
});
},
@@ -159,7 +159,7 @@ const UserAPI = {
return request({
url: `${USER_BASE_URL}/password`,
method: "put",
data: data,
data,
});
},
@@ -168,7 +168,7 @@ const UserAPI = {
return request({
url: `${USER_BASE_URL}/mobile/code`,
method: "post",
params: { mobile: mobile },
params: { mobile },
});
},
@@ -177,7 +177,7 @@ const UserAPI = {
return request({
url: `${USER_BASE_URL}/mobile`,
method: "put",
data: data,
data,
});
},
@@ -186,7 +186,7 @@ const UserAPI = {
return request({
url: `${USER_BASE_URL}/email/code`,
method: "post",
params: { email: email },
params: { email },
});
},
@@ -195,7 +195,7 @@ const UserAPI = {
return request({
url: `${USER_BASE_URL}/email`,
method: "put",
data: data,
data,
});
},

View File

@@ -33,6 +33,6 @@ const linkProps = (to: any) => {
rel: "noopener noreferrer",
};
}
return { to: to };
return { to };
};
</script>

View File

@@ -8,9 +8,10 @@
<div class="toolbar-left flex gap-y-2.5 gap-x-2 md:gap-x-3 flex-wrap">
<template v-for="btn in toolbarLeftBtn">
<el-button
v-bind="btn.attrs"
:key="btn.name"
v-hasPerm="btn.perm ?? '*:*:*'"
:disabled="btn.name === 'delete' && removeIds.length === 0"
v-bind="btn.attrs"
@click="handleToolbar(btn.name)"
>
{{ btn.text }}
@@ -546,7 +547,7 @@ const exportsFormRef = ref<FormInstance>();
const exportsFormData = reactive({
filename: "",
sheetname: "",
fields: fields,
fields,
origin: ExportsOriginEnum.CURRENT,
});
const exportsFormRules: FormRules = {
@@ -807,8 +808,8 @@ function handleModify(field: string, value: boolean | string | number, row: Reco
if (props.contentConfig.modifyAction) {
props.contentConfig.modifyAction({
[pk]: row[pk],
field: field,
value: value,
field,
value,
});
} else {
ElMessage.error("未配置modifyAction");

View File

@@ -72,7 +72,7 @@ const emit = defineEmits<{
}>();
// 组件映射表
const componentMap = new Map<ISearchComponent, any>([
/* eslint-disable */
// @ts-ignore
["input", markRaw(ElInput)], // @ts-ignore
["select", markRaw(ElSelect)], // @ts-ignore
@@ -84,7 +84,7 @@ const componentMap = new Map<ISearchComponent, any>([
["tree-select", markRaw(ElTreeSelect)], // @ts-ignore
["input-tag", markRaw(ElInputTag)], // @ts-ignore
["custom-tag", markRaw(InputTag)],
/* eslint-enable */
]);
// 存储表单实例

View File

@@ -3,7 +3,7 @@ import type { FormProps, TableProps, ColProps, ButtonProps, CardProps } from "el
import type PageContent from "./PageContent.vue";
import type PageModal from "./PageModal.vue";
import type PageSearch from "./PageSearch.vue";
import { CSSProperties } from "vue";
import type { CSSProperties } from "vue";
export type PageSearchInstance = InstanceType<typeof PageSearch>;
export type PageContentInstance = InstanceType<typeof PageContent>;

View File

@@ -36,12 +36,12 @@ function usePage() {
if (RefImpl) {
RefImpl.value?.setModalVisible();
RefImpl.value?.handleDisabled(false);
let from = await (callback?.(row) ?? Promise.resolve(row));
const from = await (callback?.(row) ?? Promise.resolve(row));
RefImpl.value?.setFormData(from ? from : row);
} else {
editModalRef.value?.setModalVisible();
editModalRef.value?.handleDisabled(false);
let from = await (callback?.(row) ?? Promise.resolve(row));
const from = await (callback?.(row) ?? Promise.resolve(row));
editModalRef.value?.setFormData(from ? from : row);
}
}
@@ -54,12 +54,12 @@ function usePage() {
if (RefImpl) {
RefImpl.value?.setModalVisible();
RefImpl.value?.handleDisabled(true);
let from = await (callback?.(row) ?? Promise.resolve(row));
const from = await (callback?.(row) ?? Promise.resolve(row));
RefImpl.value?.setFormData(from ? from : row);
} else {
editModalRef.value?.setModalVisible();
editModalRef.value?.handleDisabled(true);
let from = await (callback?.(row) ?? Promise.resolve(row));
const from = await (callback?.(row) ?? Promise.resolve(row));
editModalRef.value?.setFormData(from ? from : row);
}
}

View File

@@ -226,7 +226,7 @@ const queryParams = reactive<{
[key: string]: any;
}>({
pageNum: 1,
pageSize: pageSize,
pageSize,
});
// 计算popover的宽度

View File

@@ -130,7 +130,7 @@ watch(
fileList.value = value.map((item) => {
const name = item.name ? item.name : item.url?.substring(item.url.lastIndexOf("/") + 1);
return {
name: name,
name,
url: item.url,
status: "success",
uid: getUid(),
@@ -199,11 +199,11 @@ const handleSuccess = (response: any, uploadFile: UploadFile, files: UploadFiles
return file.status === "success" || file.status === "fail";
})
) {
let fileInfos = [] as FileInfo[];
const fileInfos = [] as FileInfo[];
files.map((file: UploadFile) => {
if (file.status === "success") {
//只取携带response的才是刚上传的
let res = file.response as FileInfo;
const res = file.response as FileInfo;
if (res) {
fileInfos.push({ name: res.name, url: res.url } as FileInfo);
}

View File

@@ -1,6 +1,6 @@
import { useDictStoreHook } from "@/store/modules/dict.store";
import { useStomp } from "./useStomp";
import { IMessage } from "@stomp/stompjs";
import type { IMessage } from "@stomp/stompjs";
import { ref } from "vue";
// 字典消息类型
@@ -39,6 +39,9 @@ function createDictSyncHook() {
// 消息回调函数列表
const messageCallbacks = ref<DictMessageCallback[]>([]);
// 重试定时器
let retryTimer: any = null;
/**
* 注册字典消息回调
* @param callback 回调函数
@@ -62,7 +65,7 @@ function createDictSyncHook() {
// 检查是否配置了WebSocket端点
const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT;
if (!wsEndpoint) {
console.log("[WebSocket] 未配置WebSocket端点,跳过连接");
console.log("[DictSync] 未配置WebSocket端点,跳过连接");
return;
}
@@ -72,7 +75,7 @@ function createDictSyncHook() {
// 设置字典订阅
setupDictSubscription();
} catch (error) {
console.error("[WebSocket] 初始化失败:", error);
console.error("[DictSync] 初始化失败:", error);
}
};
@@ -80,6 +83,12 @@ function createDictSyncHook() {
* 关闭WebSocket
*/
const closeWebSocket = () => {
// 清理重试定时器
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
// 取消所有订阅
subscriptionIds.value.forEach((id) => {
unsubscribe(id);
@@ -99,27 +108,40 @@ function createDictSyncHook() {
// 防止重复订阅
if (subscribedTopics.value.has(topic)) {
console.log(`跳过重复订阅: ${topic}`);
console.log(`[DictSync] 跳过重复订阅: ${topic}`);
return;
}
console.log(`开始尝试订阅字典主题: ${topic}`);
console.log(`[DictSync] 开始尝试订阅字典主题: ${topic}`);
// 使用简化的重试逻辑依赖useStomp的连接管理
const attemptSubscribe = () => {
if (!isConnected.value) {
console.log("等待WebSocket连接建立...");
console.log("[DictSync] 等待WebSocket连接建立...");
// 清理之前的定时器,防止重复
if (retryTimer) {
clearTimeout(retryTimer);
}
// 10秒后再次尝试
setTimeout(attemptSubscribe, 10000);
retryTimer = setTimeout(() => {
retryTimer = null;
attemptSubscribe();
}, 10000);
return;
}
// 清理重试定时器
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
// 检查是否已订阅
if (subscribedTopics.value.has(topic)) {
return;
}
console.log(`连接已建立,开始订阅: ${topic}`);
console.log(`[DictSync] 连接已建立,开始订阅: ${topic}`);
// 订阅字典更新
const subId = subscribe(topic, (message: IMessage) => {
@@ -129,9 +151,9 @@ function createDictSyncHook() {
if (subId) {
subscriptionIds.value.push(subId);
subscribedTopics.value.add(topic);
console.log(`字典主题订阅成功: ${topic}`);
console.log(`[DictSync] 字典主题订阅成功: ${topic}`);
} else {
console.warn(`字典主题订阅失败: ${topic}`);
console.warn(`[DictSync] 字典主题订阅失败: ${topic}`);
}
};
@@ -148,7 +170,7 @@ function createDictSyncHook() {
try {
// 记录接收到的消息
console.log(`收到字典更新消息: ${message.body}`);
console.log(`[DictSync] 收到字典更新消息: ${message.body}`);
// 尝试解析消息
const parsedData = JSON.parse(message.body) as DictMessage;
@@ -158,21 +180,21 @@ function createDictSyncHook() {
// 清除缓存,等待按需加载
dictStore.removeDictItem(dictCode);
console.log(`字典缓存已清除: ${dictCode}`);
console.log(`[DictSync] 字典缓存已清除: ${dictCode}`);
// 调用所有注册的回调函数
messageCallbacks.value.forEach((callback) => {
try {
callback(parsedData);
} catch (callbackError) {
console.error("[WebSocket] 回调执行失败:", callbackError);
console.error("[DictSync] 回调执行失败:", callbackError);
}
});
// 显示提示消息
console.info(`字典 ${dictCode} 已变更,将在下次使用时自动加载`);
console.info(`[DictSync] 字典 ${dictCode} 已变更,将在下次使用时自动加载`);
} catch (error) {
console.error("[WebSocket] 解析消息失败:", error);
console.error("[DictSync] 解析消息失败:", error);
}
};

View File

@@ -1,14 +1,16 @@
import { ref, onMounted, onUnmounted, watch } from "vue";
import { ref, onMounted, onUnmounted, watch, getCurrentInstance } from "vue";
import { useStomp } from "./useStomp";
import { ElMessage } from "element-plus";
import { Storage } from "@/utils/storage";
import { ACCESS_TOKEN_KEY } from "@/constants/cache-keys";
import { registerWebSocketInstance } from "@/plugins/websocket";
import { Auth } from "@/utils/auth";
// 全局单例实例
let globalInstance: ReturnType<typeof createOnlineCountHook> | null = null;
/**
* 在线用户计数组合式函数
* 用于订阅后端推送的在线用户数量变化
* 创建在线用户计数的核心逻辑
*/
export function useOnlineCount() {
function createOnlineCountHook() {
// 在线用户数量
const onlineUserCount = ref(0);
@@ -22,18 +24,23 @@ export function useOnlineCount() {
const isConnecting = ref(false);
// 使用Stomp客户端 - 配置使用指数退避策略
const stompInstance = useStomp({
reconnectDelay: 15000, // 重连基础延迟
maxReconnectAttempts: 3, // 重连次数上限
connectionTimeout: 10000, // 连接超时
useExponentialBackoff: true, // 启用指数退避
});
const {
connect,
subscribe,
unsubscribe,
disconnect,
isConnected: stompConnected,
} = useStomp({
reconnectDelay: 15000, // 重连基础延迟
maxReconnectAttempts: 3, // 重连次数上限
connectionTimeout: 10000, // 连接超时
useExponentialBackoff: true, // 启用指数退避
});
} = stompInstance;
// 注册到全局实例管理器
registerWebSocketInstance("onlineCount", stompInstance);
// 订阅ID
let subscriptionId = "";
@@ -49,7 +56,7 @@ export function useOnlineCount() {
// 一旦连接成功,立即订阅主题
subscribeToOnlineCount();
console.log("WebSocket连接成功已订阅在线用户计数主题");
console.log("[useOnlineCount] WebSocket连接成功已订阅在线用户计数主题");
}
});
@@ -81,7 +88,7 @@ export function useOnlineCount() {
lastUpdateTime.value = Date.now();
}
} catch (error) {
console.error("解析在线用户数量失败:", error);
console.error("[useOnlineCount] 解析在线用户数量失败:", error);
}
});
};
@@ -95,18 +102,19 @@ export function useOnlineCount() {
// 检查WebSocket端点是否配置
const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT;
if (!wsEndpoint) {
console.log("未配置WebSocket端点(VITE_APP_WS_ENDPOINT),跳过WebSocket连接");
console.log("[useOnlineCount] 未配置WebSocket端点(VITE_APP_WS_ENDPOINT),跳过WebSocket连接");
return;
}
// 检查是否有可用的令牌
const hasToken = !!Storage.get(ACCESS_TOKEN_KEY, "");
if (!hasToken) {
console.log("没有检测到有效令牌不尝试WebSocket连接");
// 使用 Auth.getAccessToken() 获取令牌,确保获取到最新的
const accessToken = Auth.getAccessToken();
if (!accessToken) {
console.log("[useOnlineCount] 没有检测到有效令牌不尝试WebSocket连接");
return;
}
isConnecting.value = true;
console.log("[useOnlineCount] 开始建立WebSocket连接...");
// 连接WebSocket
connect();
@@ -115,17 +123,17 @@ export function useOnlineCount() {
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = setTimeout(() => {
if (!isConnected.value) {
console.warn("WebSocket连接超时将自动尝试重连");
console.warn("[useOnlineCount] WebSocket连接超时将自动尝试重连");
ElMessage.warning("正在尝试连接服务器,请稍候...");
// 超时后尝试重新连接
closeWebSocket();
setTimeout(() => {
// 再次检查令牌有效性
if (Storage.get(ACCESS_TOKEN_KEY, "")) {
if (Auth.getAccessToken()) {
initWebSocket();
} else {
console.log("令牌无效,放弃重连");
console.log("[useOnlineCount] 令牌无效,放弃重连");
}
}, 3000);
}
@@ -160,16 +168,6 @@ export function useOnlineCount() {
isConnecting.value = false;
};
// 组件挂载时初始化WebSocket
onMounted(() => {
initWebSocket();
});
// 组件卸载时关闭WebSocket
onUnmounted(() => {
closeWebSocket();
});
return {
onlineUserCount,
lastUpdateTime,
@@ -179,3 +177,39 @@ export function useOnlineCount() {
closeWebSocket,
};
}
/**
* 在线用户计数组合式函数
* 使用单例模式,避免重复创建 WebSocket 连接
* @param options 配置选项
* @param options.autoInit 是否在组件挂载时自动初始化(默认 true
*/
export function useOnlineCount(options: { autoInit?: boolean } = {}) {
const { autoInit = true } = options;
if (!globalInstance) {
globalInstance = createOnlineCountHook();
}
// 只有在组件上下文中且 autoInit 为 true 时才使用生命周期钩子
if (autoInit && getCurrentInstance()) {
// 组件挂载时检查是否需要初始化WebSocket
onMounted(() => {
// 只有在未连接且未连接中时才尝试初始化
if (!globalInstance!.isConnected.value && !globalInstance!.isConnecting.value) {
console.log("[useOnlineCount] 组件挂载尝试初始化WebSocket连接");
globalInstance!.initWebSocket();
} else {
console.log("[useOnlineCount] WebSocket已连接或正在连接跳过初始化");
}
});
// 组件卸载时不关闭连接,保持全局连接
onUnmounted(() => {
// 不关闭连接,让其他组件继续使用
console.log("[useOnlineCount] Component unmounted, keeping WebSocket connection");
});
}
return globalInstance;
}

View File

@@ -1,4 +1,4 @@
import { Client, IMessage, StompSubscription } from "@stomp/stompjs";
import { Client, type IMessage, type StompSubscription } from "@stomp/stompjs";
import { Auth } from "@/utils/auth";
export interface UseStompOptions {
@@ -48,13 +48,18 @@ export function useStomp(options: UseStompOptions = {}) {
const subscriptions = new Map<string, StompSubscription>();
// 用于保存 STOMP 客户端的实例
let client = ref<Client | null>(null);
const client = ref<Client | null>(null);
// 防止重复连接的标志
let isConnecting = false;
let isManualDisconnect = false;
/**
* 初始化 STOMP 客户端
*/
const initializeClient = () => {
if (client.value) {
// 如果客户端已存在且正在连接或已连接,直接返回
if (client.value && (client.value.active || client.value.connected)) {
console.log("STOMP客户端已存在且处于活动状态跳过初始化");
return;
}
@@ -73,6 +78,16 @@ export function useStomp(options: UseStompOptions = {}) {
return;
}
// 如果有旧的客户端,先清理
if (client.value) {
try {
client.value.deactivate();
} catch (error) {
console.warn("清理旧客户端时出错:", error);
}
client.value = null;
}
// 创建 STOMP 客户端
client.value = new Client({
brokerURL: brokerURL.value,
@@ -80,7 +95,7 @@ export function useStomp(options: UseStompOptions = {}) {
Authorization: `Bearer ${currentToken}`,
},
debug: options.debug ? console.log : () => {},
reconnectDelay: useExponentialBackoff ? 0 : reconnectDelay, // 禁用内置重连机制
reconnectDelay: 0, // 禁用内置重连机制,使用自定义重连
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
@@ -88,18 +103,21 @@ export function useStomp(options: UseStompOptions = {}) {
// 设置连接监听器
client.value.onConnect = () => {
isConnected.value = true;
isConnecting = false;
reconnectCount.value = 0;
clearTimeout(connectionTimeoutTimer);
clearTimeout(reconnectTimer);
console.log("WebSocket连接已建立");
};
// 设置断开连接监听器
client.value.onDisconnect = () => {
isConnected.value = false;
isConnecting = false;
console.log("WebSocket连接已断开");
// 如果使用自定义指数退避重连策略,则在这里处理
if (useExponentialBackoff && reconnectCount.value < maxReconnectAttempts) {
// 如果不是手动断开且未达到最大重连次数,则尝试重连
if (!isManualDisconnect && reconnectCount.value < maxReconnectAttempts) {
handleReconnect();
}
};
@@ -107,32 +125,31 @@ export function useStomp(options: UseStompOptions = {}) {
// 设置 Web Socket 关闭监听器
client.value.onWebSocketClose = (event) => {
isConnected.value = false;
isConnecting = false;
console.log(`WebSocket已关闭: ${event?.code} ${event?.reason}`);
// 如果是授权问题导致的关闭,尝试重新获取令牌
if (event?.code === 1000 || event?.code === 1006 || event?.code === 1008) {
console.log("可能是授权问题导致连接关闭,尝试重新建立连接");
// 如果是手动断开,不要重连
if (isManualDisconnect) {
console.log("手动断开连接,不进行重连");
return;
}
// 等待一段时间后再尝试重连,避免立即重连
setTimeout(() => {
// 强制重新初始化客户端,获取最新令牌
client.value = null;
// 如果是授权问题导致的关闭,尝试重连
if (
(event?.code === 1000 || event?.code === 1006 || event?.code === 1008) &&
reconnectCount.value < maxReconnectAttempts
) {
console.log("检测到连接异常关闭,将尝试重连");
// 检查当前是否有有效令牌
const freshToken = Auth.getAccessToken();
if (freshToken) {
initializeClient();
connect();
} else {
console.warn("没有有效令牌暂不重连WebSocket");
}
}, 3000);
// 通过 handleReconnect 统一处理重连,避免重复计数
handleReconnect();
}
};
// 设置错误监听器
client.value.onStompError = (frame) => {
console.error("STOMP错误:", frame.headers, frame.body);
isConnecting = false;
// 检查是否是授权错误
if (
@@ -141,6 +158,8 @@ export function useStomp(options: UseStompOptions = {}) {
frame.body?.includes("Token")
) {
console.warn("WebSocket授权错误请检查登录状态");
// 授权错误不进行重连
isManualDisconnect = true;
}
};
};
@@ -149,13 +168,18 @@ export function useStomp(options: UseStompOptions = {}) {
* 处理重连逻辑
*/
const handleReconnect = () => {
// 如果已经在连接中或手动断开,不重连
if (isConnecting || isManualDisconnect) {
return;
}
if (reconnectCount.value >= maxReconnectAttempts) {
console.error(`已达到最大重连次数(${maxReconnectAttempts}),停止重连`);
return;
}
reconnectCount.value++;
console.log(`尝试重连(${reconnectCount.value}/${maxReconnectAttempts})...`);
console.log(`准备重连(${reconnectCount.value}/${maxReconnectAttempts})...`);
// 使用指数退避策略增加重连间隔
const delay = useExponentialBackoff
@@ -169,8 +193,9 @@ export function useStomp(options: UseStompOptions = {}) {
// 设置重连计时器
reconnectTimer = setTimeout(() => {
if (!isConnected.value && client.value) {
client.value.activate();
if (!isConnected.value && !isManualDisconnect && !isConnecting) {
console.log(`开始重连...`);
connect();
}
}, delay);
};
@@ -195,12 +220,21 @@ export function useStomp(options: UseStompOptions = {}) {
* 激活连接(如果已经连接或正在激活则直接返回)
*/
const connect = () => {
// 重置手动断开标志
isManualDisconnect = false;
// 检查是否有配置WebSocket端点
if (!brokerURL.value) {
console.error("WebSocket连接失败: 未配置WebSocket端点URL");
return;
}
// 防止重复连接
if (isConnecting) {
console.log("WebSocket正在连接中跳过重复连接请求");
return;
}
if (!client.value) {
initializeClient();
}
@@ -210,29 +244,35 @@ export function useStomp(options: UseStompOptions = {}) {
return;
}
// 避免重复连接:检查是否已连接或正在连接
// 避免重复连接:检查是否已连接
if (client.value.connected) {
console.log("WebSocket已经连接,跳过重复连接");
isConnected.value = true;
return;
}
if (client.value.active) {
console.log("WebSocket连接正在进行中,跳过重复连接请求");
return;
}
// 设置连接标志
isConnecting = true;
// 设置连接超时
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = setTimeout(() => {
if (!isConnected.value) {
if (!isConnected.value && isConnecting) {
console.warn("WebSocket连接超时");
if (useExponentialBackoff) {
isConnecting = false;
if (!isManualDisconnect && reconnectCount.value < maxReconnectAttempts) {
handleReconnect();
}
}
}, connectionTimeout);
client.value.activate();
try {
client.value.activate();
console.log("正在建立WebSocket连接...");
} catch (error) {
console.error("激活WebSocket连接失败:", error);
isConnecting = false;
}
};
/**
@@ -276,31 +316,45 @@ export function useStomp(options: UseStompOptions = {}) {
* 断开WebSocket连接
*/
const disconnect = () => {
if (client.value && client.value.connected) {
// 清除所有订阅
for (const [id, subscription] of subscriptions.entries()) {
subscription.unsubscribe();
subscriptions.delete(id);
}
// 设置手动断开标志
isManualDisconnect = true;
// 断开连接
client.value.deactivate();
console.log("WebSocket连接已断开");
}
// 清除重连计时器
// 清除所有计时器
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// 清除连接超时计时器
if (connectionTimeoutTimer) {
clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = null;
}
// 清除所有订阅
for (const [id, subscription] of subscriptions.entries()) {
try {
subscription.unsubscribe();
} catch (error) {
console.warn(`取消订阅 ${id} 时出错:`, error);
}
}
subscriptions.clear();
// 断开连接
if (client.value) {
try {
if (client.value.connected || client.value.active) {
client.value.deactivate();
console.log("WebSocket连接已主动断开");
}
} catch (error) {
console.error("断开WebSocket连接时出错:", error);
}
client.value = null;
}
isConnected.value = false;
isConnecting = false;
reconnectCount.value = 0;
};

View File

@@ -19,7 +19,7 @@ const messages = {
const i18n = createI18n({
legacy: false,
locale: appStore.language,
messages: messages,
messages,
globalInjection: true,
});

View File

@@ -62,7 +62,6 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { ElMessageBox } from "element-plus";
import defaultSettings from "@/settings";
import { DeviceEnum } from "@/enums/settings/device.enum";
import { useAppStore, useSettingsStore, useUserStore } from "@/store";

View File

@@ -181,7 +181,7 @@ const handleLayoutChange = (layout: LayoutMode) => {
* @param findName 查找的名称
*/
function findTopLevelRoute(tree: any[], findName: string) {
let parentMap: any = {};
const parentMap: any = {};
function buildParentMap(node: any, parent: any) {
parentMap[node.name] = parent;

View File

@@ -6,19 +6,15 @@
v-for="tag in visitedViews"
ref="tagRef"
:key="tag.fullPath"
:class="'tags-item ' + (tagsViewStore.isActive(tag) ? 'active' : '')"
:class="['tags-item', { active: tagsViewStore.isActive(tag) }]"
:to="{ path: tag.path, query: tag.query }"
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent="openContentMenu(tag, $event)"
@click.middle="handleMiddleClick(tag)"
@contextmenu.prevent="(event: MouseEvent) => openContextMenu(tag, event)"
>
<!-- 标签文本 -->
<span class="tag-text">{{ translateRouteTitle(tag.title) }}</span>
<!-- 关闭按钮固定标签不显示 -->
<span
v-if="!isAffix(tag)"
class="tag-close-icon"
@click.prevent.stop="closeSelectedTag(tag)"
>
<span v-if="!tag.affix" class="tag-close-btn" @click.prevent.stop="closeSelectedTag(tag)">
×
</span>
</router-link>
@@ -26,15 +22,15 @@
<!-- 标签右键菜单 -->
<ul
v-show="contentMenuVisible"
v-show="contextMenu.visible"
class="contextmenu"
:style="{ left: left + 'px', top: top + 'px' }"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<li @click="refreshSelectedTag(selectedTag)">
<div class="i-svg:refresh" />
刷新
</li>
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
<li v-if="!selectedTag?.affix" @click="closeSelectedTag(selectedTag)">
<div class="i-svg:close" />
关闭
</li>
@@ -42,11 +38,11 @@
<div class="i-svg:close_other" />
关闭其它
</li>
<li v-if="!isFirstView()" @click="closeLeftTags">
<li v-if="!isFirstView" @click="closeLeftTags">
<div class="i-svg:close_left" />
关闭左侧
</li>
<li v-if="!isLastView()" @click="closeRightTags">
<li v-if="!isLastView" @click="closeRightTags">
<div class="i-svg:close_right" />
关闭右侧
</li>
@@ -59,366 +55,391 @@
</template>
<script setup lang="ts">
import { useRoute, useRouter, RouteRecordRaw } from "vue-router";
import { useRoute, useRouter, type RouteRecordRaw } from "vue-router";
import { resolve } from "path-browserify";
import { translateRouteTitle } from "@/utils/i18n";
import { usePermissionStore, useTagsViewStore, useSettingsStore, useAppStore } from "@/store";
// ========================= 类型定义 =========================
interface ContextMenu {
visible: boolean;
x: number;
y: number;
}
// ========================= 组合式 API =========================
const instance = getCurrentInstance();
const proxy = instance?.proxy;
const router = useRouter();
const route = useRoute();
// 权限、标签页状态管理
// 状态管理
const permissionStore = usePermissionStore();
const tagsViewStore = useTagsViewStore();
const settingsStore = useSettingsStore();
const appStore = useAppStore();
// 响应式引用访问已访问的标签视图列表
// ========================= 响应式数据 =========================
const { visitedViews } = storeToRefs(tagsViewStore);
const settingsStore = useSettingsStore();
const layout = computed(() => settingsStore.layout);
// 当前选中的标签
const selectedTag = ref<TagView>({
path: "",
fullPath: "",
name: "",
title: "",
affix: false,
keepAlive: false,
const selectedTag = ref<TagView | null>(null);
// 右键菜单状态
const contextMenu = reactive<ContextMenu>({
visible: false,
x: 0,
y: 0,
});
// 固定标签列表
const affixTags = ref<TagView[]>([]);
// 右键菜单位置
const left = ref(0);
const top = ref(0);
// 滚动条引用
const scrollbarRef = ref();
// 监听路由变化,添加标签并移动到当前标签位置
watch(
route,
() => {
addTags();
moveToCurrentTag();
},
{
immediate: true, // 初始化立即执行
}
);
// 右键菜单显示状态
const contentMenuVisible = ref(false);
// 监听右键菜单显示状态,添加或移除点击事件监听器
watch(contentMenuVisible, (value) => {
if (value) {
document.body.addEventListener("click", closeContentMenu);
} else {
document.body.removeEventListener("click", closeContentMenu);
}
});
/**
* 过滤出需要固定的标签
* @param routes 路由配置
* @param basePath 基础路径
* @returns 固定标签列表
*/
function filterAffixTags(routes: RouteRecordRaw[], basePath = "/") {
let tags: TagView[] = [];
routes.forEach((route: RouteRecordRaw) => {
const tagPath = resolve(basePath, route.path);
// 当路由设置了meta.affix属性时加入固定标签列表
if (route.meta?.affix) {
tags.push({
path: tagPath,
fullPath: tagPath,
name: String(route.name),
title: route.meta?.title || "no-name",
affix: route.meta?.affix,
keepAlive: route.meta?.keepAlive,
});
}
// 递归处理子路由
if (route.children) {
const tempTags = filterAffixTags(route.children, basePath + route.path);
if (tempTags.length >= 1) {
tags = [...tags, ...tempTags];
}
}
// ========================= 计算属性 =========================
// 路由映射缓存,提升查找性能
const routePathMap = computed(() => {
const map = new Map<string, TagView>();
visitedViews.value.forEach((tag) => {
map.set(tag.path, tag);
});
return tags;
}
return map;
});
// 判断是否为第一个标签
const isFirstView = computed(() => {
if (!selectedTag.value) return false;
return (
selectedTag.value.path === "/dashboard" ||
selectedTag.value.fullPath === visitedViews.value[1]?.fullPath
);
});
// 判断是否为最后一个标签
const isLastView = computed(() => {
if (!selectedTag.value) return false;
return selectedTag.value.fullPath === visitedViews.value[visitedViews.value.length - 1]?.fullPath;
});
// ========================= 核心函数 =========================
/**
* 递归提取固定标签
*/
const extractAffixTags = (routes: RouteRecordRaw[], basePath = "/"): TagView[] => {
const affixTags: TagView[] = [];
const traverse = (routeList: RouteRecordRaw[], currentBasePath: string) => {
routeList.forEach((route) => {
const fullPath = resolve(currentBasePath, route.path);
// 如果是固定标签,添加到列表
if (route.meta?.affix) {
affixTags.push({
path: fullPath,
fullPath,
name: String(route.name || ""),
title: route.meta.title || "no-name",
affix: true,
keepAlive: route.meta.keepAlive || false,
});
}
// 递归处理子路由
if (route.children?.length) {
traverse(route.children, fullPath);
}
});
};
traverse(routes, basePath);
return affixTags;
};
/**
* 初始化标签列表,添加需要固定的标签
* 查找路由的顶级父节点
*/
function initTags() {
const tags: TagView[] = filterAffixTags(permissionStore.routes);
affixTags.value = tags;
for (const tag of tags) {
// 必须有标签名称才添加
const findTopLevelParent = (
routes: RouteRecordRaw[],
targetName: string
): RouteRecordRaw | null => {
// 构建父子关系映射
const parentMap = new Map<string, RouteRecordRaw>();
const buildMap = (routeList: RouteRecordRaw[], parent: RouteRecordRaw | null = null) => {
routeList.forEach((route) => {
if (parent) {
parentMap.set(route.name as string, parent);
}
if (route.children?.length) {
buildMap(route.children, route);
}
});
};
buildMap(routes);
// 向上查找顶级父节点
let current = parentMap.get(targetName);
let topLevel = current;
while (current) {
const parent = parentMap.get(current.name as string);
if (!parent) break;
topLevel = current;
current = parent;
}
return topLevel || null;
};
// ========================= 标签操作 =========================
/**
* 初始化固定标签
*/
const initAffixTags = () => {
const affixTags = extractAffixTags(permissionStore.routes);
affixTags.forEach((tag) => {
if (tag.name) {
tagsViewStore.addVisitedView(tag);
}
}
}
});
};
/**
* 添加当前路由标签列表
* 添加当前路由标签
*/
function addTags() {
if (route.meta.title) {
tagsViewStore.addView({
name: route.name as string,
title: route.meta.title,
path: route.path,
fullPath: route.fullPath,
affix: route.meta?.affix,
keepAlive: route.meta?.keepAlive,
query: route.query,
});
}
}
const addCurrentTag = () => {
if (!route.meta?.title) return;
tagsViewStore.addView({
name: route.name as string,
title: route.meta.title,
path: route.path,
fullPath: route.fullPath,
affix: route.meta.affix || false,
keepAlive: route.meta.keepAlive || false,
query: route.query,
});
};
/**
* the purpose of this function is make sure to move the current active tag into the view
* 更新当前标签(优化版本)
*/
function moveToCurrentTag() {
// 使用 nextTick() 确保在更新 tagsView 组件之前滚动到正确位置
const updateCurrentTag = () => {
nextTick(() => {
for (const tag of visitedViews.value) {
if (tag.path === route.path) {
// 当查询参数不同时更新标签
if (tag.fullPath !== route.fullPath) {
tagsViewStore.updateVisitedView({
name: route.name as string,
title: route.meta.title || "",
path: route.path,
fullPath: route.fullPath,
affix: route.meta?.affix,
keepAlive: route.meta?.keepAlive,
query: route.query,
});
}
}
const currentTag = routePathMap.value.get(route.path);
if (currentTag && currentTag.fullPath !== route.fullPath) {
tagsViewStore.updateVisitedView({
name: route.name as string,
title: route.meta?.title || "",
path: route.path,
fullPath: route.fullPath,
affix: route.meta?.affix || false,
keepAlive: route.meta?.keepAlive || false,
query: route.query,
});
}
});
}
};
// ========================= 事件处理 =========================
/**
* 判断标签是否为固定标签
* @param tag 标签对象
* @returns 是否为固定标签
* 处理中键点击
*/
function isAffix(tag: TagView) {
return tag?.affix;
}
/**
* 判断选中的标签是否为第一个可见标签
* @returns 是否为第一个可见标签
*/
function isFirstView() {
return (
selectedTag.value.path === "/dashboard" ||
selectedTag.value.fullPath === tagsViewStore.visitedViews[1]?.fullPath
);
}
/**
* 判断选中的标签是否为最后一个可见标签
* @returns 是否为最后一个可见标签
*/
function isLastView() {
return (
selectedTag.value.fullPath ===
tagsViewStore.visitedViews[tagsViewStore.visitedViews.length - 1]?.fullPath
);
}
/**
* 刷新选中的标签页
* @param view 标签对象
*/
function refreshSelectedTag(view: TagView) {
tagsViewStore.delCachedView(view);
const { fullPath } = view;
nextTick(() => {
router.replace("/redirect" + fullPath);
});
}
/**
* 关闭选中的标签页
* @param view 标签对象
*/
function closeSelectedTag(view: TagView) {
tagsViewStore.delView(view).then((res: any) => {
if (tagsViewStore.isActive(view)) {
tagsViewStore.toLastView(res.visitedViews, view);
}
});
}
/**
* 关闭选中标签左侧的所有标签
*/
function closeLeftTags() {
tagsViewStore.delLeftViews(selectedTag.value).then((res: any) => {
if (!res.visitedViews.find((item: any) => item.path === route.path)) {
tagsViewStore.toLastView(res.visitedViews);
}
});
}
/**
* 关闭选中标签右侧的所有标签
*/
function closeRightTags() {
tagsViewStore.delRightViews(selectedTag.value).then((res: any) => {
if (!res.visitedViews.find((item: any) => item.path === route.path)) {
tagsViewStore.toLastView(res.visitedViews);
}
});
}
/**
* 关闭除选中标签外的所有标签
*/
function closeOtherTags() {
router.push(selectedTag.value);
tagsViewStore.delOtherViews(selectedTag.value).then(() => {
moveToCurrentTag();
});
}
/**
* 关闭所有标签
* @param view 标签对象
*/
function closeAllTags(view: TagView) {
tagsViewStore.delAllViews().then((res: any) => {
tagsViewStore.toLastView(res.visitedViews, view);
});
}
const handleMiddleClick = (tag: TagView) => {
if (!tag.affix) {
closeSelectedTag(tag);
}
};
/**
* 打开右键菜单
* @param tag 标签对象
* @param e 鼠标事件
*/
function openContentMenu(tag: TagView, e: MouseEvent) {
const menuMinWidth = 105;
const offsetLeft = proxy?.$el.getBoundingClientRect().left; // 容器左边距
const offsetWidth = proxy?.$el.offsetWidth; // 容器宽度
const maxLeft = offsetWidth - menuMinWidth; // 左边界
const leftPosition = e.clientX - offsetLeft + 15; // 15: 右边距
const openContextMenu = (tag: TagView, event: MouseEvent) => {
const MENU_MIN_WIDTH = 105;
const MENU_MARGIN = 15;
// 确保菜单不超出容器右边界
if (leftPosition > maxLeft) {
left.value = maxLeft;
} else {
left.value = leftPosition;
}
const containerRect = proxy?.$el.getBoundingClientRect();
const offsetLeft = containerRect?.left || 0;
const containerWidth = proxy?.$el.offsetWidth || 0;
// 混合模式下,需要减去顶部菜单(fixed)的高度
if (layout.value === "mix") {
top.value = e.clientY - 50;
} else {
top.value = e.clientY;
}
const maxLeft = containerWidth - MENU_MIN_WIDTH;
const leftPosition = event.clientX - offsetLeft + MENU_MARGIN;
contextMenu.x = Math.min(leftPosition, maxLeft);
contextMenu.y = layout.value === "mix" ? event.clientY - 50 : event.clientY;
contextMenu.visible = true;
contentMenuVisible.value = true;
selectedTag.value = tag;
}
};
/**
* 关闭右键菜单
*/
function closeContentMenu() {
contentMenuVisible.value = false;
}
const closeContextMenu = () => {
contextMenu.visible = false;
};
/**
* 处理鼠标滚轮事件,实现水平滚动
* 处理滚轮事件
*/
const scrollbarRef = ref();
function handleScroll(event: any) {
closeContentMenu();
// 检查是否有横向滚动条
if (scrollbarRef.value.wrapRef.scrollWidth > scrollbarRef.value.wrapRef.clientWidth) {
const wheelDelta = event.wheelDelta || 0; // 向上滚动时为120向下滚动时为-120
const scrollLeft = scrollbarRef.value.wrapRef.scrollLeft; // 当前滚动条到左边的距离
// 设置滚动条到左边的距离
scrollbarRef.value.setScrollLeft(scrollLeft - wheelDelta);
}
}
const handleScroll = (event: WheelEvent) => {
closeContextMenu();
const scrollWrapper = scrollbarRef.value?.wrapRef;
if (!scrollWrapper) return;
const hasHorizontalScroll = scrollWrapper.scrollWidth > scrollWrapper.clientWidth;
if (!hasHorizontalScroll) return;
const deltaY = event.deltaY || -(event as any).wheelDelta || 0;
const newScrollLeft = scrollWrapper.scrollLeft + deltaY;
scrollbarRef.value.setScrollLeft(newScrollLeft);
};
// ========================= 标签管理 =========================
/**
* 刷新标签
*/
const refreshSelectedTag = (tag: TagView | null) => {
if (!tag) return;
tagsViewStore.delCachedView(tag);
nextTick(() => {
router.replace("/redirect" + tag.fullPath);
});
};
/**
* 寻找最外层父节点
* @param tree 路由树
* @param findName 要查找的节点名称
* @returns 最外层父节点
* 关闭标签
*/
function findOutermostParent(tree: any[], findName: string) {
let parentMap: any = {};
const closeSelectedTag = (tag: TagView | null) => {
if (!tag) return;
function buildParentMap(node: any, parent: any) {
parentMap[node.name] = parent;
if (node.children) {
for (let i = 0; i < node.children.length; i++) {
buildParentMap(node.children[i], node);
}
tagsViewStore.delView(tag).then((result: any) => {
if (tagsViewStore.isActive(tag)) {
tagsViewStore.toLastView(result.visitedViews, tag);
}
}
for (let i = 0; i < tree.length; i++) {
buildParentMap(tree[i], null);
}
let currentNode = parentMap[findName];
while (currentNode) {
if (!parentMap[currentNode.name]) {
return currentNode;
}
currentNode = parentMap[currentNode.name];
}
return null;
}
});
};
/**
* 重新激活顶部菜单
* @param newVal 新的路由名
* 关闭左侧标签
*/
const againActiveTop = (newVal: string) => {
const closeLeftTags = () => {
if (!selectedTag.value) return;
tagsViewStore.delLeftViews(selectedTag.value).then((result: any) => {
const hasCurrentRoute = result.visitedViews.some((item: TagView) => item.path === route.path);
if (!hasCurrentRoute) {
tagsViewStore.toLastView(result.visitedViews);
}
});
};
/**
* 关闭右侧标签
*/
const closeRightTags = () => {
if (!selectedTag.value) return;
tagsViewStore.delRightViews(selectedTag.value).then((result: any) => {
const hasCurrentRoute = result.visitedViews.some((item: TagView) => item.path === route.path);
if (!hasCurrentRoute) {
tagsViewStore.toLastView(result.visitedViews);
}
});
};
/**
* 关闭其他标签
*/
const closeOtherTags = () => {
if (!selectedTag.value) return;
router.push(selectedTag.value);
tagsViewStore.delOtherViews(selectedTag.value).then(() => {
updateCurrentTag();
});
};
/**
* 关闭所有标签
*/
const closeAllTags = (tag: TagView | null) => {
tagsViewStore.delAllViews().then((result: any) => {
tagsViewStore.toLastView(result.visitedViews, tag || undefined);
});
};
// ========================= 混合布局处理 =========================
/**
* 更新顶部菜单激活状态(混合布局)
*/
const updateTopMenuActive = (routeName: string) => {
if (layout.value !== "mix") return;
const parent = findOutermostParent(permissionStore.routes, newVal);
if (appStore.activeTopMenu !== parent.path) {
appStore.activeTopMenu(parent.path);
const topParent = findTopLevelParent(permissionStore.routes, routeName);
if (topParent && appStore.activeTopMenuPath !== topParent.path) {
appStore.activeTopMenu(topParent.path);
}
};
// 如果是混合模式更改selectedTag需要对应高亮的activeTop
watch(
() => route.name,
(newVal) => {
if (newVal) {
againActiveTop(newVal as string);
// ========================= 组合式函数:右键菜单管理 =========================
const useContextMenuManager = () => {
const handleOutsideClick = () => {
closeContextMenu();
};
watchEffect(() => {
if (contextMenu.visible) {
document.addEventListener("click", handleOutsideClick);
} else {
document.removeEventListener("click", handleOutsideClick);
}
});
// 组件卸载时清理
onBeforeUnmount(() => {
document.removeEventListener("click", handleOutsideClick);
});
};
// ========================= 监听器和生命周期 =========================
// 监听路由变化
watch(
route,
() => {
addCurrentTag();
updateCurrentTag();
},
{
deep: true,
}
{ immediate: true }
);
// 组件挂载时初始化标签
// 监听路由名变化(混合布局)
watch(
() => route.name,
(newRouteName) => {
if (newRouteName) {
updateTopMenuActive(newRouteName as string);
}
},
{ deep: true }
);
// 初始化
onMounted(() => {
initTags();
initAffixTags();
});
// 启用右键菜单管理
useContextMenuManager();
</script>
<style lang="scss" scoped>
@@ -429,12 +450,10 @@ onMounted(() => {
border: 1px solid var(--el-border-color-light);
box-shadow: 0 1px 1px var(--el-box-shadow-light);
/* 滚动容器样式 */
.scroll-container {
white-space: nowrap;
}
/* 标签项样式 */
.tags-item {
position: relative;
display: inline-flex;
@@ -448,8 +467,8 @@ onMounted(() => {
color: var(--el-text-color-primary);
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
transition: all 0.2s ease;
/* 第一个和最后一个标签的边距调整 */
&:first-of-type {
margin-left: 15px;
}
@@ -457,14 +476,12 @@ onMounted(() => {
margin-right: 15px;
}
/* 标签文本样式 */
.tag-text {
display: inline-block;
vertical-align: middle;
}
/* 关闭按钮样式 */
.tag-close-icon {
.tag-close-btn {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -484,11 +501,11 @@ onMounted(() => {
}
}
/* 活动标签样式 */
&.active {
color: var(--el-color-white);
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
&::before {
position: relative;
display: inline-block;
@@ -500,8 +517,7 @@ onMounted(() => {
border-radius: 50%;
}
/* 活动标签关闭按钮样式 */
.tag-close-icon {
.tag-close-btn {
color: var(--el-color-white);
&:hover {
@@ -512,7 +528,6 @@ onMounted(() => {
}
}
/* 右键菜单样式 */
.contextmenu {
position: absolute;
z-index: 3000;
@@ -526,11 +541,15 @@ onMounted(() => {
border-radius: 4px;
box-shadow: var(--el-box-shadow-light);
/* 菜单项样式 */
li {
display: flex;
gap: 8px;
align-items: center;
padding: 7px 16px;
margin: 0;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background: var(--el-fill-color-light);
}

View File

@@ -5,73 +5,178 @@ import router from "@/router";
import { usePermissionStore, useUserStore } from "@/store";
import { ROLE_ROOT } from "@/constants";
// 路由生成锁,防止重复生成
let isGeneratingRoutes = false;
export function setupPermission() {
// 白名单路由
const whiteList = ["/login"];
router.beforeEach(async (to, from, next) => {
NProgress.start();
console.log("to.path", to.path);
console.log("🚀 Route guard triggered:", { to: to.path, from: from.path });
const isLogin = Auth.isLoggedIn();
if (isLogin) {
const isLoggedIn = Auth.isLoggedIn();
if (isLoggedIn) {
console.log("✅ User is logged in");
// 如果已登录但访问登录页,重定向到首页
if (to.path === "/login") {
// 如果已登录,跳转到首页
console.log("🔄 Redirecting from login to home");
next({ path: "/" });
} else {
// 未登录
const permissionStore = usePermissionStore();
// 判断路由是否加载完成
if (permissionStore.routesLoaded) {
if (to.matched.length === 0) {
// 路由未匹配跳转到404
next("/404");
} else {
// 动态设置页面标题
const title = (to.params.title as string) || (to.query.title as string);
if (title) {
to.meta.title = title;
}
next();
}
} else {
try {
// 生成路由
const dynamicRoutes = await permissionStore.generateRoutes();
dynamicRoutes.forEach((route: RouteRecordRaw) => router.addRoute(route));
next({ ...to, replace: true });
} catch (error) {
console.error(error);
// 路由加载失败,重置 token 并重定向到登录页
await useUserStore().resetAllState();
redirectToLogin(to, next);
NProgress.done();
}
}
return;
}
// 处理已登录用户的路由访问
await handleAuthenticatedUser(to, from, next);
} else {
// 未登录,判断是否在白名单中
console.log("❌ User not logged in");
// 未登录用户的处理
if (whiteList.includes(to.path)) {
next();
} else {
// 不在白名单,重定向到登录页
redirectToLogin(to, next);
NProgress.done();
}
}
});
// 后置守卫,保证每次路由跳转结束时关闭进度条
router.afterEach(() => {
// 后置守卫,保进度条关闭
router.afterEach((to, from) => {
console.log("✅ Route navigation completed:", { to: to.path, from: from.path });
NProgress.done();
});
}
// 重定向到登录页
/**
* 处理已登录用户的路由访问
*/
async function handleAuthenticatedUser(
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) {
const permissionStore = usePermissionStore();
const userStore = useUserStore();
try {
// 检查用户信息是否存在
if (!userStore.userInfo.username) {
console.log("🔄 User info not found, fetching...");
await userStore.getUserInfo();
}
// 检查路由是否已生成
if (!permissionStore.routesLoaded) {
console.log("🔄 Routes not loaded, generating...");
// 防止重复生成路由
if (isGeneratingRoutes) {
console.log("⏳ Routes already generating, waiting...");
// 等待当前路由生成完成
await waitForRoutesGeneration(permissionStore);
} else {
await generateAndAddRoutes(permissionStore);
}
// 路由生成完成后,重新导航到目标路由
console.log("🔄 Routes generated, redirecting to:", to.path);
next({ ...to, replace: true });
return;
}
// 路由已加载,检查路由是否存在
if (to.matched.length === 0) {
console.log("❌ Route not found, redirecting to 404");
next("/404");
return;
}
// 动态设置页面标题
const title = (to.params.title as string) || (to.query.title as string);
if (title) {
to.meta.title = title;
}
console.log("✅ Route access granted:", to.path);
next();
} catch (error) {
console.error("❌ Route guard error:", error);
// 出错时重置状态并重定向到登录页
await resetUserStateAndRedirect(to, next);
}
}
/**
* 生成并添加动态路由
*/
async function generateAndAddRoutes(permissionStore: any) {
isGeneratingRoutes = true;
try {
console.log("🔧 Generating dynamic routes...");
const dynamicRoutes = await permissionStore.generateRoutes();
// 添加路由到路由器
dynamicRoutes.forEach((route: RouteRecordRaw) => {
router.addRoute(route);
});
console.log("✅ All dynamic routes generated and added");
} finally {
isGeneratingRoutes = false;
}
}
/**
* 等待路由生成完成
*/
async function waitForRoutesGeneration(permissionStore: any): Promise<void> {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!isGeneratingRoutes && permissionStore.routesLoaded) {
clearInterval(checkInterval);
resolve();
}
}, 50); // 每50ms检查一次
// 超时保护最多等待5秒
setTimeout(() => {
clearInterval(checkInterval);
console.warn("⚠️ Routes generation timeout");
resolve();
}, 5000);
});
}
/**
* 重置用户状态并重定向到登录页
*/
async function resetUserStateAndRedirect(to: RouteLocationNormalized, next: NavigationGuardNext) {
try {
await useUserStore().resetAllState();
redirectToLogin(to, next);
} catch (resetError) {
console.error("❌ Failed to reset user state:", resetError);
// 强制跳转到登录页
next("/login");
} finally {
NProgress.done();
}
}
/**
* 重定向到登录页
*/
function redirectToLogin(to: RouteLocationNormalized, next: NavigationGuardNext) {
const params = new URLSearchParams(to.query as Record<string, string>);
const queryString = params.toString();
const redirect = queryString ? `${to.path}?${queryString}` : to.path;
console.log("🔄 Redirecting to login with redirect:", redirect);
next(`/login?redirect=${encodeURIComponent(redirect)}`);
}

View File

@@ -1,8 +1,29 @@
import { useDictSync } from "@/composables/useDictSync";
import { Auth } from "@/utils/auth";
import { useUserStore } from "@/store";
import { watch } from "vue";
// 全局 WebSocket 实例管理
const websocketInstances = new Map<string, any>();
// 用于防止重复初始化的状态标记
let isInitialized = false;
let dictWebSocketInstance: ReturnType<typeof useDictSync> | null = null;
/**
* 注册 WebSocket 实例
*/
export function registerWebSocketInstance(key: string, instance: any) {
websocketInstances.set(key, instance);
console.log(`[WebSocketPlugin] Registered WebSocket instance: ${key}`);
}
/**
* 获取 WebSocket 实例
*/
export function getWebSocketInstance(key: string) {
return websocketInstances.get(key);
}
/**
* 初始化WebSocket服务
@@ -34,19 +55,27 @@ export function setupWebSocket() {
try {
// 延迟初始化,确保应用完全启动
setTimeout(() => {
const dictWebSocket = useDictSync();
// 保存实例引用
dictWebSocketInstance = useDictSync();
registerWebSocketInstance("dictSync", dictWebSocketInstance);
// 初始化字典WebSocket服务
dictWebSocket.initWebSocket();
dictWebSocketInstance.initWebSocket();
console.log("[WebSocketPlugin] 字典WebSocket初始化完成");
// 在窗口关闭前断开WebSocket连接
window.addEventListener("beforeunload", () => {
console.log("[WebSocketPlugin] 窗口即将关闭断开WebSocket连接");
dictWebSocket.closeWebSocket();
isInitialized = false;
// 初始化在线用户计数WebSocket
import("@/composables/useOnlineCount").then(({ useOnlineCount }) => {
const onlineCountInstance = useOnlineCount({ autoInit: false });
onlineCountInstance.initWebSocket();
console.log("[WebSocketPlugin] 在线用户计数WebSocket初始化完成");
});
// 在窗口关闭前断开WebSocket连接
window.addEventListener("beforeunload", handleWindowClose);
// 监听用户注销事件
watchUserLogout();
console.log("[WebSocketPlugin] WebSocket服务初始化完成");
isInitialized = true;
}, 1000); // 延迟1秒初始化
@@ -54,3 +83,84 @@ export function setupWebSocket() {
console.error("[WebSocketPlugin] 初始化WebSocket服务失败:", error);
}
}
/**
* 处理窗口关闭
*/
function handleWindowClose() {
console.log("[WebSocketPlugin] 窗口即将关闭断开WebSocket连接");
cleanupWebSocket();
}
/**
* 监听用户注销
*/
function watchUserLogout() {
const userStore = useUserStore();
// 监听用户信息变化,当用户信息被清空时断开连接
watch(
() => userStore.userInfo,
(newUserInfo, oldUserInfo) => {
// 从有用户信息变为无用户信息,说明用户注销了
if (oldUserInfo?.username && !newUserInfo?.username) {
console.log("[WebSocketPlugin] 检测到用户注销断开WebSocket连接");
cleanupWebSocket();
}
},
{ deep: true }
);
}
/**
* 清理WebSocket连接
*/
export function cleanupWebSocket() {
// 清理字典 WebSocket
if (dictWebSocketInstance) {
try {
dictWebSocketInstance.closeWebSocket();
console.log("[WebSocketPlugin] 字典WebSocket连接已断开");
} catch (error) {
console.error("[WebSocketPlugin] 断开字典WebSocket连接失败:", error);
}
}
// 清理所有注册的 WebSocket 实例
websocketInstances.forEach((instance, key) => {
try {
if (instance && typeof instance.disconnect === "function") {
instance.disconnect();
console.log(`[WebSocketPlugin] ${key} WebSocket连接已断开`);
} else if (instance && typeof instance.closeWebSocket === "function") {
instance.closeWebSocket();
console.log(`[WebSocketPlugin] ${key} WebSocket连接已断开`);
}
} catch (error) {
console.error(`[WebSocketPlugin] 断开 ${key} WebSocket连接失败:`, error);
}
});
// 清空实例映射
websocketInstances.clear();
// 移除事件监听器
window.removeEventListener("beforeunload", handleWindowClose);
// 重置状态
dictWebSocketInstance = null;
isInitialized = false;
}
/**
* 重新初始化WebSocket用于登录后重连
*/
export function reinitializeWebSocket() {
// 先清理现有连接
cleanupWebSocket();
// 延迟后重新初始化
setTimeout(() => {
setupWebSocket();
}, 500);
}

View File

@@ -22,14 +22,24 @@ export const usePermissionStore = defineStore("permission", () => {
*/
function generateRoutes() {
return new Promise<RouteRecordRaw[]>((resolve, reject) => {
console.log("🔧 Starting to generate routes...");
MenuAPI.getRoutes()
.then((data) => {
const dynamicRoutes = parseDynamicRoutes(data);
routes.value = [...constantRoutes, ...dynamicRoutes];
routesLoaded.value = true;
console.log("✅ Routes generation completed successfully");
resolve(dynamicRoutes);
})
.catch((error) => {
console.error("❌ Failed to generate routes:", error);
// 即使失败也要设置状态,避免无限重试
routesLoaded.value = false;
reject(error);
});
});

View File

@@ -1,6 +1,6 @@
import defaultSettings from "@/settings";
import { SidebarColor, ThemeMode } from "@/enums/settings/theme.enum";
import { LayoutMode } from "@/enums/settings/layout.enum";
import type { LayoutMode } from "@/enums/settings/layout.enum";
import { applyTheme, generateThemeColors, toggleDarkMode, toggleSidebarColor } from "@/utils/theme";
type SettingsValue = boolean | string;

View File

@@ -90,6 +90,17 @@ export const useUserStore = defineStore("user", () => {
// 清除标签视图
useTagsViewStore().delAllViews();
// 3. 清理 WebSocket 连接
// 动态导入避免循环依赖
import("@/plugins/websocket")
.then(({ cleanupWebSocket }) => {
cleanupWebSocket();
console.log("[UserStore] WebSocket connections cleaned up");
})
.catch((error) => {
console.error("[UserStore] Failed to cleanup WebSocket:", error);
});
return Promise.resolve();
}

View File

@@ -2,7 +2,7 @@ declare global {
/**
* 响应数据
*/
interface ResponseData<T = any> {
interface ApiResponse<T = any> {
code: string;
data: T;
msg: string;

View File

@@ -5,102 +5,170 @@ import { ResultEnum } from "@/enums/api/result.enum";
import { Auth } from "@/utils/auth";
import router from "@/router";
// 创建 axios 实例
const service = axios.create({
/**
* 创建 HTTP 请求实例
*/
const httpRequest = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: 50000,
headers: { "Content-Type": "application/json;charset=utf-8" },
paramsSerializer: (params) => qs.stringify(params),
});
// 请求拦截器
service.interceptors.request.use(
/**
* 请求拦截器 - 添加 Authorization 头
*/
httpRequest.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken = Auth.getAccessToken();
// 如果 Authorization 设置为 no-auth则不携带 Token
if (config.headers.Authorization !== "no-auth" && accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
} else {
delete config.headers.Authorization;
}
return config;
},
(error) => Promise.reject(error)
(error) => {
console.error("Request interceptor error:", error);
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
// 如果响应是二进制流则直接返回用于下载文件、Excel 导出等
/**
* 响应拦截器 - 统一处理响应和错误
*/
httpRequest.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
// 如果响应是二进制流则直接返回用于文件下载、Excel 导出等)
if (response.config.responseType === "blob") {
return response;
}
const { code, data, msg } = response.data;
// 请求成功
if (code === ResultEnum.SUCCESS) {
return data;
}
// 业务错误
ElMessage.error(msg || "系统出错");
return Promise.reject(new Error(msg || "Error"));
return Promise.reject(new Error(msg || "Business Error"));
},
async (error) => {
console.error("request error", error); // for debug
console.error("Response interceptor error:", error);
const { config, response } = error;
if (response) {
const { code, msg } = response.data;
if (code === ResultEnum.ACCESS_TOKEN_INVALID) {
// Token 过期,刷新 Token
return handleTokenRefresh(config);
} else if (code === ResultEnum.REFRESH_TOKEN_INVALID) {
// 刷新 Token 过期,跳转登录页
await handleSessionExpired();
return Promise.reject(new Error(msg || "Error"));
} else {
ElMessage.error(msg || "系统出错");
}
// 网络错误或服务器无响应
if (!response) {
ElMessage.error("网络连接失败,请检查网络设置");
return Promise.reject(error);
}
const { code, msg } = response.data as ApiResponse;
switch (code) {
case ResultEnum.ACCESS_TOKEN_INVALID:
// Access Token 过期,尝试刷新
return refreshTokenAndRetry(config);
case ResultEnum.REFRESH_TOKEN_INVALID:
// Refresh Token 过期,跳转登录页
await redirectToLogin("登录已过期,请重新登录");
return Promise.reject(new Error(msg || "Refresh Token Invalid"));
default:
ElMessage.error(msg || "请求失败");
return Promise.reject(new Error(msg || "Request Error"));
}
return Promise.reject(error.message);
}
);
export default service;
// 是否正在刷新标识,避免重复刷新
let isRefreshing = false;
// 因 Token 过期导致的请求等待队列
const waitingQueue: Array<() => void> = [];
// 刷新 Token 处理
async function handleTokenRefresh(config: InternalAxiosRequestConfig) {
return new Promise((resolve) => {
/**
* 重试请求的回调函数类型
*/
type RetryCallback = () => void;
// Token 刷新相关状态
let isRefreshingToken = false;
const pendingRequests: RetryCallback[] = [];
/**
* 刷新 Token 并重试请求
*/
async function refreshTokenAndRetry(config: InternalAxiosRequestConfig): Promise<any> {
return new Promise((resolve, reject) => {
// 封装需要重试的请求
const retryRequest = () => {
config.headers.Authorization = `Bearer ${Auth.getAccessToken()}`;
resolve(service(config));
const newToken = Auth.getAccessToken();
if (newToken && config.headers) {
config.headers.Authorization = `Bearer ${newToken}`;
}
httpRequest(config).then(resolve).catch(reject);
};
waitingQueue.push(retryRequest);
if (!isRefreshing) {
isRefreshing = true;
// 将请求加入等待队列
pendingRequests.push(retryRequest);
// 如果没有正在刷新,则开始刷新流程
if (!isRefreshingToken) {
isRefreshingToken = true;
useUserStoreHook()
.refreshToken()
.then(() => {
// 依次重试队列中所有请求, 重试后清空队列
waitingQueue.forEach((callback) => callback());
waitingQueue.length = 0;
// 刷新成功,重试所有等待的请求
pendingRequests.forEach((callback) => {
try {
callback();
} catch (error) {
console.error("Retry request error:", error);
}
});
// 清空队列
pendingRequests.length = 0;
})
.catch(async (error) => {
console.error("handleTokenRefresh error", error);
// 刷新 Token 失败,跳转登录页
await handleSessionExpired();
console.error("Token refresh failed:", error);
// 刷新失败,清空队列并跳转登录页
pendingRequests.length = 0;
await redirectToLogin("登录状态已失效,请重新登录");
// 拒绝所有等待的请求
pendingRequests.forEach(() => {
reject(new Error("Token refresh failed"));
});
})
.finally(() => {
isRefreshing = false;
isRefreshingToken = false;
});
}
});
}
// 处理会话过期
async function handleSessionExpired() {
ElNotification({
title: "提示",
message: "您的会话已过期,请重新登录",
type: "info",
});
await useUserStoreHook().resetAllState();
router.push("/login");
/**
* 重定向到登录页面
*/
async function redirectToLogin(message: string = "请重新登录"): Promise<void> {
try {
ElNotification({
title: "提示",
message,
type: "warning",
duration: 3000,
});
await useUserStoreHook().resetAllState();
// 跳转到登录页,保留当前路由用于登录后跳转
const currentPath = router.currentRoute.value.fullPath;
await router.push(`/login?redirect=${encodeURIComponent(currentPath)}`);
} catch (error) {
console.error("Redirect to login error:", error);
}
}
export default httpRequest;

View File

@@ -547,7 +547,7 @@ const initSort = () => {
const setNodeSort = (oldIndex: number, newIndex: number) => {
// 使用arr复制一份表格数组数据
let arr = Object.assign([], genConfigFormData.value.fieldConfigs);
const arr = Object.assign([], genConfigFormData.value.fieldConfigs);
const currentRow = arr.splice(oldIndex, 1)[0];
arr.splice(newIndex, 0, currentRow);
arr.forEach((item: FieldConfig, index) => {

View File

@@ -21,7 +21,7 @@ const contentConfig: IContentConfig<UserPageQuery> = {
list: res.list,
};
},
indexAction: function (params) {
indexAction (params) {
return UserAPI.getPage(params);
},
deleteAction: UserAPI.deleteByIds,
@@ -35,7 +35,7 @@ const contentConfig: IContentConfig<UserPageQuery> = {
console.log("importsAction", data);
return Promise.resolve();
},
exportsAction: async function (params) {
async exportsAction (params) {
// 模拟获取到的是全量数据
const res = await UserAPI.getPage(params);
console.log("exportsAction", res.list);

View File

@@ -15,7 +15,7 @@ const modalConfig: IModalConfig<UserForm> = {
beforeSubmit(data) {
console.log("beforeSubmit", data);
},
formAction: function (data) {
formAction (data) {
return UserAPI.update(data.id as string, data);
},
formItems: [

View File

@@ -9,9 +9,9 @@ interface OptionType {
}
// 明确指定类型为 OptionType[]
export let deptArr = ref<OptionType[]>([]);
export let roleArr = ref<OptionType[]>([]);
export let stateArr = ref<OptionType[]>([
export const deptArr = ref<OptionType[]>([]);
export const roleArr = ref<OptionType[]>([]);
export const stateArr = ref<OptionType[]>([
{ label: "启用", value: 1 },
{ label: "禁用", value: 0 },
]);

View File

@@ -1,4 +1,4 @@
import { type UserForm } from "@/api/system/user.api";
import type { UserForm } from "@/api/system/user.api";
import type { IModalConfig } from "@/components/CURD/types";
import { deptArr } from "../config/options";

View File

@@ -7,7 +7,7 @@ const contentConfig: IContentConfig = {
},
pagePosition: "right",
toolbar: [],
indexAction: function (params) {
indexAction (params) {
// 模拟发起网络请求获取列表数据
console.log("indexAction:", params);
return Promise.resolve({

View File

@@ -15,7 +15,7 @@ const modalConfig: IModalConfig = {
beforeSubmit(data) {
console.log("beforeSubmit", data);
},
formAction: function (data) {
formAction (data) {
// return UserAPI.update(data.id as string, data);
// 模拟发起网络请求修改字段
ElMessage.success(JSON.stringify(data));

View File

@@ -35,7 +35,7 @@ const searchConfig: ISearchConfig = {
attrs: { placeholder: "全部", clearable: true },
options: stateArr as any,
events: {
change: function (e) {
change (e) {
console.log("选中的值: ", e);
},
},

View File

@@ -1,10 +1,4 @@
<!-- 字典组件示例 -->
<script setup lang="ts">
const stringValue = ref("1"); // 性别(值为String)
const numberValue = ref(1); // 性别(值为Number)
const arrayValue = ref(["1", "2"]); // 性别(值为Array)
</script>
<template>
<div class="app-container">
<el-link
@@ -46,3 +40,9 @@ const arrayValue = ref(["1", "2"]); // 性别(值为Array)
</el-form>
</div>
</template>
<script setup lang="ts">
const stringValue = ref("1"); // 性别(值为String)
const numberValue = ref(1); // 性别(值为Number)
const arrayValue = ref(["1", "2"]); // 性别(值为Array)
</script>

View File

@@ -1,11 +1,4 @@
<!-- 图标选择器示例 -->
<script setup lang="ts">
// element-plus 图标格式以el-icon-开头
const iconName = ref("el-icon-edit");
// 本地SVG图标格式取 src/assets/icons 下的文件名不需要svg后缀
// const iconName = ref("api");
</script>
<template>
<div class="app-container">
<el-link
@@ -19,3 +12,10 @@ const iconName = ref("el-icon-edit");
<icon-select v-model="iconName" />
</div>
</template>
<script setup lang="ts">
// element-plus 图标格式以el-icon-开头
const iconName = ref("el-icon-edit");
// 本地SVG图标格式取 src/assets/icons 下的文件名不需要svg后缀
// const iconName = ref("api");
</script>

View File

@@ -1,3 +1,25 @@
<template>
<div class="canvas-dom">
<h3>基于canvas实现的签名组件</h3>
<header>
<el-button type="primary" @click="handleSaveImg">保存为图片</el-button>
<el-button @click="handleToFile">保存到后端</el-button>
<el-button @click="handleClearSign">清空签名</el-button>
</header>
<canvas
ref="canvas"
height="200"
width="500"
@mousedown="onEventStart"
@mousemove.stop.prevent="onEventMove"
@mouseup="onEventEnd"
@touchstart="onEventStart"
@touchmove.stop.prevent="onEventMove"
@touchend="onEventEnd"
/>
<img v-if="imgUrl" :src="imgUrl" alt="签名" />
</div>
</template>
<script setup lang="ts">
import FileAPI from "@/api/file.api";
@@ -133,28 +155,6 @@ function paint(
ctx.stroke();
}
</script>
<template>
<div class="canvas-dom">
<h3>基于canvas实现的签名组件</h3>
<header>
<el-button type="primary" @click="handleSaveImg">保存为图片</el-button>
<el-button @click="handleToFile">保存到后端</el-button>
<el-button @click="handleClearSign">清空签名</el-button>
</header>
<canvas
ref="canvas"
height="200"
width="500"
@mousedown="onEventStart"
@mousemove.stop.prevent="onEventMove"
@mouseup="onEventEnd"
@touchstart="onEventStart"
@touchmove.stop.prevent="onEventMove"
@touchend="onEventEnd"
/>
<img v-if="imgUrl" :src="imgUrl" alt="签名" />
</div>
</template>
<style scoped lang="scss">
.canvas-dom {
width: 100%;

View File

@@ -81,7 +81,7 @@ const selectConfig: ISelectConfig = {
},
},
],
indexAction: function (params) {
indexAction (params) {
if ("createAt" in params) {
const createAt = params.createAt as string[];
if (createAt?.length > 1) {

View File

@@ -1,4 +1,27 @@
<!-- 列表选择器示例 -->
<template>
<div class="app-container">
<el-link
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/table-select/index.vue"
type="primary"
target="_blank"
class="mb-10"
>
示例源码 请点击>>>>
</el-link>
<table-select :text="text" :select-config="selectConfig" @confirm-click="handleConfirm">
<template #status="scope">
<el-tag :type="scope.row[scope.prop] == 1 ? 'success' : 'info'">
{{ scope.row[scope.prop] == 1 ? "启用" : "禁用" }}
</el-tag>
</template>
<template #gender="scope">
<DictLabel v-model="scope.row.gender" code="gender" />
</template>
</table-select>
</div>
</template>
<script setup lang="ts">
import selectConfig from "./config/select";
import { useDictStore } from "@/store";
@@ -29,26 +52,3 @@ const text = computed(() => {
: "";
});
</script>
<template>
<div class="app-container">
<el-link
href="https://gitee.com/youlaiorg/vue3-element-admin/blob/master/src/views/demo/table-select/index.vue"
type="primary"
target="_blank"
class="mb-10"
>
示例源码 请点击>>>>
</el-link>
<table-select :text="text" :select-config="selectConfig" @confirm-click="handleConfirm">
<template #status="scope">
<el-tag :type="scope.row[scope.prop] == 1 ? 'success' : 'info'">
{{ scope.row[scope.prop] == 1 ? "启用" : "禁用" }}
</el-tag>
</template>
<template #gender="scope">
<DictLabel v-model="scope.row.gender" code="gender" />
</template>
</table-select>
</div>
</template>

View File

@@ -13,7 +13,7 @@
<TextScroll type="info" text="这是一条信息类型的滚动公告" />
<!-- 自定义速度和方向 -->
<TextScroll text="这是一条速度较慢、向右滚动的公告" :speed="30" direction="right" showClose />
<TextScroll text="这是一条速度较慢、向右滚动的公告" :speed="30" direction="right" show-close />
</div>
</template>

View File

@@ -1,10 +1,4 @@
<!-- wangEditor富文本编辑器示例 -->
<script setup lang="ts">
import WangEditor from "@/components/WangEditor/index.vue";
const value = ref("初始化内容");
</script>
<template>
<div class="app-container">
<el-link
@@ -22,3 +16,9 @@ const value = ref("初始化内容");
</div>
</div>
</template>
<script setup lang="ts">
import WangEditor from "@/components/WangEditor/index.vue";
const value = ref("初始化内容");
</script>

View File

@@ -135,7 +135,7 @@ const loginFormData = ref<LoginFormData>({
password: "123456",
captchaKey: "",
captchaCode: "",
rememberMe: rememberMe,
rememberMe,
});
const loginRules = computed(() => {
@@ -195,12 +195,16 @@ async function handleLoginSubmit() {
// 2. 执行登录
await userStore.login(loginFormData.value);
// 3. 获取用户信息
// 3. 获取用户信息(包含用户角色,用于路由生成)
await userStore.getUserInfo();
// 4. 解析并跳转目标地址
// 4. 登录成功,让路由守卫处理跳转逻辑
// 解析目标地址,但不直接跳转
const redirect = resolveRedirectTarget(route.query);
await router.push(redirect);
console.log("🎉 Login successful, target redirect:", redirect);
// 通过替换当前路由触发路由守卫,让守卫处理后续的路由生成和跳转
await router.replace(redirect);
// 5. 记住我功能已实现根据用户选择决定token的存储方式:
// - 选中"记住我": token存储在localStorage中浏览器关闭后仍然有效

View File

@@ -28,6 +28,6 @@
"types": ["node", "vite/client", "element-plus/global"]
},
"include": ["mock/**/*.ts", "src/**/*.ts", "src/**/*.vue", "vite.config.ts"],
"include": ["mock/**/*.ts", "src/**/*.ts", "src/**/*.vue", "vite.config.ts", "eslint.config.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -22,6 +22,8 @@ const pathSrc = resolve(__dirname, "src");
// Vite配置 https://cn.vitejs.dev/config
export default defineConfig(({ mode }: ConfigEnv) => {
const env = loadEnv(mode, process.cwd());
const isProduction = mode === "production";
return {
resolve: {
alias: {
@@ -189,17 +191,20 @@ export default defineConfig(({ mode }: ConfigEnv) => {
// 构建配置
build: {
chunkSizeWarningLimit: 2000, // 消除打包大小超过500kb警告
minify: "terser", // Vite 2.6.x 以上需要配置 minify: "terser", terserOptions 才能生效
terserOptions: {
compress: {
keep_infinity: true, // 防止 Infinity 被压缩成 1/0这可能会导致 Chrome 上的性能问题
drop_console: true, // 生产环境去除 console
drop_debugger: true, // 生产环境去除 debugger
},
format: {
comments: false, // 删除注释
},
},
minify: isProduction ? "terser" : false, // 只在生产环境启用压缩
terserOptions: isProduction
? {
compress: {
keep_infinity: true, // 防止 Infinity 被压缩成 1/0这可能会导致 Chrome 上的性能问题
drop_console: true, // 生产环境去除 console.log, console.warn, console.error 等
drop_debugger: true, // 生产环境去除 debugger
pure_funcs: ["console.log", "console.info"], // 移除指定的函数调用
},
format: {
comments: false, // 删除注释
},
}
: {},
rollupOptions: {
output: {
// manualChunks: {