refactor: 项目重构
This commit is contained in:
@@ -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
|
||||
|
||||
151
eslint.config.ts
151
eslint.config.ts
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -33,6 +33,6 @@ const linkProps = (to: any) => {
|
||||
rel: "noopener noreferrer",
|
||||
};
|
||||
}
|
||||
return { to: to };
|
||||
return { to };
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 */
|
||||
|
||||
]);
|
||||
|
||||
// 存储表单实例
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ const queryParams = reactive<{
|
||||
[key: string]: any;
|
||||
}>({
|
||||
pageNum: 1,
|
||||
pageSize: pageSize,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
// 计算popover的宽度
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const messages = {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: appStore.language,
|
||||
messages: messages,
|
||||
messages,
|
||||
globalInjection: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
2
src/types/global.d.ts
vendored
2
src/types/global.d.ts
vendored
@@ -2,7 +2,7 @@ declare global {
|
||||
/**
|
||||
* 响应数据
|
||||
*/
|
||||
interface ResponseData<T = any> {
|
||||
interface ApiResponse<T = any> {
|
||||
code: string;
|
||||
data: T;
|
||||
msg: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const contentConfig: IContentConfig = {
|
||||
},
|
||||
pagePosition: "right",
|
||||
toolbar: [],
|
||||
indexAction: function (params) {
|
||||
indexAction (params) {
|
||||
// 模拟发起网络请求获取列表数据
|
||||
console.log("indexAction:", params);
|
||||
return Promise.resolve({
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -35,7 +35,7 @@ const searchConfig: ISearchConfig = {
|
||||
attrs: { placeholder: "全部", clearable: true },
|
||||
options: stateArr as any,
|
||||
events: {
|
||||
change: function (e) {
|
||||
change (e) {
|
||||
console.log("选中的值: ", e);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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中,浏览器关闭后仍然有效
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user