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_BASE_API=/dev-api
# 接口地址 # 接口地址
VITE_APP_API_URL=https://api.youlai.tech # 线上 # VITE_APP_API_URL=https://api.youlai.tech # 线上
# VITE_APP_API_URL=http://localhost:8989 # 本地 VITE_APP_API_URL=http://localhost:8989 # 本地
# WebSocket 端点(不配置则关闭),线上 ws://api.youlai.tech/ws ,本地 ws://localhost:8989/ws # WebSocket 端点(不配置则关闭),线上 ws://api.youlai.tech/ws ,本地 ws://localhost:8989/ws
VITE_APP_WS_ENDPOINT= VITE_APP_WS_ENDPOINT=ws://localhost:8989/ws
# 启用 Mock 服务 # 启用 Mock 服务
VITE_MOCK_DEV_SERVER=false VITE_MOCK_DEV_SERVER=false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,7 +72,7 @@ const emit = defineEmits<{
}>(); }>();
// 组件映射表 // 组件映射表
const componentMap = new Map<ISearchComponent, any>([ const componentMap = new Map<ISearchComponent, any>([
/* eslint-disable */
// @ts-ignore // @ts-ignore
["input", markRaw(ElInput)], // @ts-ignore ["input", markRaw(ElInput)], // @ts-ignore
["select", markRaw(ElSelect)], // @ts-ignore ["select", markRaw(ElSelect)], // @ts-ignore
@@ -84,7 +84,7 @@ const componentMap = new Map<ISearchComponent, any>([
["tree-select", markRaw(ElTreeSelect)], // @ts-ignore ["tree-select", markRaw(ElTreeSelect)], // @ts-ignore
["input-tag", markRaw(ElInputTag)], // @ts-ignore ["input-tag", markRaw(ElInputTag)], // @ts-ignore
["custom-tag", markRaw(InputTag)], ["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 PageContent from "./PageContent.vue";
import type PageModal from "./PageModal.vue"; import type PageModal from "./PageModal.vue";
import type PageSearch from "./PageSearch.vue"; import type PageSearch from "./PageSearch.vue";
import { CSSProperties } from "vue"; import type { CSSProperties } from "vue";
export type PageSearchInstance = InstanceType<typeof PageSearch>; export type PageSearchInstance = InstanceType<typeof PageSearch>;
export type PageContentInstance = InstanceType<typeof PageContent>; export type PageContentInstance = InstanceType<typeof PageContent>;

View File

@@ -36,12 +36,12 @@ function usePage() {
if (RefImpl) { if (RefImpl) {
RefImpl.value?.setModalVisible(); RefImpl.value?.setModalVisible();
RefImpl.value?.handleDisabled(false); 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); RefImpl.value?.setFormData(from ? from : row);
} else { } else {
editModalRef.value?.setModalVisible(); editModalRef.value?.setModalVisible();
editModalRef.value?.handleDisabled(false); 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); editModalRef.value?.setFormData(from ? from : row);
} }
} }
@@ -54,12 +54,12 @@ function usePage() {
if (RefImpl) { if (RefImpl) {
RefImpl.value?.setModalVisible(); RefImpl.value?.setModalVisible();
RefImpl.value?.handleDisabled(true); 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); RefImpl.value?.setFormData(from ? from : row);
} else { } else {
editModalRef.value?.setModalVisible(); editModalRef.value?.setModalVisible();
editModalRef.value?.handleDisabled(true); 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); editModalRef.value?.setFormData(from ? from : row);
} }
} }

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { useDictStoreHook } from "@/store/modules/dict.store"; import { useDictStoreHook } from "@/store/modules/dict.store";
import { useStomp } from "./useStomp"; import { useStomp } from "./useStomp";
import { IMessage } from "@stomp/stompjs"; import type { IMessage } from "@stomp/stompjs";
import { ref } from "vue"; import { ref } from "vue";
// 字典消息类型 // 字典消息类型
@@ -39,6 +39,9 @@ function createDictSyncHook() {
// 消息回调函数列表 // 消息回调函数列表
const messageCallbacks = ref<DictMessageCallback[]>([]); const messageCallbacks = ref<DictMessageCallback[]>([]);
// 重试定时器
let retryTimer: any = null;
/** /**
* 注册字典消息回调 * 注册字典消息回调
* @param callback 回调函数 * @param callback 回调函数
@@ -62,7 +65,7 @@ function createDictSyncHook() {
// 检查是否配置了WebSocket端点 // 检查是否配置了WebSocket端点
const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT; const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT;
if (!wsEndpoint) { if (!wsEndpoint) {
console.log("[WebSocket] 未配置WebSocket端点,跳过连接"); console.log("[DictSync] 未配置WebSocket端点,跳过连接");
return; return;
} }
@@ -72,7 +75,7 @@ function createDictSyncHook() {
// 设置字典订阅 // 设置字典订阅
setupDictSubscription(); setupDictSubscription();
} catch (error) { } catch (error) {
console.error("[WebSocket] 初始化失败:", error); console.error("[DictSync] 初始化失败:", error);
} }
}; };
@@ -80,6 +83,12 @@ function createDictSyncHook() {
* 关闭WebSocket * 关闭WebSocket
*/ */
const closeWebSocket = () => { const closeWebSocket = () => {
// 清理重试定时器
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
// 取消所有订阅 // 取消所有订阅
subscriptionIds.value.forEach((id) => { subscriptionIds.value.forEach((id) => {
unsubscribe(id); unsubscribe(id);
@@ -99,27 +108,40 @@ function createDictSyncHook() {
// 防止重复订阅 // 防止重复订阅
if (subscribedTopics.value.has(topic)) { if (subscribedTopics.value.has(topic)) {
console.log(`跳过重复订阅: ${topic}`); console.log(`[DictSync] 跳过重复订阅: ${topic}`);
return; return;
} }
console.log(`开始尝试订阅字典主题: ${topic}`); console.log(`[DictSync] 开始尝试订阅字典主题: ${topic}`);
// 使用简化的重试逻辑依赖useStomp的连接管理 // 使用简化的重试逻辑依赖useStomp的连接管理
const attemptSubscribe = () => { const attemptSubscribe = () => {
if (!isConnected.value) { if (!isConnected.value) {
console.log("等待WebSocket连接建立..."); console.log("[DictSync] 等待WebSocket连接建立...");
// 清理之前的定时器,防止重复
if (retryTimer) {
clearTimeout(retryTimer);
}
// 10秒后再次尝试 // 10秒后再次尝试
setTimeout(attemptSubscribe, 10000); retryTimer = setTimeout(() => {
retryTimer = null;
attemptSubscribe();
}, 10000);
return; return;
} }
// 清理重试定时器
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
// 检查是否已订阅 // 检查是否已订阅
if (subscribedTopics.value.has(topic)) { if (subscribedTopics.value.has(topic)) {
return; return;
} }
console.log(`连接已建立,开始订阅: ${topic}`); console.log(`[DictSync] 连接已建立,开始订阅: ${topic}`);
// 订阅字典更新 // 订阅字典更新
const subId = subscribe(topic, (message: IMessage) => { const subId = subscribe(topic, (message: IMessage) => {
@@ -129,9 +151,9 @@ function createDictSyncHook() {
if (subId) { if (subId) {
subscriptionIds.value.push(subId); subscriptionIds.value.push(subId);
subscribedTopics.value.add(topic); subscribedTopics.value.add(topic);
console.log(`字典主题订阅成功: ${topic}`); console.log(`[DictSync] 字典主题订阅成功: ${topic}`);
} else { } else {
console.warn(`字典主题订阅失败: ${topic}`); console.warn(`[DictSync] 字典主题订阅失败: ${topic}`);
} }
}; };
@@ -148,7 +170,7 @@ function createDictSyncHook() {
try { try {
// 记录接收到的消息 // 记录接收到的消息
console.log(`收到字典更新消息: ${message.body}`); console.log(`[DictSync] 收到字典更新消息: ${message.body}`);
// 尝试解析消息 // 尝试解析消息
const parsedData = JSON.parse(message.body) as DictMessage; const parsedData = JSON.parse(message.body) as DictMessage;
@@ -158,21 +180,21 @@ function createDictSyncHook() {
// 清除缓存,等待按需加载 // 清除缓存,等待按需加载
dictStore.removeDictItem(dictCode); dictStore.removeDictItem(dictCode);
console.log(`字典缓存已清除: ${dictCode}`); console.log(`[DictSync] 字典缓存已清除: ${dictCode}`);
// 调用所有注册的回调函数 // 调用所有注册的回调函数
messageCallbacks.value.forEach((callback) => { messageCallbacks.value.forEach((callback) => {
try { try {
callback(parsedData); callback(parsedData);
} catch (callbackError) { } catch (callbackError) {
console.error("[WebSocket] 回调执行失败:", callbackError); console.error("[DictSync] 回调执行失败:", callbackError);
} }
}); });
// 显示提示消息 // 显示提示消息
console.info(`字典 ${dictCode} 已变更,将在下次使用时自动加载`); console.info(`[DictSync] 字典 ${dictCode} 已变更,将在下次使用时自动加载`);
} catch (error) { } 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 { useStomp } from "./useStomp";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Storage } from "@/utils/storage"; import { registerWebSocketInstance } from "@/plugins/websocket";
import { ACCESS_TOKEN_KEY } from "@/constants/cache-keys"; import { Auth } from "@/utils/auth";
// 全局单例实例
let globalInstance: ReturnType<typeof createOnlineCountHook> | null = null;
/** /**
* 在线用户计数组合式函数 * 创建在线用户计数的核心逻辑
* 用于订阅后端推送的在线用户数量变化
*/ */
export function useOnlineCount() { function createOnlineCountHook() {
// 在线用户数量 // 在线用户数量
const onlineUserCount = ref(0); const onlineUserCount = ref(0);
@@ -22,18 +24,23 @@ export function useOnlineCount() {
const isConnecting = ref(false); const isConnecting = ref(false);
// 使用Stomp客户端 - 配置使用指数退避策略 // 使用Stomp客户端 - 配置使用指数退避策略
const stompInstance = useStomp({
reconnectDelay: 15000, // 重连基础延迟
maxReconnectAttempts: 3, // 重连次数上限
connectionTimeout: 10000, // 连接超时
useExponentialBackoff: true, // 启用指数退避
});
const { const {
connect, connect,
subscribe, subscribe,
unsubscribe, unsubscribe,
disconnect, disconnect,
isConnected: stompConnected, isConnected: stompConnected,
} = useStomp({ } = stompInstance;
reconnectDelay: 15000, // 重连基础延迟
maxReconnectAttempts: 3, // 重连次数上限 // 注册到全局实例管理器
connectionTimeout: 10000, // 连接超时 registerWebSocketInstance("onlineCount", stompInstance);
useExponentialBackoff: true, // 启用指数退避
});
// 订阅ID // 订阅ID
let subscriptionId = ""; let subscriptionId = "";
@@ -49,7 +56,7 @@ export function useOnlineCount() {
// 一旦连接成功,立即订阅主题 // 一旦连接成功,立即订阅主题
subscribeToOnlineCount(); subscribeToOnlineCount();
console.log("WebSocket连接成功已订阅在线用户计数主题"); console.log("[useOnlineCount] WebSocket连接成功已订阅在线用户计数主题");
} }
}); });
@@ -81,7 +88,7 @@ export function useOnlineCount() {
lastUpdateTime.value = Date.now(); lastUpdateTime.value = Date.now();
} }
} catch (error) { } catch (error) {
console.error("解析在线用户数量失败:", error); console.error("[useOnlineCount] 解析在线用户数量失败:", error);
} }
}); });
}; };
@@ -95,18 +102,19 @@ export function useOnlineCount() {
// 检查WebSocket端点是否配置 // 检查WebSocket端点是否配置
const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT; const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT;
if (!wsEndpoint) { if (!wsEndpoint) {
console.log("未配置WebSocket端点(VITE_APP_WS_ENDPOINT),跳过WebSocket连接"); console.log("[useOnlineCount] 未配置WebSocket端点(VITE_APP_WS_ENDPOINT),跳过WebSocket连接");
return; return;
} }
// 检查是否有可用的令牌 // 使用 Auth.getAccessToken() 获取令牌,确保获取到最新的
const hasToken = !!Storage.get(ACCESS_TOKEN_KEY, ""); const accessToken = Auth.getAccessToken();
if (!hasToken) { if (!accessToken) {
console.log("没有检测到有效令牌不尝试WebSocket连接"); console.log("[useOnlineCount] 没有检测到有效令牌不尝试WebSocket连接");
return; return;
} }
isConnecting.value = true; isConnecting.value = true;
console.log("[useOnlineCount] 开始建立WebSocket连接...");
// 连接WebSocket // 连接WebSocket
connect(); connect();
@@ -115,17 +123,17 @@ export function useOnlineCount() {
clearTimeout(connectionTimeoutTimer); clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = setTimeout(() => { connectionTimeoutTimer = setTimeout(() => {
if (!isConnected.value) { if (!isConnected.value) {
console.warn("WebSocket连接超时将自动尝试重连"); console.warn("[useOnlineCount] WebSocket连接超时将自动尝试重连");
ElMessage.warning("正在尝试连接服务器,请稍候..."); ElMessage.warning("正在尝试连接服务器,请稍候...");
// 超时后尝试重新连接 // 超时后尝试重新连接
closeWebSocket(); closeWebSocket();
setTimeout(() => { setTimeout(() => {
// 再次检查令牌有效性 // 再次检查令牌有效性
if (Storage.get(ACCESS_TOKEN_KEY, "")) { if (Auth.getAccessToken()) {
initWebSocket(); initWebSocket();
} else { } else {
console.log("令牌无效,放弃重连"); console.log("[useOnlineCount] 令牌无效,放弃重连");
} }
}, 3000); }, 3000);
} }
@@ -160,16 +168,6 @@ export function useOnlineCount() {
isConnecting.value = false; isConnecting.value = false;
}; };
// 组件挂载时初始化WebSocket
onMounted(() => {
initWebSocket();
});
// 组件卸载时关闭WebSocket
onUnmounted(() => {
closeWebSocket();
});
return { return {
onlineUserCount, onlineUserCount,
lastUpdateTime, lastUpdateTime,
@@ -179,3 +177,39 @@ export function useOnlineCount() {
closeWebSocket, 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"; import { Auth } from "@/utils/auth";
export interface UseStompOptions { export interface UseStompOptions {
@@ -48,13 +48,18 @@ export function useStomp(options: UseStompOptions = {}) {
const subscriptions = new Map<string, StompSubscription>(); const subscriptions = new Map<string, StompSubscription>();
// 用于保存 STOMP 客户端的实例 // 用于保存 STOMP 客户端的实例
let client = ref<Client | null>(null); const client = ref<Client | null>(null);
// 防止重复连接的标志
let isConnecting = false;
let isManualDisconnect = false;
/** /**
* 初始化 STOMP 客户端 * 初始化 STOMP 客户端
*/ */
const initializeClient = () => { const initializeClient = () => {
if (client.value) { // 如果客户端已存在且正在连接或已连接,直接返回
if (client.value && (client.value.active || client.value.connected)) {
console.log("STOMP客户端已存在且处于活动状态跳过初始化");
return; return;
} }
@@ -73,6 +78,16 @@ export function useStomp(options: UseStompOptions = {}) {
return; return;
} }
// 如果有旧的客户端,先清理
if (client.value) {
try {
client.value.deactivate();
} catch (error) {
console.warn("清理旧客户端时出错:", error);
}
client.value = null;
}
// 创建 STOMP 客户端 // 创建 STOMP 客户端
client.value = new Client({ client.value = new Client({
brokerURL: brokerURL.value, brokerURL: brokerURL.value,
@@ -80,7 +95,7 @@ export function useStomp(options: UseStompOptions = {}) {
Authorization: `Bearer ${currentToken}`, Authorization: `Bearer ${currentToken}`,
}, },
debug: options.debug ? console.log : () => {}, debug: options.debug ? console.log : () => {},
reconnectDelay: useExponentialBackoff ? 0 : reconnectDelay, // 禁用内置重连机制 reconnectDelay: 0, // 禁用内置重连机制,使用自定义重连
heartbeatIncoming: 4000, heartbeatIncoming: 4000,
heartbeatOutgoing: 4000, heartbeatOutgoing: 4000,
}); });
@@ -88,18 +103,21 @@ export function useStomp(options: UseStompOptions = {}) {
// 设置连接监听器 // 设置连接监听器
client.value.onConnect = () => { client.value.onConnect = () => {
isConnected.value = true; isConnected.value = true;
isConnecting = false;
reconnectCount.value = 0; reconnectCount.value = 0;
clearTimeout(connectionTimeoutTimer); clearTimeout(connectionTimeoutTimer);
clearTimeout(reconnectTimer);
console.log("WebSocket连接已建立"); console.log("WebSocket连接已建立");
}; };
// 设置断开连接监听器 // 设置断开连接监听器
client.value.onDisconnect = () => { client.value.onDisconnect = () => {
isConnected.value = false; isConnected.value = false;
isConnecting = false;
console.log("WebSocket连接已断开"); console.log("WebSocket连接已断开");
// 如果使用自定义指数退避重连策略,则在这里处理 // 如果不是手动断开且未达到最大重连次数,则尝试重连
if (useExponentialBackoff && reconnectCount.value < maxReconnectAttempts) { if (!isManualDisconnect && reconnectCount.value < maxReconnectAttempts) {
handleReconnect(); handleReconnect();
} }
}; };
@@ -107,32 +125,31 @@ export function useStomp(options: UseStompOptions = {}) {
// 设置 Web Socket 关闭监听器 // 设置 Web Socket 关闭监听器
client.value.onWebSocketClose = (event) => { client.value.onWebSocketClose = (event) => {
isConnected.value = false; isConnected.value = false;
isConnecting = false;
console.log(`WebSocket已关闭: ${event?.code} ${event?.reason}`); console.log(`WebSocket已关闭: ${event?.code} ${event?.reason}`);
// 如果是授权问题导致的关闭,尝试重新获取令牌 // 如果是手动断开,不要重连
if (event?.code === 1000 || event?.code === 1006 || event?.code === 1008) { if (isManualDisconnect) {
console.log("可能是授权问题导致连接关闭,尝试重新建立连接"); console.log("手动断开连接,不进行重连");
return;
}
// 等待一段时间后再尝试重连,避免立即重连 // 如果是授权问题导致的关闭,尝试重连
setTimeout(() => { if (
// 强制重新初始化客户端,获取最新令牌 (event?.code === 1000 || event?.code === 1006 || event?.code === 1008) &&
client.value = null; reconnectCount.value < maxReconnectAttempts
) {
console.log("检测到连接异常关闭,将尝试重连");
// 检查当前是否有有效令牌 // 通过 handleReconnect 统一处理重连,避免重复计数
const freshToken = Auth.getAccessToken(); handleReconnect();
if (freshToken) {
initializeClient();
connect();
} else {
console.warn("没有有效令牌暂不重连WebSocket");
}
}, 3000);
} }
}; };
// 设置错误监听器 // 设置错误监听器
client.value.onStompError = (frame) => { client.value.onStompError = (frame) => {
console.error("STOMP错误:", frame.headers, frame.body); console.error("STOMP错误:", frame.headers, frame.body);
isConnecting = false;
// 检查是否是授权错误 // 检查是否是授权错误
if ( if (
@@ -141,6 +158,8 @@ export function useStomp(options: UseStompOptions = {}) {
frame.body?.includes("Token") frame.body?.includes("Token")
) { ) {
console.warn("WebSocket授权错误请检查登录状态"); console.warn("WebSocket授权错误请检查登录状态");
// 授权错误不进行重连
isManualDisconnect = true;
} }
}; };
}; };
@@ -149,13 +168,18 @@ export function useStomp(options: UseStompOptions = {}) {
* 处理重连逻辑 * 处理重连逻辑
*/ */
const handleReconnect = () => { const handleReconnect = () => {
// 如果已经在连接中或手动断开,不重连
if (isConnecting || isManualDisconnect) {
return;
}
if (reconnectCount.value >= maxReconnectAttempts) { if (reconnectCount.value >= maxReconnectAttempts) {
console.error(`已达到最大重连次数(${maxReconnectAttempts}),停止重连`); console.error(`已达到最大重连次数(${maxReconnectAttempts}),停止重连`);
return; return;
} }
reconnectCount.value++; reconnectCount.value++;
console.log(`尝试重连(${reconnectCount.value}/${maxReconnectAttempts})...`); console.log(`准备重连(${reconnectCount.value}/${maxReconnectAttempts})...`);
// 使用指数退避策略增加重连间隔 // 使用指数退避策略增加重连间隔
const delay = useExponentialBackoff const delay = useExponentialBackoff
@@ -169,8 +193,9 @@ export function useStomp(options: UseStompOptions = {}) {
// 设置重连计时器 // 设置重连计时器
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(() => {
if (!isConnected.value && client.value) { if (!isConnected.value && !isManualDisconnect && !isConnecting) {
client.value.activate(); console.log(`开始重连...`);
connect();
} }
}, delay); }, delay);
}; };
@@ -195,12 +220,21 @@ export function useStomp(options: UseStompOptions = {}) {
* 激活连接(如果已经连接或正在激活则直接返回) * 激活连接(如果已经连接或正在激活则直接返回)
*/ */
const connect = () => { const connect = () => {
// 重置手动断开标志
isManualDisconnect = false;
// 检查是否有配置WebSocket端点 // 检查是否有配置WebSocket端点
if (!brokerURL.value) { if (!brokerURL.value) {
console.error("WebSocket连接失败: 未配置WebSocket端点URL"); console.error("WebSocket连接失败: 未配置WebSocket端点URL");
return; return;
} }
// 防止重复连接
if (isConnecting) {
console.log("WebSocket正在连接中跳过重复连接请求");
return;
}
if (!client.value) { if (!client.value) {
initializeClient(); initializeClient();
} }
@@ -210,29 +244,35 @@ export function useStomp(options: UseStompOptions = {}) {
return; return;
} }
// 避免重复连接:检查是否已连接或正在连接 // 避免重复连接:检查是否已连接
if (client.value.connected) { if (client.value.connected) {
console.log("WebSocket已经连接,跳过重复连接"); console.log("WebSocket已经连接,跳过重复连接");
isConnected.value = true;
return; return;
} }
if (client.value.active) { // 设置连接标志
console.log("WebSocket连接正在进行中,跳过重复连接请求"); isConnecting = true;
return;
}
// 设置连接超时 // 设置连接超时
clearTimeout(connectionTimeoutTimer); clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = setTimeout(() => { connectionTimeoutTimer = setTimeout(() => {
if (!isConnected.value) { if (!isConnected.value && isConnecting) {
console.warn("WebSocket连接超时"); console.warn("WebSocket连接超时");
if (useExponentialBackoff) { isConnecting = false;
if (!isManualDisconnect && reconnectCount.value < maxReconnectAttempts) {
handleReconnect(); handleReconnect();
} }
} }
}, connectionTimeout); }, 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连接 * 断开WebSocket连接
*/ */
const disconnect = () => { const disconnect = () => {
if (client.value && client.value.connected) { // 设置手动断开标志
// 清除所有订阅 isManualDisconnect = true;
for (const [id, subscription] of subscriptions.entries()) {
subscription.unsubscribe();
subscriptions.delete(id);
}
// 断开连接 // 清除所有计时器
client.value.deactivate();
console.log("WebSocket连接已断开");
}
// 清除重连计时器
if (reconnectTimer) { if (reconnectTimer) {
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer);
reconnectTimer = null; reconnectTimer = null;
} }
// 清除连接超时计时器
if (connectionTimeoutTimer) { if (connectionTimeoutTimer) {
clearTimeout(connectionTimeoutTimer); clearTimeout(connectionTimeoutTimer);
connectionTimeoutTimer = null; 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; isConnected.value = false;
isConnecting = false;
reconnectCount.value = 0; reconnectCount.value = 0;
}; };

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,73 +5,178 @@ import router from "@/router";
import { usePermissionStore, useUserStore } from "@/store"; import { usePermissionStore, useUserStore } from "@/store";
import { ROLE_ROOT } from "@/constants"; import { ROLE_ROOT } from "@/constants";
// 路由生成锁,防止重复生成
let isGeneratingRoutes = false;
export function setupPermission() { export function setupPermission() {
// 白名单路由 // 白名单路由
const whiteList = ["/login"]; const whiteList = ["/login"];
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
NProgress.start(); NProgress.start();
console.log("to.path", to.path); console.log("🚀 Route guard triggered:", { to: to.path, from: from.path });
const isLogin = Auth.isLoggedIn(); const isLoggedIn = Auth.isLoggedIn();
if (isLogin) {
if (isLoggedIn) {
console.log("✅ User is logged in");
// 如果已登录但访问登录页,重定向到首页
if (to.path === "/login") { if (to.path === "/login") {
// 如果已登录,跳转到首页 console.log("🔄 Redirecting from login to home");
next({ path: "/" }); next({ path: "/" });
} else { return;
// 未登录
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();
}
}
} }
// 处理已登录用户的路由访问
await handleAuthenticatedUser(to, from, next);
} else { } else {
// 未登录,判断是否在白名单中 console.log("❌ User not logged in");
// 未登录用户的处理
if (whiteList.includes(to.path)) { if (whiteList.includes(to.path)) {
next(); next();
} else { } else {
// 不在白名单,重定向到登录页
redirectToLogin(to, next); redirectToLogin(to, next);
NProgress.done(); NProgress.done();
} }
} }
}); });
// 后置守卫,保证每次路由跳转结束时关闭进度条 // 后置守卫,保进度条关闭
router.afterEach(() => { router.afterEach((to, from) => {
console.log("✅ Route navigation completed:", { to: to.path, from: from.path });
NProgress.done(); 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) { function redirectToLogin(to: RouteLocationNormalized, next: NavigationGuardNext) {
const params = new URLSearchParams(to.query as Record<string, string>); const params = new URLSearchParams(to.query as Record<string, string>);
const queryString = params.toString(); const queryString = params.toString();
const redirect = queryString ? `${to.path}?${queryString}` : to.path; const redirect = queryString ? `${to.path}?${queryString}` : to.path;
console.log("🔄 Redirecting to login with redirect:", redirect);
next(`/login?redirect=${encodeURIComponent(redirect)}`); next(`/login?redirect=${encodeURIComponent(redirect)}`);
} }

View File

@@ -1,8 +1,29 @@
import { useDictSync } from "@/composables/useDictSync"; import { useDictSync } from "@/composables/useDictSync";
import { Auth } from "@/utils/auth"; import { Auth } from "@/utils/auth";
import { useUserStore } from "@/store";
import { watch } from "vue";
// 全局 WebSocket 实例管理
const websocketInstances = new Map<string, any>();
// 用于防止重复初始化的状态标记 // 用于防止重复初始化的状态标记
let isInitialized = false; 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服务 * 初始化WebSocket服务
@@ -34,19 +55,27 @@ export function setupWebSocket() {
try { try {
// 延迟初始化,确保应用完全启动 // 延迟初始化,确保应用完全启动
setTimeout(() => { setTimeout(() => {
const dictWebSocket = useDictSync(); // 保存实例引用
dictWebSocketInstance = useDictSync();
registerWebSocketInstance("dictSync", dictWebSocketInstance);
// 初始化字典WebSocket服务 // 初始化字典WebSocket服务
dictWebSocket.initWebSocket(); dictWebSocketInstance.initWebSocket();
console.log("[WebSocketPlugin] 字典WebSocket初始化完成"); console.log("[WebSocketPlugin] 字典WebSocket初始化完成");
// 在窗口关闭前断开WebSocket连接 // 初始化在线用户计数WebSocket
window.addEventListener("beforeunload", () => { import("@/composables/useOnlineCount").then(({ useOnlineCount }) => {
console.log("[WebSocketPlugin] 窗口即将关闭断开WebSocket连接"); const onlineCountInstance = useOnlineCount({ autoInit: false });
dictWebSocket.closeWebSocket(); onlineCountInstance.initWebSocket();
isInitialized = false; console.log("[WebSocketPlugin] 在线用户计数WebSocket初始化完成");
}); });
// 在窗口关闭前断开WebSocket连接
window.addEventListener("beforeunload", handleWindowClose);
// 监听用户注销事件
watchUserLogout();
console.log("[WebSocketPlugin] WebSocket服务初始化完成"); console.log("[WebSocketPlugin] WebSocket服务初始化完成");
isInitialized = true; isInitialized = true;
}, 1000); // 延迟1秒初始化 }, 1000); // 延迟1秒初始化
@@ -54,3 +83,84 @@ export function setupWebSocket() {
console.error("[WebSocketPlugin] 初始化WebSocket服务失败:", error); 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() { function generateRoutes() {
return new Promise<RouteRecordRaw[]>((resolve, reject) => { return new Promise<RouteRecordRaw[]>((resolve, reject) => {
console.log("🔧 Starting to generate routes...");
MenuAPI.getRoutes() MenuAPI.getRoutes()
.then((data) => { .then((data) => {
const dynamicRoutes = parseDynamicRoutes(data); const dynamicRoutes = parseDynamicRoutes(data);
routes.value = [...constantRoutes, ...dynamicRoutes]; routes.value = [...constantRoutes, ...dynamicRoutes];
routesLoaded.value = true; routesLoaded.value = true;
console.log("✅ Routes generation completed successfully");
resolve(dynamicRoutes); resolve(dynamicRoutes);
}) })
.catch((error) => { .catch((error) => {
console.error("❌ Failed to generate routes:", error);
// 即使失败也要设置状态,避免无限重试
routesLoaded.value = false;
reject(error); reject(error);
}); });
}); });

View File

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

View File

@@ -90,6 +90,17 @@ export const useUserStore = defineStore("user", () => {
// 清除标签视图 // 清除标签视图
useTagsViewStore().delAllViews(); 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(); return Promise.resolve();
} }

View File

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

View File

@@ -5,102 +5,170 @@ import { ResultEnum } from "@/enums/api/result.enum";
import { Auth } from "@/utils/auth"; import { Auth } from "@/utils/auth";
import router from "@/router"; import router from "@/router";
// 创建 axios 实例 /**
const service = axios.create({ * 创建 HTTP 请求实例
*/
const httpRequest = axios.create({
baseURL: import.meta.env.VITE_APP_BASE_API, baseURL: import.meta.env.VITE_APP_BASE_API,
timeout: 50000, timeout: 50000,
headers: { "Content-Type": "application/json;charset=utf-8" }, headers: { "Content-Type": "application/json;charset=utf-8" },
paramsSerializer: (params) => qs.stringify(params), paramsSerializer: (params) => qs.stringify(params),
}); });
// 请求拦截器 /**
service.interceptors.request.use( * 请求拦截器 - 添加 Authorization 头
*/
httpRequest.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const accessToken = Auth.getAccessToken(); const accessToken = Auth.getAccessToken();
// 如果 Authorization 设置为 no-auth则不携带 Token // 如果 Authorization 设置为 no-auth则不携带 Token
if (config.headers.Authorization !== "no-auth" && accessToken) { if (config.headers.Authorization !== "no-auth" && accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`; config.headers.Authorization = `Bearer ${accessToken}`;
} else { } else {
delete config.headers.Authorization; delete config.headers.Authorization;
} }
return config; 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") { if (response.config.responseType === "blob") {
return response; return response;
} }
const { code, data, msg } = response.data; const { code, data, msg } = response.data;
// 请求成功
if (code === ResultEnum.SUCCESS) { if (code === ResultEnum.SUCCESS) {
return data; return data;
} }
// 业务错误
ElMessage.error(msg || "系统出错"); ElMessage.error(msg || "系统出错");
return Promise.reject(new Error(msg || "Error")); return Promise.reject(new Error(msg || "Business Error"));
}, },
async (error) => { async (error) => {
console.error("request error", error); // for debug console.error("Response interceptor error:", error);
const { config, response } = error; const { config, response } = error;
if (response) {
const { code, msg } = response.data; // 网络错误或服务器无响应
if (code === ResultEnum.ACCESS_TOKEN_INVALID) { if (!response) {
// Token 过期,刷新 Token ElMessage.error("网络连接失败,请检查网络设置");
return handleTokenRefresh(config); return Promise.reject(error);
} else if (code === ResultEnum.REFRESH_TOKEN_INVALID) { }
// 刷新 Token 过期,跳转登录页
await handleSessionExpired(); const { code, msg } = response.data as ApiResponse;
return Promise.reject(new Error(msg || "Error"));
} else { switch (code) {
ElMessage.error(msg || "系统出错"); 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> = []; type RetryCallback = () => void;
// 刷新 Token 处理
async function handleTokenRefresh(config: InternalAxiosRequestConfig) { // Token 刷新相关状态
return new Promise((resolve) => { let isRefreshingToken = false;
const pendingRequests: RetryCallback[] = [];
/**
* 刷新 Token 并重试请求
*/
async function refreshTokenAndRetry(config: InternalAxiosRequestConfig): Promise<any> {
return new Promise((resolve, reject) => {
// 封装需要重试的请求 // 封装需要重试的请求
const retryRequest = () => { const retryRequest = () => {
config.headers.Authorization = `Bearer ${Auth.getAccessToken()}`; const newToken = Auth.getAccessToken();
resolve(service(config)); 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() useUserStoreHook()
.refreshToken() .refreshToken()
.then(() => { .then(() => {
// 依次重试队列中所有请求, 重试后清空队列 // 刷新成功,重试所有等待的请求
waitingQueue.forEach((callback) => callback()); pendingRequests.forEach((callback) => {
waitingQueue.length = 0; try {
callback();
} catch (error) {
console.error("Retry request error:", error);
}
});
// 清空队列
pendingRequests.length = 0;
}) })
.catch(async (error) => { .catch(async (error) => {
console.error("handleTokenRefresh error", error); console.error("Token refresh failed:", error);
// 刷新 Token 失败,跳转登录页 // 刷新失败,清空队列并跳转登录页
await handleSessionExpired(); pendingRequests.length = 0;
await redirectToLogin("登录状态已失效,请重新登录");
// 拒绝所有等待的请求
pendingRequests.forEach(() => {
reject(new Error("Token refresh failed"));
});
}) })
.finally(() => { .finally(() => {
isRefreshing = false; isRefreshingToken = false;
}); });
} }
}); });
} }
// 处理会话过期
async function handleSessionExpired() { /**
ElNotification({ * 重定向到登录页面
title: "提示", */
message: "您的会话已过期,请重新登录", async function redirectToLogin(message: string = "请重新登录"): Promise<void> {
type: "info", try {
}); ElNotification({
await useUserStoreHook().resetAllState(); title: "提示",
router.push("/login"); 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) => { const setNodeSort = (oldIndex: number, newIndex: number) => {
// 使用arr复制一份表格数组数据 // 使用arr复制一份表格数组数据
let arr = Object.assign([], genConfigFormData.value.fieldConfigs); const arr = Object.assign([], genConfigFormData.value.fieldConfigs);
const currentRow = arr.splice(oldIndex, 1)[0]; const currentRow = arr.splice(oldIndex, 1)[0];
arr.splice(newIndex, 0, currentRow); arr.splice(newIndex, 0, currentRow);
arr.forEach((item: FieldConfig, index) => { arr.forEach((item: FieldConfig, index) => {

View File

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

View File

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

View File

@@ -9,9 +9,9 @@ interface OptionType {
} }
// 明确指定类型为 OptionType[] // 明确指定类型为 OptionType[]
export let deptArr = ref<OptionType[]>([]); export const deptArr = ref<OptionType[]>([]);
export let roleArr = ref<OptionType[]>([]); export const roleArr = ref<OptionType[]>([]);
export let stateArr = ref<OptionType[]>([ export const stateArr = ref<OptionType[]>([
{ label: "启用", value: 1 }, { label: "启用", value: 1 },
{ label: "禁用", value: 0 }, { 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 type { IModalConfig } from "@/components/CURD/types";
import { deptArr } from "../config/options"; import { deptArr } from "../config/options";

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ const searchConfig: ISearchConfig = {
attrs: { placeholder: "全部", clearable: true }, attrs: { placeholder: "全部", clearable: true },
options: stateArr as any, options: stateArr as any,
events: { events: {
change: function (e) { change (e) {
console.log("选中的值: ", 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> <template>
<div class="app-container"> <div class="app-container">
<el-link <el-link
@@ -46,3 +40,9 @@ const arrayValue = ref(["1", "2"]); // 性别(值为Array)
</el-form> </el-form>
</div> </div>
</template> </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> <template>
<div class="app-container"> <div class="app-container">
<el-link <el-link
@@ -19,3 +12,10 @@ const iconName = ref("el-icon-edit");
<icon-select v-model="iconName" /> <icon-select v-model="iconName" />
</div> </div>
</template> </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"> <script setup lang="ts">
import FileAPI from "@/api/file.api"; import FileAPI from "@/api/file.api";
@@ -133,28 +155,6 @@ function paint(
ctx.stroke(); ctx.stroke();
} }
</script> </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"> <style scoped lang="scss">
.canvas-dom { .canvas-dom {
width: 100%; width: 100%;

View File

@@ -81,7 +81,7 @@ const selectConfig: ISelectConfig = {
}, },
}, },
], ],
indexAction: function (params) { indexAction (params) {
if ("createAt" in params) { if ("createAt" in params) {
const createAt = params.createAt as string[]; const createAt = params.createAt as string[];
if (createAt?.length > 1) { 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"> <script setup lang="ts">
import selectConfig from "./config/select"; import selectConfig from "./config/select";
import { useDictStore } from "@/store"; import { useDictStore } from "@/store";
@@ -29,26 +52,3 @@ const text = computed(() => {
: ""; : "";
}); });
</script> </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 type="info" text="这是一条信息类型的滚动公告" />
<!-- 自定义速度和方向 --> <!-- 自定义速度和方向 -->
<TextScroll text="这是一条速度较慢、向右滚动的公告" :speed="30" direction="right" showClose /> <TextScroll text="这是一条速度较慢、向右滚动的公告" :speed="30" direction="right" show-close />
</div> </div>
</template> </template>

View File

@@ -1,10 +1,4 @@
<!-- wangEditor富文本编辑器示例 --> <!-- wangEditor富文本编辑器示例 -->
<script setup lang="ts">
import WangEditor from "@/components/WangEditor/index.vue";
const value = ref("初始化内容");
</script>
<template> <template>
<div class="app-container"> <div class="app-container">
<el-link <el-link
@@ -22,3 +16,9 @@ const value = ref("初始化内容");
</div> </div>
</div> </div>
</template> </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", password: "123456",
captchaKey: "", captchaKey: "",
captchaCode: "", captchaCode: "",
rememberMe: rememberMe, rememberMe,
}); });
const loginRules = computed(() => { const loginRules = computed(() => {
@@ -195,12 +195,16 @@ async function handleLoginSubmit() {
// 2. 执行登录 // 2. 执行登录
await userStore.login(loginFormData.value); await userStore.login(loginFormData.value);
// 3. 获取用户信息 // 3. 获取用户信息(包含用户角色,用于路由生成)
await userStore.getUserInfo(); await userStore.getUserInfo();
// 4. 解析并跳转目标地址 // 4. 登录成功,让路由守卫处理跳转逻辑
// 解析目标地址,但不直接跳转
const redirect = resolveRedirectTarget(route.query); const redirect = resolveRedirectTarget(route.query);
await router.push(redirect); console.log("🎉 Login successful, target redirect:", redirect);
// 通过替换当前路由触发路由守卫,让守卫处理后续的路由生成和跳转
await router.replace(redirect);
// 5. 记住我功能已实现根据用户选择决定token的存储方式: // 5. 记住我功能已实现根据用户选择决定token的存储方式:
// - 选中"记住我": token存储在localStorage中浏览器关闭后仍然有效 // - 选中"记住我": token存储在localStorage中浏览器关闭后仍然有效

View File

@@ -28,6 +28,6 @@
"types": ["node", "vite/client", "element-plus/global"] "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"] "exclude": ["node_modules", "dist"]
} }

View File

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