chore: 移除单元测试、升级Vite8的配置和依赖、css目录重构和WebSocket 迁移到 SSE 实现实时推送
This commit is contained in:
@@ -6,14 +6,12 @@ VITE_APP_TITLE=vue3-element-admin
|
||||
VITE_APP_BASE_API=/dev-api
|
||||
|
||||
# 接口地址
|
||||
VITE_APP_API_URL=https://api.youlai.tech # 线上
|
||||
# VITE_APP_API_URL=https://api.youlai.tech # 线上
|
||||
# VITE_APP_API_URL=https://api.youlai.tech/v2 # 线上(多租户)
|
||||
# VITE_APP_API_URL=http://localhost:8000 # 本地
|
||||
VITE_APP_API_URL=http://localhost:8000 # 本地
|
||||
|
||||
# WebSocket 端点(不配置则关闭)
|
||||
# 线上: ws://api.youlai.tech/ws
|
||||
# 本地: ws://localhost:8000/ws
|
||||
VITE_APP_WS_ENDPOINT=
|
||||
# SSE 端点(默认 /api/v1/sse/connect)
|
||||
# VITE_APP_SSE_ENDPOINT=/api/v1/sse/connect
|
||||
|
||||
|
||||
# 启用 Mock 服务(true:开启 false:关闭)
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
VITE_APP_BASE_API = '/prod-api'
|
||||
# 项目名称
|
||||
VITE_APP_TITLE=vue3-element-admin
|
||||
# WebSocket 端点(可选)
|
||||
#VITE_APP_WS_ENDPOINT=wss://api.youlai.tech/ws
|
||||
# SSE 端点(使用 VITE_APP_BASE_API 前缀自动拼接)
|
||||
# 示例:/prod-api/api/v1/sse/connect
|
||||
|
||||
# ============================================
|
||||
# 🎛️ 功能开关
|
||||
|
||||
@@ -19,6 +19,13 @@ module.exports = {
|
||||
files: ["**/*.{css,scss}"],
|
||||
customSyntax: "postcss-scss",
|
||||
},
|
||||
{
|
||||
// :export 是 CSS Modules 导出语法,禁用属性检查
|
||||
files: ["**/variables.module.scss"],
|
||||
rules: {
|
||||
"property-no-unknown": null,
|
||||
},
|
||||
},
|
||||
],
|
||||
rules: {
|
||||
"prettier/prettier": true, // 强制执行 Prettier 格式化规则(需配合 .prettierrc 配置文件)
|
||||
|
||||
@@ -164,4 +164,43 @@ export default defineMock([
|
||||
msg: "一切ok",
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "logs/visits/trend",
|
||||
method: ["GET"],
|
||||
body: {
|
||||
code: "00000",
|
||||
data: {
|
||||
dates: [
|
||||
"2024-06-30",
|
||||
"2024-07-01",
|
||||
"2024-07-02",
|
||||
"2024-07-03",
|
||||
"2024-07-04",
|
||||
"2024-07-05",
|
||||
"2024-07-06",
|
||||
"2024-07-07",
|
||||
],
|
||||
pvList: [1751, 5168, 4882, 5301, 4721, 4885, 1901, 1003],
|
||||
uvList: null,
|
||||
ipList: [207, 566, 565, 631, 579, 496, 222, 152],
|
||||
},
|
||||
msg: "一切ok",
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "logs/visits/overview",
|
||||
method: ["GET"],
|
||||
body: {
|
||||
code: "00000",
|
||||
data: {
|
||||
todayUvCount: 169,
|
||||
totalUvCount: 19985,
|
||||
uvGrowthRate: -0.57,
|
||||
todayPvCount: 1629,
|
||||
totalPvCount: 286086,
|
||||
pvGrowthRate: -0.65,
|
||||
},
|
||||
msg: "一切ok",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { defineMock } from "./base";
|
||||
|
||||
export default defineMock([
|
||||
{
|
||||
url: "statistics/visits/trend",
|
||||
method: ["GET"],
|
||||
body: {
|
||||
code: "00000",
|
||||
data: {
|
||||
dates: [
|
||||
"2024-06-30",
|
||||
"2024-07-01",
|
||||
"2024-07-02",
|
||||
"2024-07-03",
|
||||
"2024-07-04",
|
||||
"2024-07-05",
|
||||
"2024-07-06",
|
||||
"2024-07-07",
|
||||
],
|
||||
pvList: [1751, 5168, 4882, 5301, 4721, 4885, 1901, 1003],
|
||||
uvList: null,
|
||||
ipList: [207, 566, 565, 631, 579, 496, 222, 152],
|
||||
},
|
||||
msg: "一切ok",
|
||||
},
|
||||
},
|
||||
{
|
||||
url: "statistics/visits/overview",
|
||||
method: ["GET"],
|
||||
body: {
|
||||
code: "00000",
|
||||
data: {
|
||||
todayUvCount: 169,
|
||||
totalUvCount: 19985,
|
||||
uvGrowthRate: -0.57,
|
||||
todayPvCount: 1629,
|
||||
totalPvCount: 286086,
|
||||
pvGrowthRate: -0.65,
|
||||
},
|
||||
msg: "一切ok",
|
||||
},
|
||||
},
|
||||
]);
|
||||
11
package.json
11
package.json
@@ -10,10 +10,6 @@
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint:eslint": "eslint --cache \"src/**/*.{vue,ts,js}\" --fix",
|
||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,css,scss,vue,html,md}\"",
|
||||
"lint:stylelint": "stylelint --cache \"**/*.{css,scss,vue}\" --fix",
|
||||
@@ -51,7 +47,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@stomp/stompjs": "^7.3.0",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@wangeditor-next/editor": "^5.6.49",
|
||||
"@wangeditor-next/editor-for-vue": "^5.1.14",
|
||||
@@ -80,8 +75,6 @@
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@types/codemirror": "^5.60.17",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^25.5.0",
|
||||
@@ -92,8 +85,6 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
||||
"@typescript-eslint/parser": "^8.57.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vitest/ui": "^4.1.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"commitizen": "^4.3.1",
|
||||
"cz-git": "^1.12.0",
|
||||
@@ -102,7 +93,6 @@
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"globals": "^17.4.0",
|
||||
"happy-dom": "^20.8.4",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"postcss": "^8.5.8",
|
||||
@@ -125,7 +115,6 @@
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-mock-dev-server": "^2.1.0",
|
||||
"vitest": "^4.1.0",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^3.2.5"
|
||||
},
|
||||
|
||||
889
pnpm-lock.yaml
generated
889
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,16 @@
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import type { NoticeItem, NoticeDetail, NoticeQueryParams } from "@/types/api";
|
||||
import NoticeAPI from "@/api/system/notice";
|
||||
import { useStomp } from "@/composables";
|
||||
import { useSse } from "@/composables";
|
||||
import router from "@/router";
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
// SSE 事件名称:通知消息
|
||||
const NOTICE_EVENT = "notice";
|
||||
|
||||
export function useNotice() {
|
||||
const { subscribe, unsubscribe, isConnected } = useStomp();
|
||||
const { on, isConnected } = useSse();
|
||||
|
||||
// 状态
|
||||
const list = ref<NoticeItem[]>([]);
|
||||
@@ -18,7 +21,7 @@ export function useNotice() {
|
||||
const detail = ref<NoticeDetail | null>(null);
|
||||
const dialogVisible = ref(false);
|
||||
|
||||
let subscribed = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
// ============================================
|
||||
// 数据获取
|
||||
@@ -60,15 +63,15 @@ export function useNotice() {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// WebSocket 订阅
|
||||
// SSE 订阅
|
||||
// ============================================
|
||||
|
||||
function setupSubscription() {
|
||||
if (subscribed || !isConnected.value) return;
|
||||
if (unsubscribe || !isConnected.value) return;
|
||||
|
||||
subscribe("/user/queue/message", (message: any) => {
|
||||
// 订阅新通知事件
|
||||
unsubscribe = on(NOTICE_EVENT, (data: any) => {
|
||||
try {
|
||||
const data = JSON.parse(message.body || "{}");
|
||||
if (!data.id) return;
|
||||
|
||||
// 避免重复
|
||||
@@ -98,7 +101,21 @@ export function useNotice() {
|
||||
}
|
||||
});
|
||||
|
||||
subscribed = true;
|
||||
// 订阅撤回通知事件
|
||||
on("notice-revoke", (data: any) => {
|
||||
try {
|
||||
if (!data.id) return;
|
||||
|
||||
// 从列表中移除已撤回的通知
|
||||
const idx = list.value.findIndex((item: NoticeItem) => item.id === data.id);
|
||||
if (idx >= 0) {
|
||||
list.value.splice(idx, 1);
|
||||
if (unreadTotal.value > 0) unreadTotal.value -= 1;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("处理撤回通知失败", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
@@ -111,8 +128,10 @@ export function useNotice() {
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe("/user/queue/message");
|
||||
subscribed = false;
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// WebSocket 服务
|
||||
export { setupWebSocket, cleanupWebSocket } from "./websocket";
|
||||
export { useStomp, useDictSync, useOnlineCount } from "./websocket";
|
||||
export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./websocket";
|
||||
// SSE 服务
|
||||
export { setupSse, cleanupSseServices } from "./sse";
|
||||
export { useSse, useDictSync, useOnlineCount, SseConnectionState } from "./sse";
|
||||
export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./sse";
|
||||
|
||||
// 表格相关
|
||||
export { useTableSelection } from "./useTableSelection";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* WebSocket 服务统一管理
|
||||
* SSE 服务统一管理
|
||||
*
|
||||
* @description
|
||||
* 提供 WebSocket 服务的统一初始化和清理接口
|
||||
* 提供 SSE 服务的统一初始化和清理接口
|
||||
* - 字典同步服务
|
||||
* - 在线用户统计服务
|
||||
*
|
||||
@@ -11,19 +11,20 @@
|
||||
|
||||
import { useDictSync } from "./useDictSync";
|
||||
import { useOnlineCount } from "./useOnlineCount";
|
||||
import { cleanupSse } from "./useSse";
|
||||
|
||||
/**
|
||||
* 初始化所有 WebSocket 服务
|
||||
* 初始化所有 SSE 服务
|
||||
*
|
||||
* 应在应用启动时调用,统一初始化所有 WebSocket 连接
|
||||
* 应在应用启动时调用,统一初始化所有 SSE 连接
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 在 main.ts 中调用
|
||||
* setupWebSocket();
|
||||
* setupSse();
|
||||
* ```
|
||||
*/
|
||||
export function setupWebSocket() {
|
||||
export function setupSse() {
|
||||
// 初始化字典同步服务
|
||||
const dictSync = useDictSync();
|
||||
dictSync.initialize();
|
||||
@@ -34,17 +35,17 @@ export function setupWebSocket() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有 WebSocket 连接
|
||||
* 清理所有 SSE 连接
|
||||
*
|
||||
* 应在用户登出时调用,释放所有 WebSocket 资源
|
||||
* 应在用户登出时调用,释放所有 SSE 资源
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 在 user store 的 logout 方法中调用
|
||||
* cleanupWebSocket();
|
||||
* cleanupSseServices();
|
||||
* ```
|
||||
*/
|
||||
export function cleanupWebSocket() {
|
||||
export function cleanupSseServices() {
|
||||
// 清理字典同步服务
|
||||
const dictSync = useDictSync();
|
||||
dictSync.cleanup();
|
||||
@@ -52,10 +53,13 @@ export function cleanupWebSocket() {
|
||||
// 清理在线用户统计服务
|
||||
const onlineCount = useOnlineCount();
|
||||
onlineCount.cleanup();
|
||||
|
||||
// 清理全局 SSE 实例
|
||||
cleanupSse();
|
||||
}
|
||||
|
||||
// 导出所有 WebSocket 相关的 composables
|
||||
// 导出所有 SSE 相关的 composables
|
||||
export { useDictSync } from "./useDictSync";
|
||||
export { useOnlineCount } from "./useOnlineCount";
|
||||
export { useStomp } from "./useStomp";
|
||||
export { useSse, cleanupSse, SseConnectionState } from "./useSse";
|
||||
export type { DictMessage, DictChangeMessage, DictChangeCallback } from "./useDictSync";
|
||||
150
src/composables/sse/useDictSync.ts
Normal file
150
src/composables/sse/useDictSync.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useDictStoreHook } from "@/store/modules/dict";
|
||||
import { useSse } from "./useSse";
|
||||
|
||||
/**
|
||||
* 字典变更消息结构
|
||||
*/
|
||||
export interface DictChangeMessage {
|
||||
/** 字典编码 */
|
||||
dictCode: string;
|
||||
/** 时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字典消息别名(向后兼容)
|
||||
*/
|
||||
export type DictMessage = DictChangeMessage;
|
||||
|
||||
/**
|
||||
* 字典变更事件回调函数类型
|
||||
*/
|
||||
export type DictChangeCallback = (message: DictChangeMessage) => void;
|
||||
|
||||
/**
|
||||
* 全局单例实例
|
||||
*/
|
||||
let singletonInstance: ReturnType<typeof createDictSyncComposable> | null = null;
|
||||
|
||||
/**
|
||||
* 创建字典同步组合式函数(内部工厂函数)
|
||||
*/
|
||||
function createDictSyncComposable() {
|
||||
const dictStore = useDictStoreHook();
|
||||
const sse = useSse();
|
||||
|
||||
// 消息回调函数列表
|
||||
const messageCallbacks = ref<DictChangeCallback[]>([]);
|
||||
|
||||
// 取消订阅函数
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* 处理字典变更事件
|
||||
*/
|
||||
const handleDictChangeMessage = (data: DictChangeMessage) => {
|
||||
const { dictCode } = data;
|
||||
|
||||
if (!dictCode) {
|
||||
console.warn("[DictSync] 收到无效的字典变更消息:缺少 dictCode");
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除缓存,等待按需加载
|
||||
dictStore.removeDictItem(dictCode);
|
||||
|
||||
// 执行所有注册的回调函数
|
||||
messageCallbacks.value.forEach((callback) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error("[DictSync] 回调函数执行失败:", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化 SSE 连接并订阅字典事件
|
||||
*/
|
||||
const initialize = () => {
|
||||
// 建立 SSE 连接
|
||||
sse.connect();
|
||||
|
||||
// 订阅字典变更事件
|
||||
unsubscribe = sse.on("dict", handleDictChangeMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭 SSE 连接并清理资源
|
||||
*/
|
||||
const cleanup = () => {
|
||||
// 取消订阅
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
|
||||
// 清空回调列表
|
||||
messageCallbacks.value = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册字典变更回调函数
|
||||
*
|
||||
* @param callback 回调函数
|
||||
* @returns 返回一个取消注册的函数
|
||||
*/
|
||||
const onDictChange = (callback: DictChangeCallback) => {
|
||||
messageCallbacks.value.push(callback);
|
||||
|
||||
// 返回取消注册的函数
|
||||
return () => {
|
||||
const index = messageCallbacks.value.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
messageCallbacks.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isConnected: sse.isConnected,
|
||||
connectionState: sse.connectionState,
|
||||
|
||||
// 方法
|
||||
initialize,
|
||||
cleanup,
|
||||
onDictChange,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 字典同步组合式函数(单例模式)
|
||||
*
|
||||
* 用于监听后端字典变更并自动同步到前端缓存
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const dictSync = useDictSync();
|
||||
*
|
||||
* // 初始化(通常在应用启动时调用)
|
||||
* dictSync.initialize();
|
||||
*
|
||||
* // 注册回调
|
||||
* const unsubscribe = dictSync.onDictChange((message) => {
|
||||
* console.log('字典已更新:', message.dictCode);
|
||||
* });
|
||||
*
|
||||
* // 取消注册
|
||||
* unsubscribe();
|
||||
*
|
||||
* // 清理(在应用退出时调用)
|
||||
* dictSync.cleanup();
|
||||
* ```
|
||||
*/
|
||||
export function useDictSync() {
|
||||
if (!singletonInstance) {
|
||||
singletonInstance = createDictSyncComposable();
|
||||
}
|
||||
return singletonInstance;
|
||||
}
|
||||
113
src/composables/sse/useOnlineCount.ts
Normal file
113
src/composables/sse/useOnlineCount.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ref, onMounted, onUnmounted, getCurrentInstance } from "vue";
|
||||
import { useSse } from "./useSse";
|
||||
|
||||
/**
|
||||
* 全局单例实例
|
||||
*/
|
||||
let globalInstance: ReturnType<typeof createOnlineCountComposable> | null = null;
|
||||
|
||||
/**
|
||||
* 创建在线用户计数组合式函数(内部工厂函数)
|
||||
*/
|
||||
function createOnlineCountComposable() {
|
||||
// 状态管理
|
||||
const onlineUserCount = ref(0);
|
||||
const lastUpdateTime = ref(0);
|
||||
|
||||
// SSE 客户端
|
||||
const sse = useSse();
|
||||
|
||||
// 取消订阅函数
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* 处理在线用户数量消息
|
||||
*/
|
||||
const handleOnlineCountMessage = (count: number) => {
|
||||
if (count !== undefined && !isNaN(count)) {
|
||||
onlineUserCount.value = count;
|
||||
lastUpdateTime.value = Date.now();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化 SSE 连接并订阅在线用户事件
|
||||
*/
|
||||
const initialize = () => {
|
||||
// 建立 SSE 连接
|
||||
sse.connect();
|
||||
|
||||
// 订阅在线用户计数事件
|
||||
unsubscribe = sse.on("online-count", handleOnlineCountMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭 SSE 连接并清理资源
|
||||
*/
|
||||
const cleanup = () => {
|
||||
// 取消订阅
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
onlineUserCount.value = 0;
|
||||
lastUpdateTime.value = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
onlineUserCount: readonly(onlineUserCount),
|
||||
lastUpdateTime: readonly(lastUpdateTime),
|
||||
isConnected: sse.isConnected,
|
||||
connectionState: sse.connectionState,
|
||||
|
||||
// 方法
|
||||
initialize,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在线用户计数组合式函数(单例模式)
|
||||
*
|
||||
* 用于实时显示系统在线用户数量
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 在组件中使用(推荐)
|
||||
* const { onlineUserCount, isConnected } = useOnlineCount();
|
||||
*
|
||||
* // 手动控制初始化(高级用法)
|
||||
* const { onlineUserCount, initialize, cleanup } = useOnlineCount({ autoInit: false });
|
||||
* onMounted(() => initialize());
|
||||
* onUnmounted(() => cleanup());
|
||||
* ```
|
||||
*/
|
||||
export function useOnlineCount(options: { autoInit?: boolean } = {}) {
|
||||
const { autoInit = true } = options;
|
||||
|
||||
// 获取或创建单例实例
|
||||
if (!globalInstance) {
|
||||
globalInstance = createOnlineCountComposable();
|
||||
}
|
||||
|
||||
// 组件级自动初始化(仅在组件上下文中生效)
|
||||
const instance = getCurrentInstance();
|
||||
if (autoInit && instance) {
|
||||
onMounted(() => {
|
||||
// 防止重复初始化:只有在未连接时才尝试初始化
|
||||
if (!globalInstance!.isConnected.value) {
|
||||
globalInstance!.initialize();
|
||||
}
|
||||
});
|
||||
|
||||
// 注意:组件卸载时不关闭连接,保持全局连接
|
||||
onUnmounted(() => {
|
||||
// 全局连接由 cleanupSse() 统一管理
|
||||
});
|
||||
}
|
||||
|
||||
return globalInstance;
|
||||
}
|
||||
269
src/composables/sse/useSse.ts
Normal file
269
src/composables/sse/useSse.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { AuthStorage } from "@/utils/auth";
|
||||
|
||||
export interface UseSseOptions {
|
||||
/** SSE 端点 URL,不传时使用环境变量拼接 */
|
||||
url?: string;
|
||||
/** 是否开启调试日志 */
|
||||
debug?: boolean;
|
||||
/** 连接超时时间,单位毫秒,默认为 10000 */
|
||||
connectionTimeout?: number;
|
||||
}
|
||||
|
||||
type EventHandler = (data: any) => void;
|
||||
|
||||
/**
|
||||
* SSE 连接状态枚举
|
||||
*/
|
||||
export enum SseConnectionState {
|
||||
DISCONNECTED = "DISCONNECTED",
|
||||
CONNECTING = "CONNECTING",
|
||||
CONNECTED = "CONNECTED",
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 SSE 实例管理
|
||||
*/
|
||||
let globalInstance: ReturnType<typeof createSseConnection> | null = null;
|
||||
|
||||
/**
|
||||
* 创建 SSE 连接(内部工厂函数)
|
||||
*/
|
||||
function createSseConnection(options: UseSseOptions = {}) {
|
||||
// 使用环境变量拼接 SSE URL:/dev-api/api/v1/sse/connect 或 /prod-api/api/v1/sse/connect
|
||||
const baseApi = import.meta.env.VITE_APP_BASE_API || "/dev-api";
|
||||
const defaultUrl = `${baseApi}/api/v1/sse/connect`;
|
||||
const config = {
|
||||
url: options.url ?? defaultUrl,
|
||||
debug: options.debug ?? false,
|
||||
connectionTimeout: options.connectionTimeout ?? 10000,
|
||||
};
|
||||
|
||||
// 状态管理
|
||||
const connectionState = ref<SseConnectionState>(SseConnectionState.DISCONNECTED);
|
||||
const isConnected = computed(() => connectionState.value === SseConnectionState.CONNECTED);
|
||||
|
||||
// EventSource 实例
|
||||
let eventSource: EventSource | null = null;
|
||||
let isManualDisconnect = false;
|
||||
let connectionTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// 事件处理器注册表
|
||||
const eventHandlers = new Map<string, Set<EventHandler>>();
|
||||
|
||||
// 日志
|
||||
const log = config.debug ? (...args: any[]) => console.log("[SSE]", ...args) : () => {};
|
||||
const logError = (...args: any[]) => console.error("[SSE]", ...args);
|
||||
|
||||
/**
|
||||
* 清除连接超时定时器
|
||||
*/
|
||||
const clearConnectionTimeout = () => {
|
||||
if (connectionTimeoutTimer) {
|
||||
clearTimeout(connectionTimeoutTimer);
|
||||
connectionTimeoutTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理连接打开事件
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
clearConnectionTimeout();
|
||||
connectionState.value = SseConnectionState.CONNECTED;
|
||||
log("SSE 连接已建立");
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理连接错误事件
|
||||
*/
|
||||
const handleError = (event: Event) => {
|
||||
clearConnectionTimeout();
|
||||
connectionState.value = SseConnectionState.DISCONNECTED;
|
||||
logError("SSE 连接错误:", event);
|
||||
|
||||
// 非手动断开时,浏览器会自动重连
|
||||
if (!isManualDisconnect && eventSource) {
|
||||
log("浏览器将自动重连...");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理消息事件(默认 message 类型)
|
||||
*/
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
log("收到消息:", event.data);
|
||||
const handlers = eventHandlers.get("message");
|
||||
if (handlers) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handlers.forEach((handler) => handler(data));
|
||||
} catch {
|
||||
handlers.forEach((handler) => handler(event.data));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理自定义事件
|
||||
*/
|
||||
const handleCustomEvent = (eventName: string) => (event: MessageEvent) => {
|
||||
log(`收到事件[${eventName}]:`, event.data);
|
||||
const handlers = eventHandlers.get(eventName);
|
||||
if (handlers) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handlers.forEach((handler) => handler(data));
|
||||
} catch {
|
||||
handlers.forEach((handler) => handler(event.data));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 建立 SSE 连接
|
||||
*/
|
||||
const connect = () => {
|
||||
// 重置手动断开标志
|
||||
isManualDisconnect = false;
|
||||
|
||||
// 检查是否已连接或正在连接
|
||||
if (eventSource && connectionState.value === SseConnectionState.CONNECTED) {
|
||||
log("SSE 已连接,跳过重复连接");
|
||||
return;
|
||||
}
|
||||
|
||||
if (connectionState.value === SseConnectionState.CONNECTING) {
|
||||
log("SSE 正在连接中,跳过重复连接");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 Token
|
||||
const token = AuthStorage.getAccessToken();
|
||||
if (!token) {
|
||||
log("未检测到有效令牌,跳过 SSE 连接");
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置连接状态
|
||||
connectionState.value = SseConnectionState.CONNECTING;
|
||||
|
||||
// 构建 URL(带 Token)
|
||||
const separator = config.url.includes("?") ? "&" : "?";
|
||||
const fullUrl = `${config.url}${separator}token=${encodeURIComponent(token)}`;
|
||||
|
||||
// 创建 EventSource
|
||||
eventSource = new EventSource(fullUrl);
|
||||
|
||||
// 设置连接超时
|
||||
connectionTimeoutTimer = setTimeout(() => {
|
||||
if (connectionState.value === SseConnectionState.CONNECTING) {
|
||||
log("SSE 连接超时");
|
||||
disconnect();
|
||||
}
|
||||
}, config.connectionTimeout);
|
||||
|
||||
// 注册事件监听器
|
||||
eventSource.onopen = handleOpen;
|
||||
eventSource.onerror = handleError;
|
||||
eventSource.onmessage = handleMessage;
|
||||
|
||||
log("正在建立 SSE 连接...");
|
||||
};
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
*
|
||||
* @param eventName 事件名称(如 "dict"、"online-count")
|
||||
* @param handler 事件处理函数
|
||||
* @returns 取消订阅的函数
|
||||
*/
|
||||
const on = (eventName: string, handler: EventHandler): (() => void) => {
|
||||
if (!eventHandlers.has(eventName)) {
|
||||
eventHandlers.set(eventName, new Set());
|
||||
}
|
||||
eventHandlers.get(eventName)!.add(handler);
|
||||
|
||||
// 如果是自定义事件(非 message),需要注册监听器
|
||||
if (eventName !== "message" && eventSource) {
|
||||
eventSource.addEventListener(eventName, handleCustomEvent(eventName) as EventListener);
|
||||
}
|
||||
|
||||
log(`已订阅事件: ${eventName}`);
|
||||
|
||||
// 返回取消订阅函数
|
||||
return () => {
|
||||
const handlers = eventHandlers.get(eventName);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
if (handlers.size === 0) {
|
||||
eventHandlers.delete(eventName);
|
||||
}
|
||||
}
|
||||
log(`已取消订阅事件: ${eventName}`);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 断开 SSE 连接
|
||||
*/
|
||||
const disconnect = () => {
|
||||
isManualDisconnect = true;
|
||||
clearConnectionTimeout();
|
||||
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
log("SSE 连接已断开");
|
||||
}
|
||||
|
||||
connectionState.value = SseConnectionState.DISCONNECTED;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
const cleanup = () => {
|
||||
disconnect();
|
||||
eventHandlers.clear();
|
||||
log("SSE 资源已清理");
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
connectionState: readonly(connectionState),
|
||||
isConnected,
|
||||
|
||||
// 方法
|
||||
connect,
|
||||
disconnect,
|
||||
cleanup,
|
||||
on,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 连接组合式函数(单例模式)
|
||||
*/
|
||||
export function useSse(options: UseSseOptions = {}) {
|
||||
if (!globalInstance) {
|
||||
globalInstance = createSseConnection(options);
|
||||
}
|
||||
return globalInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建 SSE 实例(用于外部访问)
|
||||
*/
|
||||
export function getSseInstance() {
|
||||
return globalInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理全局 SSE 实例
|
||||
*/
|
||||
export function cleanupSse() {
|
||||
if (globalInstance) {
|
||||
globalInstance.cleanup();
|
||||
globalInstance = null;
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useDictStoreHook } from "@/store/modules/dict";
|
||||
import { useStomp } from "./useStomp";
|
||||
import type { IMessage } from "@stomp/stompjs";
|
||||
|
||||
/**
|
||||
* 字典变更消息结构
|
||||
*/
|
||||
export interface DictChangeMessage {
|
||||
/** 字典编码 */
|
||||
dictCode: string;
|
||||
/** 时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 字典消息别名(向后兼容)
|
||||
*/
|
||||
export type DictMessage = DictChangeMessage;
|
||||
|
||||
/**
|
||||
* 字典变更事件回调函数类型
|
||||
*/
|
||||
export type DictChangeCallback = (message: DictChangeMessage) => void;
|
||||
|
||||
/**
|
||||
* 全局单例实例
|
||||
*/
|
||||
let singletonInstance: ReturnType<typeof createDictSyncComposable> | null = null;
|
||||
|
||||
/**
|
||||
* 创建字典同步组合式函数(内部工厂函数)
|
||||
*/
|
||||
function createDictSyncComposable() {
|
||||
const dictStore = useDictStoreHook();
|
||||
|
||||
// 使用优化后的 useStomp
|
||||
const stomp = useStomp({
|
||||
reconnectDelay: 20000,
|
||||
connectionTimeout: 15000,
|
||||
useExponentialBackoff: false,
|
||||
maxReconnectAttempts: 3,
|
||||
autoRestoreSubscriptions: true, // 自动恢复订阅
|
||||
debug: false,
|
||||
});
|
||||
|
||||
// 字典主题地址
|
||||
const DICT_TOPIC = "/topic/dict";
|
||||
|
||||
// 消息回调函数列表
|
||||
const messageCallbacks = ref<DictChangeCallback[]>([]);
|
||||
|
||||
// 订阅 ID(用于取消订阅)
|
||||
let subscriptionId: string | null = null;
|
||||
|
||||
/**
|
||||
* 处理字典变更事件
|
||||
*/
|
||||
const handleDictChangeMessage = (message: IMessage) => {
|
||||
if (!message.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(message.body) as DictChangeMessage;
|
||||
const { dictCode } = data;
|
||||
|
||||
if (!dictCode) {
|
||||
console.warn("[DictSync] 收到无效的字典变更消息:缺少 dictCode");
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除缓存,等待按需加载
|
||||
dictStore.removeDictItem(dictCode);
|
||||
|
||||
// 执行所有注册的回调函数
|
||||
messageCallbacks.value.forEach((callback) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error("[DictSync] 回调函数执行失败:", error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[DictSync] 解析字典变更消息失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化 WebSocket 连接并订阅字典主题
|
||||
*/
|
||||
const initialize = () => {
|
||||
// 检查是否配置了 WebSocket 端点
|
||||
const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT;
|
||||
if (!wsEndpoint) {
|
||||
console.log("[DictSync] 未配置 WebSocket 端点,跳过字典同步功能");
|
||||
return;
|
||||
}
|
||||
|
||||
// console.log("[DictSync] 初始化字典同步服务..."); // 高频日志已禁用
|
||||
|
||||
// 建立 WebSocket 连接
|
||||
stomp.connect();
|
||||
|
||||
// 订阅字典主题(useStomp 会自动处理重连后的订阅恢复)
|
||||
subscriptionId = stomp.subscribe(DICT_TOPIC, handleDictChangeMessage);
|
||||
|
||||
// if (subscriptionId) {
|
||||
// console.log(`[DictSync] 已订阅字典主题: ${DICT_TOPIC}`);
|
||||
// } else {
|
||||
// console.log(`[DictSync] 暂存字典主题订阅,等待连接建立后自动订阅`);
|
||||
// }
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭 WebSocket 连接并清理资源
|
||||
*/
|
||||
const cleanup = () => {
|
||||
// 取消订阅(如果有的话)
|
||||
if (subscriptionId) {
|
||||
stomp.unsubscribe(subscriptionId);
|
||||
subscriptionId = null;
|
||||
}
|
||||
|
||||
// 也可以通过主题地址取消订阅
|
||||
stomp.unsubscribeDestination(DICT_TOPIC);
|
||||
|
||||
// 断开连接
|
||||
stomp.disconnect();
|
||||
|
||||
// 清空回调列表
|
||||
messageCallbacks.value = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册字典变更回调函数
|
||||
*
|
||||
* @param callback 回调函数
|
||||
* @returns 返回一个取消注册的函数
|
||||
*/
|
||||
const onDictChange = (callback: DictChangeCallback) => {
|
||||
messageCallbacks.value.push(callback);
|
||||
|
||||
// 返回取消注册的函数
|
||||
return () => {
|
||||
const index = messageCallbacks.value.indexOf(callback);
|
||||
if (index !== -1) {
|
||||
messageCallbacks.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isConnected: stomp.isConnected,
|
||||
connectionState: stomp.connectionState,
|
||||
|
||||
// 方法
|
||||
initialize,
|
||||
cleanup,
|
||||
onDictChange,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 字典同步组合式函数(单例模式)
|
||||
*
|
||||
* 用于监听后端字典变更并自动同步到前端缓存
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const dictSync = useDictSync();
|
||||
*
|
||||
* // 初始化(通常在应用启动时调用)
|
||||
* dictSync.initialize();
|
||||
*
|
||||
* // 注册回调
|
||||
* const unsubscribe = dictSync.onDictChange((message) => {
|
||||
* console.log('字典已更新:', message.dictCode);
|
||||
* });
|
||||
*
|
||||
* // 取消注册
|
||||
* unsubscribe();
|
||||
*
|
||||
* // 清理(在应用退出时调用)
|
||||
* dictSync.cleanup();
|
||||
* ```
|
||||
*/
|
||||
export function useDictSync() {
|
||||
if (!singletonInstance) {
|
||||
singletonInstance = createDictSyncComposable();
|
||||
}
|
||||
return singletonInstance;
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { ref, onMounted, onUnmounted, getCurrentInstance } from "vue";
|
||||
import { useStomp } from "./useStomp";
|
||||
import { AuthStorage } from "@/utils/auth";
|
||||
|
||||
/**
|
||||
* 在线用户数量消息结构
|
||||
*/
|
||||
interface OnlineCountMessage {
|
||||
count?: number;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局单例实例
|
||||
*/
|
||||
let globalInstance: ReturnType<typeof createOnlineCountComposable> | null = null;
|
||||
|
||||
/**
|
||||
* 创建在线用户计数组合式函数(内部工厂函数)
|
||||
*/
|
||||
function createOnlineCountComposable() {
|
||||
// ==================== 状态管理 ====================
|
||||
const onlineUserCount = ref(0);
|
||||
const lastUpdateTime = ref(0);
|
||||
|
||||
// ==================== WebSocket 客户端 ====================
|
||||
const stomp = useStomp({
|
||||
reconnectDelay: 15000,
|
||||
maxReconnectAttempts: 3,
|
||||
connectionTimeout: 10000,
|
||||
useExponentialBackoff: true,
|
||||
autoRestoreSubscriptions: true, // 自动恢复订阅
|
||||
debug: false,
|
||||
});
|
||||
|
||||
// 在线用户计数主题
|
||||
const ONLINE_COUNT_TOPIC = "/topic/online-count";
|
||||
|
||||
// 订阅 ID
|
||||
let subscriptionId: string | null = null;
|
||||
|
||||
/**
|
||||
* 处理在线用户数量消息
|
||||
*/
|
||||
const handleOnlineCountMessage = (message: any) => {
|
||||
try {
|
||||
const data = message.body;
|
||||
const jsonData = JSON.parse(data) as OnlineCountMessage;
|
||||
|
||||
// 支持两种消息格式
|
||||
// 1. 直接是数字: 42
|
||||
// 2. 对象格式: { count: 42, timestamp: 1234567890 }
|
||||
const count = typeof jsonData === "number" ? jsonData : jsonData.count;
|
||||
|
||||
if (count !== undefined && !isNaN(count)) {
|
||||
onlineUserCount.value = count;
|
||||
lastUpdateTime.value = Date.now();
|
||||
} else {
|
||||
console.warn("[useOnlineCount] 收到无效的在线用户数:", data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[useOnlineCount] 解析在线用户数失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 订阅在线用户计数主题
|
||||
*/
|
||||
const subscribeToOnlineCount = () => {
|
||||
if (subscriptionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 订阅在线用户计数主题(useStomp 会处理重连后的订阅恢复)
|
||||
subscriptionId = stomp.subscribe(ONLINE_COUNT_TOPIC, handleOnlineCountMessage);
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化 WebSocket 连接并订阅在线用户主题
|
||||
*/
|
||||
const initialize = () => {
|
||||
// 检查 WebSocket 端点是否配置
|
||||
const wsEndpoint = import.meta.env.VITE_APP_WS_ENDPOINT;
|
||||
if (!wsEndpoint) {
|
||||
console.log("[useOnlineCount] 未配置 WebSocket 端点,跳过初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查令牌有效性
|
||||
const accessToken = AuthStorage.getAccessToken();
|
||||
if (!accessToken) {
|
||||
console.log("[useOnlineCount] 未检测到有效令牌,跳过初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
// 建立 WebSocket 连接
|
||||
stomp.connect();
|
||||
|
||||
// 订阅主题
|
||||
subscribeToOnlineCount();
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭 WebSocket 连接并清理资源
|
||||
*/
|
||||
const cleanup = () => {
|
||||
// 取消订阅
|
||||
if (subscriptionId) {
|
||||
stomp.unsubscribe(subscriptionId);
|
||||
subscriptionId = null;
|
||||
}
|
||||
|
||||
// 也可以通过主题地址取消订阅
|
||||
stomp.unsubscribeDestination(ONLINE_COUNT_TOPIC);
|
||||
|
||||
// 断开连接
|
||||
stomp.disconnect();
|
||||
|
||||
// 重置状态
|
||||
onlineUserCount.value = 0;
|
||||
lastUpdateTime.value = 0;
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
onlineUserCount: readonly(onlineUserCount),
|
||||
lastUpdateTime: readonly(lastUpdateTime),
|
||||
isConnected: stomp.isConnected,
|
||||
connectionState: stomp.connectionState,
|
||||
|
||||
// 方法
|
||||
initialize,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在线用户计数组合式函数(单例模式)
|
||||
*
|
||||
* 用于实时显示系统在线用户数量
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // 在组件中使用(推荐)
|
||||
* const { onlineUserCount, isConnected } = useOnlineCount();
|
||||
*
|
||||
* // 手动控制初始化(高级用法)
|
||||
* const { onlineUserCount, initialize, cleanup } = useOnlineCount({ autoInit: false });
|
||||
* onMounted(() => initialize());
|
||||
* onUnmounted(() => cleanup());
|
||||
* ```
|
||||
*/
|
||||
export function useOnlineCount(options: { autoInit?: boolean } = {}) {
|
||||
const { autoInit = true } = options;
|
||||
|
||||
// 获取或创建单例实例
|
||||
if (!globalInstance) {
|
||||
globalInstance = createOnlineCountComposable();
|
||||
}
|
||||
|
||||
// 组件级自动初始化(仅在组件上下文中生效)
|
||||
const instance = getCurrentInstance();
|
||||
if (autoInit && instance) {
|
||||
onMounted(() => {
|
||||
// 防止重复初始化:只有在未连接时才尝试初始化
|
||||
if (!globalInstance!.isConnected.value) {
|
||||
globalInstance!.initialize();
|
||||
}
|
||||
});
|
||||
|
||||
// 注意:组件卸载时不关闭连接,保持全局连接
|
||||
onUnmounted(() => {
|
||||
// 全局连接由 cleanupWebSocket() 统一管理
|
||||
});
|
||||
}
|
||||
|
||||
return globalInstance;
|
||||
}
|
||||
@@ -1,568 +0,0 @@
|
||||
import { Client, type IMessage, type StompSubscription } from "@stomp/stompjs";
|
||||
import { AuthStorage } from "@/utils/auth";
|
||||
|
||||
export interface UseStompOptions {
|
||||
/** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */
|
||||
brokerURL?: string;
|
||||
/** 用于鉴权的 token,不传时使用 getAccessToken() 的返回值 */
|
||||
token?: string;
|
||||
/** 重连延迟,单位毫秒,默认为 15000 */
|
||||
reconnectDelay?: number;
|
||||
/** 连接超时时间,单位毫秒,默认为 10000 */
|
||||
connectionTimeout?: number;
|
||||
/** 是否开启指数退避重连策略 */
|
||||
useExponentialBackoff?: boolean;
|
||||
/** 最大重连次数,默认为 3 */
|
||||
maxReconnectAttempts?: number;
|
||||
/** 最大重连延迟,单位毫秒,默认为 60000 */
|
||||
maxReconnectDelay?: number;
|
||||
/** 是否开启调试日志 */
|
||||
debug?: boolean;
|
||||
/** 是否在重连时自动恢复订阅,默认为 true */
|
||||
autoRestoreSubscriptions?: boolean;
|
||||
/**
|
||||
* 心跳接收间隔,单位毫秒,默认为 4000
|
||||
* 注意:标签页失活时,浏览器会节流定时器,建议设置较长的间隔(如 10000)以减少失活影响
|
||||
*/
|
||||
heartbeatIncoming?: number;
|
||||
/**
|
||||
* 心跳发送间隔,单位毫秒,默认为 4000
|
||||
* 注意:标签页失活时,浏览器会节流定时器,建议设置较长的间隔(如 10000)以减少失活影响
|
||||
*/
|
||||
heartbeatOutgoing?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅配置信息
|
||||
*/
|
||||
interface SubscriptionConfig {
|
||||
destination: string;
|
||||
callback: (message: IMessage) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接状态枚举
|
||||
*/
|
||||
enum ConnectionState {
|
||||
DISCONNECTED = "DISCONNECTED",
|
||||
CONNECTING = "CONNECTING",
|
||||
CONNECTED = "CONNECTED",
|
||||
RECONNECTING = "RECONNECTING",
|
||||
}
|
||||
|
||||
/**
|
||||
* STOMP WebSocket 连接管理组合式函数
|
||||
*
|
||||
* 核心功能:
|
||||
* - 自动连接管理(连接、断开、重连)
|
||||
* - 订阅管理(订阅、取消订阅、自动恢复)
|
||||
* - 心跳检测
|
||||
* - Token 自动刷新
|
||||
*
|
||||
* @param options 配置选项
|
||||
* @returns STOMP 客户端操作接口
|
||||
*/
|
||||
export function useStomp(options: UseStompOptions = {}) {
|
||||
// ==================== 配置初始化 ====================
|
||||
const defaultBrokerURL = import.meta.env.VITE_APP_WS_ENDPOINT || "";
|
||||
|
||||
const config = {
|
||||
brokerURL: ref(options.brokerURL ?? defaultBrokerURL),
|
||||
reconnectDelay: options.reconnectDelay ?? 15000,
|
||||
connectionTimeout: options.connectionTimeout ?? 10000,
|
||||
useExponentialBackoff: options.useExponentialBackoff ?? false,
|
||||
maxReconnectAttempts: options.maxReconnectAttempts ?? 3,
|
||||
maxReconnectDelay: options.maxReconnectDelay ?? 60000,
|
||||
autoRestoreSubscriptions: options.autoRestoreSubscriptions ?? true,
|
||||
debug: options.debug ?? false,
|
||||
heartbeatIncoming: options.heartbeatIncoming ?? 4000,
|
||||
heartbeatOutgoing: options.heartbeatOutgoing ?? 4000,
|
||||
};
|
||||
|
||||
// ==================== 状态管理 ====================
|
||||
const connectionState = ref<ConnectionState>(ConnectionState.DISCONNECTED);
|
||||
const isConnected = computed(() => connectionState.value === ConnectionState.CONNECTED);
|
||||
const reconnectAttempts = ref(0);
|
||||
|
||||
// ==================== 定时器管理 ====================
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let connectionTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ==================== 订阅管理 ====================
|
||||
// 活动订阅:存储当前 STOMP 订阅对象
|
||||
const activeSubscriptions = new Map<string, StompSubscription>();
|
||||
// 订阅配置注册表:用于自动恢复订阅
|
||||
const subscriptionRegistry = new Map<string, SubscriptionConfig>();
|
||||
|
||||
// ==================== 客户端实例 ====================
|
||||
const stompClient = ref<Client | null>(null);
|
||||
let isManualDisconnect = false;
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 清理所有定时器
|
||||
*/
|
||||
const clearAllTimers = () => {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (connectionTimeoutTimer) {
|
||||
clearTimeout(connectionTimeoutTimer);
|
||||
connectionTimeoutTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 日志输出(支持调试模式控制)
|
||||
*/
|
||||
const log = config.debug ? (...args: any[]) => console.log("[useStomp]", ...args) : () => {};
|
||||
const logWarn = (...args: any[]) => console.warn("[useStomp]", ...args);
|
||||
const logError = (...args: any[]) => console.error("[useStomp]", ...args);
|
||||
|
||||
/**
|
||||
* 恢复所有订阅
|
||||
*/
|
||||
const restoreSubscriptions = () => {
|
||||
if (!config.autoRestoreSubscriptions || subscriptionRegistry.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
log(`开始恢复 ${subscriptionRegistry.size} 个订阅...`);
|
||||
|
||||
for (const [destination, subscriptionConfig] of subscriptionRegistry.entries()) {
|
||||
try {
|
||||
performSubscribe(destination, subscriptionConfig.callback);
|
||||
} catch (error) {
|
||||
logError(`恢复订阅 ${destination} 失败:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化 STOMP 客户端
|
||||
*/
|
||||
const initializeClient = () => {
|
||||
// 如果客户端已存在且处于活动状态,直接返回
|
||||
if (stompClient.value && (stompClient.value.active || stompClient.value.connected)) {
|
||||
log("STOMP 客户端已存在且处于活动状态,跳过初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 WebSocket 端点是否配置
|
||||
if (!config.brokerURL.value) {
|
||||
logWarn("WebSocket 连接失败: 未配置 WebSocket 端点 URL");
|
||||
return;
|
||||
}
|
||||
|
||||
// 每次连接前重新获取最新令牌
|
||||
const accessToken = AuthStorage.getAccessToken();
|
||||
if (!accessToken) {
|
||||
logWarn("WebSocket 连接失败:授权令牌为空,请先登录");
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理旧客户端
|
||||
if (stompClient.value) {
|
||||
try {
|
||||
stompClient.value.deactivate();
|
||||
} catch (error) {
|
||||
logWarn("清理旧客户端时出错:", error);
|
||||
}
|
||||
stompClient.value = null;
|
||||
}
|
||||
|
||||
// 创建 STOMP 客户端
|
||||
stompClient.value = new Client({
|
||||
brokerURL: config.brokerURL.value,
|
||||
connectHeaders: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
debug: config.debug ? (msg) => console.log("[STOMP]", msg) : () => {},
|
||||
reconnectDelay: 0, // 禁用内置重连,使用自定义重连逻辑
|
||||
heartbeatIncoming: config.heartbeatIncoming,
|
||||
heartbeatOutgoing: config.heartbeatOutgoing,
|
||||
});
|
||||
|
||||
// ==================== 事件监听器 ====================
|
||||
|
||||
// 连接成功
|
||||
stompClient.value.onConnect = () => {
|
||||
connectionState.value = ConnectionState.CONNECTED;
|
||||
reconnectAttempts.value = 0;
|
||||
clearAllTimers();
|
||||
|
||||
log("✅ WebSocket 连接已建立");
|
||||
|
||||
// 自动恢复订阅
|
||||
restoreSubscriptions();
|
||||
};
|
||||
|
||||
// 连接断开
|
||||
stompClient.value.onDisconnect = () => {
|
||||
connectionState.value = ConnectionState.DISCONNECTED;
|
||||
log("❌ WebSocket 连接已断开");
|
||||
|
||||
// 清空活动订阅(但保留订阅配置用于恢复)
|
||||
activeSubscriptions.clear();
|
||||
|
||||
// 如果不是手动断开且未达到最大重连次数,则尝试重连
|
||||
if (!isManualDisconnect && reconnectAttempts.value < config.maxReconnectAttempts) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
// WebSocket 关闭
|
||||
stompClient.value.onWebSocketClose = (event) => {
|
||||
connectionState.value = ConnectionState.DISCONNECTED;
|
||||
log(`WebSocket 已关闭: code=${event?.code}, reason=${event?.reason}`);
|
||||
|
||||
// 如果是手动断开,不重连
|
||||
if (isManualDisconnect) {
|
||||
log("手动断开连接,不进行重连");
|
||||
return;
|
||||
}
|
||||
|
||||
// 对于异常关闭,尝试重连
|
||||
if (
|
||||
event?.code &&
|
||||
[1000, 1006, 1008, 1011].includes(event.code) &&
|
||||
reconnectAttempts.value < config.maxReconnectAttempts
|
||||
) {
|
||||
log("检测到连接异常关闭,将尝试重连");
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
// STOMP 错误
|
||||
stompClient.value.onStompError = (frame) => {
|
||||
logError("STOMP 错误:", frame.headers, frame.body);
|
||||
connectionState.value = ConnectionState.DISCONNECTED;
|
||||
|
||||
// 检查是否是授权错误
|
||||
const isAuthError =
|
||||
frame.headers?.message?.includes("Unauthorized") ||
|
||||
frame.body?.includes("Unauthorized") ||
|
||||
frame.body?.includes("Token") ||
|
||||
frame.body?.includes("401");
|
||||
|
||||
if (isAuthError) {
|
||||
logWarn("WebSocket 授权错误,停止重连");
|
||||
isManualDisconnect = true; // 授权错误不进行重连
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 调度重连任务
|
||||
*/
|
||||
const scheduleReconnect = () => {
|
||||
// 如果正在连接或手动断开,不重连
|
||||
if (connectionState.value === ConnectionState.CONNECTING || isManualDisconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否达到最大重连次数
|
||||
if (reconnectAttempts.value >= config.maxReconnectAttempts) {
|
||||
logError(`已达到最大重连次数 (${config.maxReconnectAttempts}),停止重连`);
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectAttempts.value++;
|
||||
connectionState.value = ConnectionState.RECONNECTING;
|
||||
|
||||
// 计算重连延迟(支持指数退避)
|
||||
const delay = config.useExponentialBackoff
|
||||
? Math.min(
|
||||
config.reconnectDelay * Math.pow(2, reconnectAttempts.value - 1),
|
||||
config.maxReconnectDelay
|
||||
)
|
||||
: config.reconnectDelay;
|
||||
|
||||
log(`准备重连 (${reconnectAttempts.value}/${config.maxReconnectAttempts}),延迟 ${delay}ms`);
|
||||
|
||||
// 清除之前的重连计时器
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
}
|
||||
|
||||
// 设置重连计时器
|
||||
reconnectTimer = setTimeout(() => {
|
||||
if (connectionState.value !== ConnectionState.CONNECTED && !isManualDisconnect) {
|
||||
log(`开始第 ${reconnectAttempts.value} 次重连...`);
|
||||
connect();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
|
||||
// 监听 brokerURL 的变化,自动重新初始化
|
||||
watch(config.brokerURL, (newURL, oldURL) => {
|
||||
if (newURL !== oldURL) {
|
||||
log(`WebSocket 端点已更改: ${oldURL} -> ${newURL}`);
|
||||
|
||||
// 断开当前连接
|
||||
if (stompClient.value && stompClient.value.connected) {
|
||||
stompClient.value.deactivate();
|
||||
}
|
||||
|
||||
// 重新初始化客户端
|
||||
initializeClient();
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化客户端
|
||||
initializeClient();
|
||||
|
||||
// ==================== 标签页可见性监听 ====================
|
||||
|
||||
/**
|
||||
* 处理标签页可见性变化
|
||||
* 当标签页从失活变为激活时,检查连接状态并尝试重连
|
||||
*/
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
log("标签页已失活");
|
||||
} else {
|
||||
log("标签页已激活,检查WebSocket连接状态...");
|
||||
|
||||
// 标签页激活时,检查连接状态
|
||||
if (stompClient.value && !stompClient.value.connected && !isManualDisconnect) {
|
||||
logWarn("检测到WebSocket连接已断开,尝试重新连接...");
|
||||
// 重置重连次数,给予更多重连机会
|
||||
reconnectAttempts.value = 0;
|
||||
connect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听标签页可见性变化
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
}
|
||||
|
||||
// 清理函数:移除事件监听器
|
||||
const cleanup = () => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
}
|
||||
disconnect();
|
||||
};
|
||||
|
||||
// ==================== 公共接口 ====================
|
||||
|
||||
/**
|
||||
* 建立 WebSocket 连接
|
||||
*/
|
||||
const connect = () => {
|
||||
// 重置手动断开标志
|
||||
isManualDisconnect = false;
|
||||
|
||||
// 检查是否配置了 WebSocket 端点
|
||||
if (!config.brokerURL.value) {
|
||||
logError("WebSocket 连接失败: 未配置 WebSocket 端点 URL");
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复连接
|
||||
if (connectionState.value === ConnectionState.CONNECTING) {
|
||||
log("WebSocket 正在连接中,跳过重复连接请求");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果客户端不存在,先初始化
|
||||
if (!stompClient.value) {
|
||||
initializeClient();
|
||||
}
|
||||
|
||||
if (!stompClient.value) {
|
||||
logError("STOMP 客户端初始化失败");
|
||||
return;
|
||||
}
|
||||
|
||||
// 避免重复连接:检查是否已连接
|
||||
if (stompClient.value.connected) {
|
||||
log("WebSocket 已连接,跳过重复连接");
|
||||
connectionState.value = ConnectionState.CONNECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置连接状态
|
||||
connectionState.value = ConnectionState.CONNECTING;
|
||||
|
||||
// 设置连接超时
|
||||
if (connectionTimeoutTimer) {
|
||||
clearTimeout(connectionTimeoutTimer);
|
||||
}
|
||||
|
||||
connectionTimeoutTimer = setTimeout(() => {
|
||||
if (connectionState.value === ConnectionState.CONNECTING) {
|
||||
logWarn("WebSocket 连接超时");
|
||||
connectionState.value = ConnectionState.DISCONNECTED;
|
||||
|
||||
// 超时后尝试重连
|
||||
if (!isManualDisconnect && reconnectAttempts.value < config.maxReconnectAttempts) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
}, config.connectionTimeout);
|
||||
|
||||
try {
|
||||
stompClient.value.activate();
|
||||
log("正在建立 WebSocket 连接...");
|
||||
} catch (error) {
|
||||
logError("激活 WebSocket 连接失败:", error);
|
||||
connectionState.value = ConnectionState.DISCONNECTED;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行订阅操作(内部方法)
|
||||
*/
|
||||
const performSubscribe = (destination: string, callback: (message: IMessage) => void): string => {
|
||||
if (!stompClient.value || !stompClient.value.connected) {
|
||||
logWarn(`尝试订阅 ${destination} 失败: 客户端未连接`);
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = stompClient.value.subscribe(destination, callback);
|
||||
const subscriptionId = subscription.id;
|
||||
activeSubscriptions.set(subscriptionId, subscription);
|
||||
log(`✓ 订阅成功: ${destination} (ID: ${subscriptionId})`);
|
||||
return subscriptionId;
|
||||
} catch (error) {
|
||||
logError(`订阅 ${destination} 失败:`, error);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 订阅指定主题
|
||||
*
|
||||
* @param destination 目标主题地址(如:/topic/message)
|
||||
* @param callback 接收到消息时的回调函数
|
||||
* @returns 订阅 ID,用于后续取消订阅
|
||||
*/
|
||||
const subscribe = (destination: string, callback: (message: IMessage) => void): string => {
|
||||
// 保存订阅配置到注册表,用于断线重连后自动恢复
|
||||
subscriptionRegistry.set(destination, { destination, callback });
|
||||
|
||||
// 如果已连接,立即订阅
|
||||
if (stompClient.value?.connected) {
|
||||
return performSubscribe(destination, callback);
|
||||
}
|
||||
|
||||
log(`暂存订阅配置: ${destination},将在连接建立后自动订阅`);
|
||||
return "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消订阅
|
||||
*
|
||||
* @param subscriptionId 订阅 ID(由 subscribe 方法返回)
|
||||
*/
|
||||
const unsubscribe = (subscriptionId: string) => {
|
||||
const subscription = activeSubscriptions.get(subscriptionId);
|
||||
if (subscription) {
|
||||
try {
|
||||
subscription.unsubscribe();
|
||||
activeSubscriptions.delete(subscriptionId);
|
||||
log(`✓ 已取消订阅: ${subscriptionId}`);
|
||||
} catch (error) {
|
||||
logWarn(`取消订阅 ${subscriptionId} 时出错:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消指定主题的订阅(从注册表中移除)
|
||||
*
|
||||
* @param destination 主题地址
|
||||
*/
|
||||
const unsubscribeDestination = (destination: string) => {
|
||||
// 从注册表中移除
|
||||
subscriptionRegistry.delete(destination);
|
||||
|
||||
// 取消所有匹配该主题的活动订阅
|
||||
for (const [id, subscription] of activeSubscriptions.entries()) {
|
||||
// 注意:STOMP 的 subscription 对象没有直接暴露 destination,
|
||||
// 这里简化处理,实际使用时可能需要额外维护 id -> destination 的映射
|
||||
try {
|
||||
subscription.unsubscribe();
|
||||
activeSubscriptions.delete(id);
|
||||
} catch (error) {
|
||||
logWarn(`取消订阅 ${id} 时出错:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
log(`✓ 已移除主题订阅配置: ${destination}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* 断开 WebSocket 连接
|
||||
*
|
||||
* @param clearSubscriptions 是否清除订阅注册表(默认为 true)
|
||||
*/
|
||||
const disconnect = (clearSubscriptions = true) => {
|
||||
// 设置手动断开标志
|
||||
isManualDisconnect = true;
|
||||
|
||||
// 清除所有定时器
|
||||
clearAllTimers();
|
||||
|
||||
// 取消所有活动订阅
|
||||
for (const [id, subscription] of activeSubscriptions.entries()) {
|
||||
try {
|
||||
subscription.unsubscribe();
|
||||
} catch (error) {
|
||||
logWarn(`取消订阅 ${id} 时出错:`, error);
|
||||
}
|
||||
}
|
||||
activeSubscriptions.clear();
|
||||
|
||||
// 可选:清除订阅注册表
|
||||
if (clearSubscriptions) {
|
||||
subscriptionRegistry.clear();
|
||||
log("已清除所有订阅配置");
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
if (stompClient.value) {
|
||||
try {
|
||||
if (stompClient.value.connected || stompClient.value.active) {
|
||||
stompClient.value.deactivate();
|
||||
log("✓ WebSocket 连接已主动断开");
|
||||
}
|
||||
} catch (error) {
|
||||
logError("断开 WebSocket 连接时出错:", error);
|
||||
}
|
||||
stompClient.value = null;
|
||||
}
|
||||
|
||||
connectionState.value = ConnectionState.DISCONNECTED;
|
||||
reconnectAttempts.value = 0;
|
||||
};
|
||||
|
||||
// ==================== 返回公共接口 ====================
|
||||
return {
|
||||
// 状态
|
||||
connectionState: readonly(connectionState),
|
||||
isConnected,
|
||||
reconnectAttempts: readonly(reconnectAttempts),
|
||||
|
||||
// 连接管理
|
||||
connect,
|
||||
disconnect,
|
||||
cleanup, // 清理资源(包括移除事件监听器)
|
||||
|
||||
// 订阅管理
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
unsubscribeDestination,
|
||||
|
||||
// 统计信息
|
||||
getActiveSubscriptionCount: () => activeSubscriptions.size,
|
||||
getRegisteredSubscriptionCount: () => subscriptionRegistry.size,
|
||||
};
|
||||
}
|
||||
@@ -33,9 +33,6 @@ import { configureVxeTable } from "@/plugins/vxe-table";
|
||||
// ===== 路由守卫 =====
|
||||
import { setupPermissionGuard } from "@/router/guards/permission";
|
||||
|
||||
// ===== 业务服务 =====
|
||||
import { setupWebSocket } from "@/composables";
|
||||
|
||||
// 创建 Vue 应用实例
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -56,8 +53,5 @@ app.use(InstallCodeMirror);
|
||||
// 4️⃣ 路由守卫
|
||||
setupPermissionGuard();
|
||||
|
||||
// 5️⃣ WebSocket 初始化
|
||||
setupWebSocket();
|
||||
|
||||
// 6️⃣ 挂载应用
|
||||
// 5️⃣ 挂载应用
|
||||
app.mount("#app");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePermissionStore, useUserStore } from "@/store";
|
||||
import { useTenantStoreHook } from "@/store/modules/tenant";
|
||||
import { isTenantEnabled } from "@/utils/tenant";
|
||||
import { addRecentMenu } from "@/composables/useRecentMenus";
|
||||
import { setupSse } from "@/composables";
|
||||
|
||||
/**
|
||||
* 路由权限守卫
|
||||
@@ -44,6 +45,8 @@ export function setupPermissionGuard() {
|
||||
if (!permissionStore.isRouteGenerated) {
|
||||
if (!userStore.userInfo?.roles?.length) {
|
||||
await userStore.getUserInfo();
|
||||
// 用户信息加载完成后初始化 SSE
|
||||
setupSse();
|
||||
}
|
||||
|
||||
// 加载用户租户列表(VITE_APP_TENANT_ENABLED=true 时生效)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AuthStorage } from "@/utils/auth";
|
||||
import { usePermissionStoreHook } from "@/store/modules/permission";
|
||||
import { useDictStoreHook } from "@/store/modules/dict";
|
||||
import { useTagsViewStore } from "@/store";
|
||||
import { cleanupWebSocket } from "@/composables";
|
||||
import { cleanupSseServices } from "@/composables";
|
||||
|
||||
export const useUserStore = defineStore("user", () => {
|
||||
// 用户信息
|
||||
@@ -76,8 +76,8 @@ export const useUserStore = defineStore("user", () => {
|
||||
useDictStoreHook().clearDictCache();
|
||||
useTagsViewStore().delAllViews();
|
||||
|
||||
// 3. 清理 WebSocket 连接
|
||||
cleanupWebSocket();
|
||||
// 3. 清理 SSE 连接
|
||||
cleanupSseServices();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
/* 全局业务通用样式 */
|
||||
/**
|
||||
* 布局相关样式
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 通用容器
|
||||
// ============================================
|
||||
|
||||
.app-container {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
/* 进度条颜色 */
|
||||
// ============================================
|
||||
// 进度条
|
||||
// ============================================
|
||||
|
||||
#nprogress .bar {
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
/* 弹出菜单统一使用主题变量(简化 hover 色) */
|
||||
// ============================================
|
||||
// 弹出菜单
|
||||
// ============================================
|
||||
|
||||
.el-menu--popup {
|
||||
--el-menu-bg-color: var(--menu-background);
|
||||
--el-menu-text-color: var(--menu-text);
|
||||
@@ -35,9 +47,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
混合布局左侧菜单 hover 样式
|
||||
============================================ */
|
||||
// ============================================
|
||||
// 混合布局左侧菜单 hover 样式
|
||||
// ============================================
|
||||
|
||||
.layout-mix .layout__sidebar--left .el-menu {
|
||||
.el-menu-item {
|
||||
&:hover {
|
||||
@@ -52,7 +65,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色或深蓝侧边栏的 hover 样式 */
|
||||
// 深色或深蓝侧边栏的 hover 样式
|
||||
html.dark .layout-mix .layout__sidebar--left .el-menu,
|
||||
html.sidebar-color-blue .layout-mix .layout__sidebar--left .el-menu {
|
||||
.el-menu-item,
|
||||
@@ -63,7 +76,10 @@ html.sidebar-color-blue .layout-mix .layout__sidebar--left .el-menu {
|
||||
}
|
||||
}
|
||||
|
||||
/* Top layout: let horizontal menu fill the remaining header space */
|
||||
// ============================================
|
||||
// 顶部布局菜单
|
||||
// ============================================
|
||||
|
||||
.layout-top .layout__header-left .el-menu--horizontal,
|
||||
.layout-mix .layout__header-menu .el-menu--horizontal {
|
||||
display: flex;
|
||||
@@ -74,7 +90,7 @@ html.sidebar-color-blue .layout-mix .layout__sidebar--left .el-menu {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 窄屏隐藏菜单文字,仅保留图标 */
|
||||
// 窄屏隐藏菜单文字,仅保留图标
|
||||
.hideSidebar {
|
||||
&.layout-top .layout__header .el-menu--horizontal,
|
||||
&.layout-mix .layout__header .el-menu--horizontal {
|
||||
@@ -98,7 +114,10 @@ html.sidebar-color-blue .layout-mix .layout__sidebar--left .el-menu {
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局筛选区域 */
|
||||
// ============================================
|
||||
// 全局筛选区域
|
||||
// ============================================
|
||||
|
||||
.filter-section {
|
||||
padding: 8px 12px 0;
|
||||
margin-bottom: 8px;
|
||||
@@ -115,7 +134,10 @@ html.sidebar-color-blue .layout-mix .layout__sidebar--left .el-menu {
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格区域 */
|
||||
// ============================================
|
||||
// 表格区域
|
||||
// ============================================
|
||||
|
||||
.table-section {
|
||||
margin-bottom: 12px;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* 项目主题变量
|
||||
* 全局变量定义
|
||||
*
|
||||
* 结构:
|
||||
* 1. SCSS 变量 - 布局尺寸(供 JS 导出和组件使用)
|
||||
* 2. CSS 变量 - 侧边栏/菜单主题色
|
||||
* 3. 主题模式 - 深蓝侧边栏、暗黑模式
|
||||
* 4. 无障碍模式 - 色弱模式
|
||||
* 1. 布局尺寸(SCSS 变量)
|
||||
* 2. 主题变量(CSS 变量)
|
||||
* 3. 主题模式切换
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 1. SCSS 变量 - 布局尺寸
|
||||
// 1. 布局尺寸(SCSS 变量)
|
||||
// ============================================
|
||||
|
||||
$sidebar-width: 210px;
|
||||
@@ -18,7 +17,7 @@ $navbar-height: 50px;
|
||||
$tags-view-height: 34px;
|
||||
|
||||
// ============================================
|
||||
// 2. CSS 变量 - 默认主题(浅色 + 白色侧边栏)
|
||||
// 2. 主题变量(CSS 变量)
|
||||
// ============================================
|
||||
|
||||
:root {
|
||||
@@ -70,10 +69,6 @@ html.dark {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 4. 无障碍模式
|
||||
// ============================================
|
||||
|
||||
// 色弱模式
|
||||
html.color-weak {
|
||||
filter: invert(80%);
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Element Plus 变量覆盖
|
||||
*
|
||||
* 此文件用于覆盖 Element Plus 的默认主题变量
|
||||
* 需要在 element-plus.scss 中导入,而不是在 variables.scss 中
|
||||
*/
|
||||
@forward "element-plus/theme-chalk/src/common/var.scss" with (
|
||||
$colors: (
|
||||
"primary": (
|
||||
// 默认主题色 - 修改此值时需同步修改 src/settings.ts 中的 themeColor
|
||||
"base": #4080ff,
|
||||
),
|
||||
"success": (
|
||||
"base": #23c343,
|
||||
),
|
||||
"warning": (
|
||||
"base": #ff9a2e,
|
||||
),
|
||||
"danger": (
|
||||
"base": #f76560,
|
||||
),
|
||||
"info": (
|
||||
"base": #a9aeb8,
|
||||
),
|
||||
),
|
||||
|
||||
$bg-color: (
|
||||
"page": #f5f8fd,
|
||||
)
|
||||
);
|
||||
@@ -1,48 +0,0 @@
|
||||
// Element Plus 变量覆盖(必须在最前面)
|
||||
@use "./element-plus-vars";
|
||||
|
||||
$border: 1px solid var(--el-border-color-light);
|
||||
|
||||
/* el-dialog */
|
||||
.el-dialog {
|
||||
.el-dialog__header {
|
||||
padding: 15px 20px;
|
||||
margin: 0;
|
||||
border-bottom: $border;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 15px;
|
||||
border-top: $border;
|
||||
}
|
||||
}
|
||||
|
||||
/* el-drawer */
|
||||
.el-drawer {
|
||||
.el-drawer__header {
|
||||
padding: 15px 20px;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
border-bottom: $border;
|
||||
}
|
||||
|
||||
.el-drawer__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-drawer__footer {
|
||||
padding: 15px;
|
||||
border-top: $border;
|
||||
}
|
||||
}
|
||||
|
||||
// 抽屉和对话框底部按钮区域
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -1,13 +1,75 @@
|
||||
// 1. 基础重置
|
||||
@use "./reset";
|
||||
/**
|
||||
* 样式入口文件
|
||||
*
|
||||
* 编译说明:
|
||||
* - index.scss:✅ 编译成 index.css
|
||||
* - _*.scss:❌ 不单独编译,仅被引入使用
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 1. 变量和第三方库(@use 必须在所有规则之前)
|
||||
// ============================================
|
||||
|
||||
// 2. 主题变量
|
||||
@use "./variables" as *;
|
||||
@use "./vendors";
|
||||
@use "./layouts";
|
||||
|
||||
// 3. UI 框架适配
|
||||
@use "./element-plus";
|
||||
@use "./vxe-table";
|
||||
@use "./wangeditor";
|
||||
// ============================================
|
||||
// 2. 基础重置
|
||||
// ============================================
|
||||
|
||||
// 4. 业务通用样式
|
||||
@use "./common";
|
||||
#app,
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
"Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑",
|
||||
Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizelegibility;
|
||||
}
|
||||
|
||||
ul,
|
||||
li {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a,
|
||||
a:focus,
|
||||
a:hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a:focus,
|
||||
a:active,
|
||||
div:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
svg {
|
||||
// 因 icon 大小被设置为和字体大小一致,而 span 等标签的下边缘会和字体的基线对齐,
|
||||
// 故需设置一个往下的偏移比例,来纠正视觉上的未对齐效果
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// 全局基础重置:补充 UnoCSS 预设未覆盖的项目级样式
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
line-height: 1.5;
|
||||
tab-size: 4;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
#app,
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family:
|
||||
"Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑",
|
||||
Arial, sans-serif;
|
||||
line-height: inherit;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizelegibility;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
svg {
|
||||
// 因icon大小被设置为和字体大小一致,而span等标签的下边缘会和字体的基线对齐,故需设置一个往下的偏移比例,来纠正视觉上的未对齐效果
|
||||
vertical-align: -0.15em;
|
||||
}
|
||||
|
||||
ul,
|
||||
li {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a,
|
||||
a:focus,
|
||||
a:hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a:focus,
|
||||
a:active,
|
||||
div:focus {
|
||||
outline: none;
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
/* stylelint-disable property-no-unknown */
|
||||
/**
|
||||
* 导出变量供 JS/TS 使用
|
||||
*
|
||||
* 使用方式:
|
||||
* import styles from "@/styles/variables.module.scss"
|
||||
* console.log(styles['sidebar-width']) // "210px"
|
||||
*/
|
||||
|
||||
// 通过 SCSS 变量导出给 JS/TS 使用的模块文件
|
||||
// 注意:依赖 src/styles/variables.scss 中定义的 SCSS 变量
|
||||
@use "./variables" as *;
|
||||
|
||||
:export {
|
||||
sidebar-width: $sidebar-width;
|
||||
@@ -12,5 +17,3 @@
|
||||
menu-active-text: $menu-active-text;
|
||||
menu-hover: $menu-hover;
|
||||
}
|
||||
|
||||
/* stylelint-enable property-no-unknown */
|
||||
|
||||
81
src/styles/vendors/_element-plus.scss
vendored
Normal file
81
src/styles/vendors/_element-plus.scss
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Element Plus 变量覆盖和样式适配
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 1. 变量覆盖(必须在最前面)
|
||||
// ============================================
|
||||
|
||||
@forward "element-plus/theme-chalk/src/common/var.scss" with (
|
||||
$colors: (
|
||||
"primary": (
|
||||
// 默认主题色 - 修改此值时需同步修改 src/settings.ts 中的 themeColor
|
||||
"base": #4080ff,
|
||||
),
|
||||
"success": (
|
||||
"base": #23c343,
|
||||
),
|
||||
"warning": (
|
||||
"base": #ff9a2e,
|
||||
),
|
||||
"danger": (
|
||||
"base": #f76560,
|
||||
),
|
||||
"info": (
|
||||
"base": #a9aeb8,
|
||||
),
|
||||
),
|
||||
$bg-color: (
|
||||
"page": #f5f8fd,
|
||||
)
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 2. 样式覆盖
|
||||
// ============================================
|
||||
|
||||
$border: 1px solid var(--el-border-color-light);
|
||||
|
||||
/* el-dialog */
|
||||
.el-dialog {
|
||||
.el-dialog__header {
|
||||
padding: 15px 20px;
|
||||
margin: 0;
|
||||
border-bottom: $border;
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 15px;
|
||||
border-top: $border;
|
||||
}
|
||||
}
|
||||
|
||||
/* el-drawer */
|
||||
.el-drawer {
|
||||
.el-drawer__header {
|
||||
padding: 15px 20px;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
border-bottom: $border;
|
||||
}
|
||||
|
||||
.el-drawer__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.el-drawer__footer {
|
||||
padding: 15px;
|
||||
border-top: $border;
|
||||
}
|
||||
}
|
||||
|
||||
/* 抽屉和对话框底部按钮区域 */
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
10
src/styles/vendors/_index.scss
vendored
Normal file
10
src/styles/vendors/_index.scss
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 第三方库(vendors)样式适配入口
|
||||
*
|
||||
* vendors = 第三方供应商代码
|
||||
* 包含:Element Plus、Vxe Table、WangEditor 等
|
||||
*/
|
||||
|
||||
@use "./element-plus";
|
||||
@use "./vxe-table";
|
||||
@use "./wangeditor";
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Vxe Table 主题统一:
|
||||
* 1) 用 Element Plus 的 CSS 变量覆写 Vxe Table 的 CSS 变量
|
||||
* 2) 自定义局部样式
|
||||
* Vxe Table 主题适配
|
||||
* 使用 Element Plus CSS 变量实现主题统一
|
||||
*/
|
||||
|
||||
/* 变量覆写 */
|
||||
// ============================================
|
||||
// 变量覆写
|
||||
// ============================================
|
||||
|
||||
:root {
|
||||
/* color */
|
||||
--vxe-font-color: var(--el-text-color-regular);
|
||||
@@ -92,7 +94,10 @@
|
||||
--vxe-select-panel-background-color: var(--el-bg-color);
|
||||
}
|
||||
|
||||
/* 自定义组件样式 */
|
||||
// ============================================
|
||||
// 自定义组件样式
|
||||
// ============================================
|
||||
|
||||
.vxe-grid {
|
||||
/* 表单 */
|
||||
&--form-wrapper {
|
||||
@@ -133,20 +133,15 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-0.5 text-xs leading-5 rounded-full border select-none"
|
||||
:class="wsStatusClass"
|
||||
:class="sseStatusClass"
|
||||
>
|
||||
<el-icon class="text-sm">
|
||||
<Loading
|
||||
v-if="
|
||||
!isConnected &&
|
||||
(connectionState === 'CONNECTING' || connectionState === 'RECONNECTING')
|
||||
"
|
||||
/>
|
||||
<Loading v-if="!isConnected && connectionState === 'CONNECTING'" />
|
||||
<CircleCheck v-else-if="isConnected" />
|
||||
<CircleClose v-else />
|
||||
</el-icon>
|
||||
<span class="text-[--el-text-color-secondary]">WebSocket</span>
|
||||
<span class="font-medium">{{ wsStatusText }}</span>
|
||||
<span class="text-[--el-text-color-secondary]">SSE</span>
|
||||
<span class="font-medium">{{ sseStatusText }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -413,19 +408,17 @@ const formattedTime = computed(() => {
|
||||
return useDateFormat(lastUpdateTime, "HH:mm:ss").value;
|
||||
});
|
||||
|
||||
const wsStatusText = computed(() => {
|
||||
const sseStatusText = computed(() => {
|
||||
if (!isConnected.value) {
|
||||
return connectionState.value === "CONNECTING" || connectionState.value === "RECONNECTING"
|
||||
? "连接中"
|
||||
: "未连接";
|
||||
return connectionState.value === "CONNECTING" ? "连接中" : "未连接";
|
||||
}
|
||||
return "已连接";
|
||||
});
|
||||
|
||||
const wsStatusClass = computed(() => {
|
||||
const sseStatusClass = computed(() => {
|
||||
if (isConnected.value)
|
||||
return "text-[--el-color-success] bg-[--el-color-success-light-9] border-[--el-color-success-light-7]";
|
||||
return connectionState.value === "CONNECTING" || connectionState.value === "RECONNECTING"
|
||||
return connectionState.value === "CONNECTING"
|
||||
? "text-[--el-color-warning] bg-[--el-color-warning-light-9] border-[--el-color-warning-light-7]"
|
||||
: "text-[--el-color-danger] bg-[--el-color-danger-light-9] border-[--el-color-danger-light-7]";
|
||||
});
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>字典WebSocket实时更新演示</span>
|
||||
<el-tag :type="wsConnected ? 'success' : 'danger'" size="small" class="ml-2">
|
||||
WebSocket {{ wsStatusText }}
|
||||
<span>字典 SSE 实时更新演示</span>
|
||||
<el-tag :type="sseConnected ? 'success' : 'danger'" size="small" class="ml-2">
|
||||
SSE {{ sseStatusText }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert type="info" :closable="false" class="mb-4">
|
||||
本示例展示WebSocket实时更新字典缓存的效果。您可以编辑"男"性别字典项,保存后后端将通过WebSocket通知所有客户端刷新缓存。
|
||||
本示例展示 SSE 实时更新字典缓存的效果。您可以编辑"男"性别字典项,保存后后端将通过 SSE
|
||||
通知所有客户端刷新缓存。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="16">
|
||||
@@ -161,16 +162,16 @@ const dictForm = ref<DictItemForm | null>(null);
|
||||
// 选中的性别
|
||||
const selectedGender = ref("");
|
||||
|
||||
// 初始化WebSocket
|
||||
const dictWebSocket = useDictSync();
|
||||
// 初始化 SSE
|
||||
const dictSse = useDictSync();
|
||||
|
||||
// 获取连接状态
|
||||
const wsConnected = computed(() => dictWebSocket.isConnected);
|
||||
const sseConnected = computed(() => dictSse.isConnected.value);
|
||||
|
||||
// WebSocket连接状态显示文本
|
||||
const wsStatusText = computed(() => (wsConnected.value ? "已连接" : "未连接"));
|
||||
// SSE 连接状态显示文本
|
||||
const sseStatusText = computed(() => (sseConnected.value ? "已连接" : "未连接"));
|
||||
|
||||
// 保存WebSocket清理函数
|
||||
// 保存 SSE 清理函数
|
||||
let unregisterCallback: (() => void) | null = null;
|
||||
|
||||
// 当前选中字典的缓存状态
|
||||
@@ -179,13 +180,13 @@ const dictCacheStatus = computed(() => {
|
||||
return dictStore.getDictItems(DICT_CODE).length > 0;
|
||||
});
|
||||
|
||||
// 设置WebSocket
|
||||
const setupWebSocket = () => {
|
||||
// 初始化WebSocket连接
|
||||
dictWebSocket.initialize();
|
||||
// 设置 SSE
|
||||
const setupSse = () => {
|
||||
// 初始化 SSE 连接
|
||||
dictSse.initialize();
|
||||
|
||||
// 注册字典消息回调
|
||||
unregisterCallback = dictWebSocket.onDictChange((message: DictMessage) => {
|
||||
unregisterCallback = dictSse.onDictChange((message: DictMessage) => {
|
||||
// 只有当消息是关于性别字典的更新时才处理
|
||||
if (message.dictCode === DICT_CODE) {
|
||||
// 更新最后更新时间
|
||||
@@ -224,7 +225,7 @@ const saveDict = async () => {
|
||||
// 更新时间
|
||||
lastUpdateTime.value = useDateFormat(new Date(), "YYYY-MM-DD HH:mm:ss").value;
|
||||
|
||||
ElMessage.success("保存成功,后端将通过WebSocket通知所有客户端");
|
||||
ElMessage.success("保存成功,后端将通过 SSE 通知所有客户端");
|
||||
saving.value = false;
|
||||
};
|
||||
|
||||
@@ -235,11 +236,11 @@ onMounted(async () => {
|
||||
await dictStore.loadDictItems(DICT_CODE);
|
||||
// 初始化选中性别为男
|
||||
selectedGender.value = "1";
|
||||
// 设置WebSocket
|
||||
setupWebSocket();
|
||||
// 设置 SSE
|
||||
setupSse();
|
||||
});
|
||||
|
||||
// 组件卸载时清理WebSocket
|
||||
// 组件卸载时清理 SSE
|
||||
onUnmounted(() => {
|
||||
unregisterCallback?.();
|
||||
});
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* 测试环境全局配置
|
||||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = class IntersectionObserver {
|
||||
constructor() {}
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
unobserve() {}
|
||||
} as any;
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = class ResizeObserver {
|
||||
constructor() {}
|
||||
disconnect() {}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
} as any;
|
||||
|
||||
// Mock Element.scrollIntoView
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
// Mock __APP_INFO__
|
||||
(globalThis as any).__APP_INFO__ = {
|
||||
pkg: {
|
||||
name: "vue3-element-admin",
|
||||
version: "4.0.0",
|
||||
},
|
||||
};
|
||||
|
||||
// Mock console methods to reduce noise in tests
|
||||
global.console = {
|
||||
...console,
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import Pagination from "@/components/Pagination/index.vue";
|
||||
import ElementPlus from "element-plus";
|
||||
|
||||
describe("Pagination 组件", () => {
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(Pagination, {
|
||||
props: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 100,
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe("渲染", () => {
|
||||
it("应该正确渲染分页组件", () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
expect(wrapper.find(".pagination").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("应该在 hidden 为 true 时隐藏分页", () => {
|
||||
const wrapper = createWrapper({ hidden: true });
|
||||
expect(wrapper.find(".pagination").classes()).toContain("hidden");
|
||||
});
|
||||
|
||||
it("应该在 hidden 为 false 时显示分页", () => {
|
||||
const wrapper = createWrapper({ hidden: false });
|
||||
expect(wrapper.find(".pagination").classes()).not.toContain("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Props", () => {
|
||||
it("应该接收 total 属性", () => {
|
||||
const wrapper = createWrapper({ total: 200 });
|
||||
expect(wrapper.props("total")).toBe(200);
|
||||
});
|
||||
|
||||
it("应该接收 pageSizes 属性", () => {
|
||||
const pageSizes = [5, 10, 20, 50];
|
||||
const wrapper = createWrapper({ pageSizes });
|
||||
expect(wrapper.props("pageSizes")).toEqual(pageSizes);
|
||||
});
|
||||
|
||||
it("应该使用默认的 pageSizes", () => {
|
||||
const wrapper = createWrapper();
|
||||
expect(wrapper.props("pageSizes")).toEqual([10, 20, 30, 50]);
|
||||
});
|
||||
|
||||
it("应该接收 layout 属性", () => {
|
||||
const layout = "prev, pager, next";
|
||||
const wrapper = createWrapper({ layout });
|
||||
expect(wrapper.props("layout")).toBe(layout);
|
||||
});
|
||||
|
||||
it("应该接收 background 属性", () => {
|
||||
const wrapper = createWrapper({ background: false });
|
||||
expect(wrapper.props("background")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("事件", () => {
|
||||
it("应该在页码改变时触发 pagination 事件", async () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
// 模拟页码改变
|
||||
await wrapper.vm.handleCurrentChange(2);
|
||||
|
||||
expect(wrapper.emitted("pagination")).toBeTruthy();
|
||||
expect(wrapper.emitted("pagination")?.[0]).toEqual([{ page: 2, limit: 10 }]);
|
||||
});
|
||||
|
||||
it("应该在每页条数改变时触发 pagination 事件", async () => {
|
||||
const wrapper = createWrapper();
|
||||
|
||||
// 模拟每页条数改变
|
||||
await wrapper.vm.handleSizeChange(20);
|
||||
|
||||
expect(wrapper.emitted("pagination")).toBeTruthy();
|
||||
expect(wrapper.emitted("pagination")?.[0]).toEqual([{ page: 1, limit: 20 }]);
|
||||
});
|
||||
|
||||
it("应该在每页条数改变时重置页码为 1", async () => {
|
||||
const wrapper = createWrapper({ page: 3 });
|
||||
|
||||
await wrapper.vm.handleSizeChange(20);
|
||||
|
||||
expect(wrapper.emitted("pagination")?.[0]).toEqual([{ page: 1, limit: 20 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("边界情况", () => {
|
||||
it("应该在 total 为 0 时正常工作", () => {
|
||||
const wrapper = createWrapper({ total: 0 });
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("应该在当前页超出范围时自动调整", async () => {
|
||||
const wrapper = createWrapper({ page: 5, limit: 10, total: 100 });
|
||||
|
||||
// 修改 total 使当前页超出范围
|
||||
await wrapper.setProps({ total: 20 });
|
||||
|
||||
// 应该触发 pagination 事件,页码调整为最后一页
|
||||
expect(wrapper.emitted("pagination")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { useDictSync } from "@/composables/websocket/useDictSync";
|
||||
import { useDictStore } from "@/store/modules/dict";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
|
||||
// Mock useStomp
|
||||
vi.mock("@/composables/websocket/useStomp", () => ({
|
||||
useStomp: vi.fn(() => ({
|
||||
isConnected: ref(false),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
subscribe: vi.fn(() => "sub-id"),
|
||||
unsubscribe: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("useDictSync", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("初始化", () => {
|
||||
it("应该创建字典同步实例", () => {
|
||||
const dictSync = useDictSync();
|
||||
expect(dictSync).toBeDefined();
|
||||
expect(dictSync.initialize).toBeDefined();
|
||||
expect(dictSync.cleanup).toBeDefined();
|
||||
});
|
||||
|
||||
it("应该初始化 WebSocket 连接", () => {
|
||||
const dictSync = useDictSync();
|
||||
dictSync.initialize();
|
||||
// 验证初始化逻辑(具体实现取决于 useDictSync 的内部逻辑)
|
||||
expect(dictSync).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("清理", () => {
|
||||
it("应该清理 WebSocket 连接", () => {
|
||||
const dictSync = useDictSync();
|
||||
dictSync.initialize();
|
||||
dictSync.cleanup();
|
||||
// 验证清理逻辑
|
||||
expect(dictSync).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("字典同步", () => {
|
||||
it("应该处理字典更新消息", () => {
|
||||
const dictSync = useDictSync();
|
||||
const dictStore = useDictStore();
|
||||
|
||||
dictSync.initialize();
|
||||
|
||||
// 模拟接收字典更新消息
|
||||
// 注意:这里需要根据实际的消息格式来测试
|
||||
expect(dictStore).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useTableSelection } from "@/composables/table/useTableSelection";
|
||||
|
||||
describe("useTableSelection", () => {
|
||||
let selection: ReturnType<typeof useTableSelection>;
|
||||
|
||||
beforeEach(() => {
|
||||
selection = useTableSelection();
|
||||
});
|
||||
|
||||
it("应该初始化为空数组", () => {
|
||||
expect(selection.selectedIds.value).toEqual([]);
|
||||
expect(selection.selectedRows.value).toEqual([]);
|
||||
});
|
||||
|
||||
describe("handleSelectionChange()", () => {
|
||||
it("应该更新选中的行", () => {
|
||||
const rows = [
|
||||
{ id: 1, name: "张三" },
|
||||
{ id: 2, name: "李四" },
|
||||
];
|
||||
|
||||
selection.handleSelectionChange(rows);
|
||||
|
||||
expect(selection.selectedRows.value).toEqual(rows);
|
||||
expect(selection.selectedIds.value).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("应该处理空数组", () => {
|
||||
selection.handleSelectionChange([]);
|
||||
|
||||
expect(selection.selectedRows.value).toEqual([]);
|
||||
expect(selection.selectedIds.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("应该支持自定义 ID 字段", () => {
|
||||
const customSelection = useTableSelection("userId");
|
||||
const rows = [
|
||||
{ userId: "u1", name: "张三" },
|
||||
{ userId: "u2", name: "李四" },
|
||||
];
|
||||
|
||||
customSelection.handleSelectionChange(rows);
|
||||
|
||||
expect(customSelection.selectedIds.value).toEqual(["u1", "u2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearSelection()", () => {
|
||||
it("应该清空选中项", () => {
|
||||
const rows = [{ id: 1, name: "张三" }];
|
||||
selection.handleSelectionChange(rows);
|
||||
|
||||
selection.clearSelection();
|
||||
|
||||
expect(selection.selectedIds.value).toEqual([]);
|
||||
expect(selection.selectedRows.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSelection", () => {
|
||||
it("有选中项时应该返回 true", () => {
|
||||
const rows = [{ id: 1, name: "张三" }];
|
||||
selection.handleSelectionChange(rows);
|
||||
|
||||
expect(selection.hasSelection.value).toBe(true);
|
||||
});
|
||||
|
||||
it("无选中项时应该返回 false", () => {
|
||||
expect(selection.hasSelection.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectionCount", () => {
|
||||
it("应该返回选中项数量", () => {
|
||||
expect(selection.selectionCount.value).toBe(0);
|
||||
|
||||
const rows = [
|
||||
{ id: 1, name: "张三" },
|
||||
{ id: 2, name: "李四" },
|
||||
{ id: 3, name: "王五" },
|
||||
];
|
||||
selection.handleSelectionChange(rows);
|
||||
|
||||
expect(selection.selectionCount.value).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { useAppStore } from "@/store/modules/app";
|
||||
import { DeviceEnum, SidebarStatus } from "@/enums";
|
||||
|
||||
describe("useAppStore", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("侧边栏状态", () => {
|
||||
it("应该切换侧边栏状态", () => {
|
||||
const store = useAppStore();
|
||||
const initialState = store.sidebar.opened;
|
||||
|
||||
store.toggleSidebar();
|
||||
expect(store.sidebar.opened).toBe(!initialState);
|
||||
|
||||
store.toggleSidebar();
|
||||
expect(store.sidebar.opened).toBe(initialState);
|
||||
});
|
||||
|
||||
it("应该关闭侧边栏", () => {
|
||||
const store = useAppStore();
|
||||
store.openSideBar();
|
||||
expect(store.sidebar.opened).toBe(true);
|
||||
|
||||
store.closeSideBar();
|
||||
expect(store.sidebar.opened).toBe(false);
|
||||
});
|
||||
|
||||
it("应该打开侧边栏", () => {
|
||||
const store = useAppStore();
|
||||
store.closeSideBar();
|
||||
expect(store.sidebar.opened).toBe(false);
|
||||
|
||||
store.openSideBar();
|
||||
expect(store.sidebar.opened).toBe(true);
|
||||
});
|
||||
|
||||
it("应该持久化侧边栏状态", () => {
|
||||
const store = useAppStore();
|
||||
store.openSideBar();
|
||||
|
||||
// 创建新的 store 实例模拟页面刷新
|
||||
const newStore = useAppStore();
|
||||
expect(newStore.sidebar.opened).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("设备类型", () => {
|
||||
it("应该切换设备类型", () => {
|
||||
const store = useAppStore();
|
||||
|
||||
store.toggleDevice(DeviceEnum.MOBILE);
|
||||
expect(store.device).toBe(DeviceEnum.MOBILE);
|
||||
|
||||
store.toggleDevice(DeviceEnum.DESKTOP);
|
||||
expect(store.device).toBe(DeviceEnum.DESKTOP);
|
||||
});
|
||||
|
||||
it("应该持久化设备类型", () => {
|
||||
const store = useAppStore();
|
||||
store.toggleDevice(DeviceEnum.MOBILE);
|
||||
|
||||
const newStore = useAppStore();
|
||||
expect(newStore.device).toBe(DeviceEnum.MOBILE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("组件尺寸", () => {
|
||||
it("应该修改组件尺寸", () => {
|
||||
const store = useAppStore();
|
||||
|
||||
store.changeSize("large");
|
||||
expect(store.size).toBe("large");
|
||||
|
||||
store.changeSize("small");
|
||||
expect(store.size).toBe("small");
|
||||
});
|
||||
|
||||
it("应该持久化组件尺寸", () => {
|
||||
const store = useAppStore();
|
||||
store.changeSize("large");
|
||||
|
||||
const newStore = useAppStore();
|
||||
expect(newStore.size).toBe("large");
|
||||
});
|
||||
});
|
||||
|
||||
describe("语言设置", () => {
|
||||
it("应该修改语言", () => {
|
||||
const store = useAppStore();
|
||||
|
||||
store.changeLanguage("en");
|
||||
expect(store.language).toBe("en");
|
||||
|
||||
store.changeLanguage("zh-cn");
|
||||
expect(store.language).toBe("zh-cn");
|
||||
});
|
||||
|
||||
it("应该根据语言返回正确的 locale", () => {
|
||||
const store = useAppStore();
|
||||
|
||||
store.changeLanguage("en");
|
||||
expect(store.locale).toBeDefined();
|
||||
|
||||
store.changeLanguage("zh-cn");
|
||||
expect(store.locale).toBeDefined();
|
||||
});
|
||||
|
||||
it("应该持久化语言设置", () => {
|
||||
const store = useAppStore();
|
||||
store.changeLanguage("en");
|
||||
|
||||
const newStore = useAppStore();
|
||||
expect(newStore.language).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("顶部菜单", () => {
|
||||
it("应该激活顶部菜单", () => {
|
||||
const store = useAppStore();
|
||||
|
||||
store.activeTopMenu("/dashboard");
|
||||
expect(store.activeTopMenuPath).toBe("/dashboard");
|
||||
|
||||
store.activeTopMenu("/system");
|
||||
expect(store.activeTopMenuPath).toBe("/system");
|
||||
});
|
||||
|
||||
it("应该持久化顶部菜单路径", () => {
|
||||
const store = useAppStore();
|
||||
store.activeTopMenu("/dashboard");
|
||||
|
||||
const newStore = useAppStore();
|
||||
expect(newStore.activeTopMenuPath).toBe("/dashboard");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { useDictStore } from "@/store/modules/dict";
|
||||
import DictAPI from "@/api/system/dict";
|
||||
import type { DictItemOption } from "@/types/api";
|
||||
|
||||
// Mock DictAPI
|
||||
vi.mock("@/api/system/dict", () => ({
|
||||
default: {
|
||||
getDictItems: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useDictStore", () => {
|
||||
beforeEach(() => {
|
||||
// 创建新的 Pinia 实例
|
||||
setActivePinia(createPinia());
|
||||
// 清理 localStorage
|
||||
localStorage.clear();
|
||||
// 重置所有 mock
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("字典缓存", () => {
|
||||
it("应该缓存字典数据", async () => {
|
||||
const store = useDictStore();
|
||||
const mockData: DictItemOption[] = [
|
||||
{ value: "1", label: "选项1" },
|
||||
{ value: "2", label: "选项2" },
|
||||
];
|
||||
|
||||
vi.mocked(DictAPI.getDictItems).mockResolvedValue(mockData);
|
||||
|
||||
await store.loadDictItems("test_dict");
|
||||
|
||||
expect(store.getDictItems("test_dict")).toEqual(mockData);
|
||||
expect(DictAPI.getDictItems).toHaveBeenCalledWith("test_dict");
|
||||
expect(DictAPI.getDictItems).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("应该从缓存中获取字典数据,不重复请求", async () => {
|
||||
const store = useDictStore();
|
||||
const mockData: DictItemOption[] = [{ value: "1", label: "选项1" }];
|
||||
|
||||
vi.mocked(DictAPI.getDictItems).mockResolvedValue(mockData);
|
||||
|
||||
// 第一次加载
|
||||
await store.loadDictItems("test_dict");
|
||||
// 第二次加载(应该从缓存获取)
|
||||
await store.loadDictItems("test_dict");
|
||||
|
||||
expect(DictAPI.getDictItems).toHaveBeenCalledTimes(1);
|
||||
expect(store.getDictItems("test_dict")).toEqual(mockData);
|
||||
});
|
||||
|
||||
it("应该防止并发重复请求", async () => {
|
||||
const store = useDictStore();
|
||||
const mockData: DictItemOption[] = [{ value: "1", label: "选项1" }];
|
||||
|
||||
vi.mocked(DictAPI.getDictItems).mockResolvedValue(mockData);
|
||||
|
||||
// 同时发起多个请求
|
||||
const promises = [
|
||||
store.loadDictItems("test_dict"),
|
||||
store.loadDictItems("test_dict"),
|
||||
store.loadDictItems("test_dict"),
|
||||
];
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// 应该只请求一次
|
||||
expect(DictAPI.getDictItems).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("字典操作", () => {
|
||||
it("应该返回空数组当字典不存在时", () => {
|
||||
const store = useDictStore();
|
||||
expect(store.getDictItems("non_exist")).toEqual([]);
|
||||
});
|
||||
|
||||
it("应该移除指定字典项", async () => {
|
||||
const store = useDictStore();
|
||||
const mockData: DictItemOption[] = [{ value: "1", label: "选项1" }];
|
||||
|
||||
vi.mocked(DictAPI.getDictItems).mockResolvedValue(mockData);
|
||||
|
||||
await store.loadDictItems("test_dict");
|
||||
expect(store.getDictItems("test_dict")).toEqual(mockData);
|
||||
|
||||
store.removeDictItem("test_dict");
|
||||
expect(store.getDictItems("test_dict")).toEqual([]);
|
||||
});
|
||||
|
||||
it("应该清空所有字典缓存", async () => {
|
||||
const store = useDictStore();
|
||||
const mockData1: DictItemOption[] = [{ value: "1", label: "选项1" }];
|
||||
const mockData2: DictItemOption[] = [{ value: "2", label: "选项2" }];
|
||||
|
||||
vi.mocked(DictAPI.getDictItems)
|
||||
.mockResolvedValueOnce(mockData1)
|
||||
.mockResolvedValueOnce(mockData2);
|
||||
|
||||
await store.loadDictItems("dict1");
|
||||
await store.loadDictItems("dict2");
|
||||
|
||||
expect(store.getDictItems("dict1")).toEqual(mockData1);
|
||||
expect(store.getDictItems("dict2")).toEqual(mockData2);
|
||||
|
||||
store.clearDictCache();
|
||||
|
||||
expect(store.getDictItems("dict1")).toEqual([]);
|
||||
expect(store.getDictItems("dict2")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("错误处理", () => {
|
||||
it("应该处理请求失败的情况", async () => {
|
||||
const store = useDictStore();
|
||||
const error = new Error("Network error");
|
||||
|
||||
vi.mocked(DictAPI.getDictItems).mockRejectedValue(error);
|
||||
|
||||
await expect(store.loadDictItems("test_dict")).rejects.toThrow("Network error");
|
||||
|
||||
// 失败后应该允许重试
|
||||
const mockData: DictItemOption[] = [{ value: "1", label: "选项1" }];
|
||||
vi.mocked(DictAPI.getDictItems).mockResolvedValue(mockData);
|
||||
|
||||
await store.loadDictItems("test_dict");
|
||||
expect(store.getDictItems("test_dict")).toEqual(mockData);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { useSettingsStore } from "@/store/modules/settings";
|
||||
import { ThemeMode, SidebarColor } from "@/enums";
|
||||
|
||||
// Mock theme utils
|
||||
vi.mock("@/utils/theme", () => ({
|
||||
applyTheme: vi.fn(),
|
||||
generateThemeColors: vi.fn(() => ({})),
|
||||
toggleDarkMode: vi.fn(),
|
||||
toggleSidebarColor: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("useSettingsStore", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("界面显示设置", () => {
|
||||
it("应该切换标签页显示", () => {
|
||||
const store = useSettingsStore();
|
||||
const initial = store.showTagsView;
|
||||
|
||||
store.showTagsView = !initial;
|
||||
expect(store.showTagsView).toBe(!initial);
|
||||
});
|
||||
|
||||
it("应该切换 Logo 显示", () => {
|
||||
const store = useSettingsStore();
|
||||
const initial = store.showAppLogo;
|
||||
|
||||
store.showAppLogo = !initial;
|
||||
expect(store.showAppLogo).toBe(!initial);
|
||||
});
|
||||
|
||||
it("应该切换水印显示", () => {
|
||||
const store = useSettingsStore();
|
||||
const initial = store.showWatermark;
|
||||
|
||||
store.showWatermark = !initial;
|
||||
expect(store.showWatermark).toBe(!initial);
|
||||
});
|
||||
|
||||
it("应该持久化界面显示设置", () => {
|
||||
const store = useSettingsStore();
|
||||
store.showTagsView = false;
|
||||
store.showAppLogo = false;
|
||||
store.showWatermark = true;
|
||||
|
||||
const newStore = useSettingsStore();
|
||||
expect(newStore.showTagsView).toBe(false);
|
||||
expect(newStore.showAppLogo).toBe(false);
|
||||
expect(newStore.showWatermark).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("主题设置", () => {
|
||||
it("应该切换主题模式", () => {
|
||||
const store = useSettingsStore();
|
||||
|
||||
store.theme = ThemeMode.DARK;
|
||||
expect(store.theme).toBe(ThemeMode.DARK);
|
||||
|
||||
store.theme = ThemeMode.LIGHT;
|
||||
expect(store.theme).toBe(ThemeMode.LIGHT);
|
||||
});
|
||||
|
||||
it("应该修改主题颜色", () => {
|
||||
const store = useSettingsStore();
|
||||
|
||||
store.themeColor = "#409EFF";
|
||||
expect(store.themeColor).toBe("#409EFF");
|
||||
|
||||
store.themeColor = "#67C23A";
|
||||
expect(store.themeColor).toBe("#67C23A");
|
||||
});
|
||||
|
||||
it("应该持久化主题设置", () => {
|
||||
const store = useSettingsStore();
|
||||
store.theme = ThemeMode.DARK;
|
||||
store.themeColor = "#409EFF";
|
||||
|
||||
const newStore = useSettingsStore();
|
||||
expect(newStore.theme).toBe(ThemeMode.DARK);
|
||||
expect(newStore.themeColor).toBe("#409EFF");
|
||||
});
|
||||
});
|
||||
|
||||
describe("侧边栏配色", () => {
|
||||
it("应该切换侧边栏配色方案", () => {
|
||||
const store = useSettingsStore();
|
||||
|
||||
store.sidebarColorScheme = SidebarColor.CLASSIC_BLUE;
|
||||
expect(store.sidebarColorScheme).toBe(SidebarColor.CLASSIC_BLUE);
|
||||
|
||||
store.sidebarColorScheme = SidebarColor.THEME_COLOR;
|
||||
expect(store.sidebarColorScheme).toBe(SidebarColor.THEME_COLOR);
|
||||
});
|
||||
|
||||
it("应该持久化侧边栏配色", () => {
|
||||
const store = useSettingsStore();
|
||||
store.sidebarColorScheme = SidebarColor.CLASSIC_BLUE;
|
||||
|
||||
const newStore = useSettingsStore();
|
||||
expect(newStore.sidebarColorScheme).toBe(SidebarColor.CLASSIC_BLUE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("特殊模式", () => {
|
||||
it("应该切换灰色模式", () => {
|
||||
const store = useSettingsStore();
|
||||
|
||||
store.grayMode = true;
|
||||
expect(store.grayMode).toBe(true);
|
||||
|
||||
store.grayMode = false;
|
||||
expect(store.grayMode).toBe(false);
|
||||
});
|
||||
|
||||
it("应该切换色弱模式", () => {
|
||||
const store = useSettingsStore();
|
||||
|
||||
store.colorWeak = true;
|
||||
expect(store.colorWeak).toBe(true);
|
||||
|
||||
store.colorWeak = false;
|
||||
expect(store.colorWeak).toBe(false);
|
||||
});
|
||||
|
||||
it("应该持久化特殊模式", () => {
|
||||
const store = useSettingsStore();
|
||||
store.grayMode = true;
|
||||
store.colorWeak = true;
|
||||
|
||||
const newStore = useSettingsStore();
|
||||
expect(newStore.grayMode).toBe(true);
|
||||
expect(newStore.colorWeak).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AI 助手", () => {
|
||||
it("应该切换 AI 助手", () => {
|
||||
const store = useSettingsStore();
|
||||
|
||||
store.userEnableAi = true;
|
||||
expect(store.userEnableAi).toBe(true);
|
||||
|
||||
store.userEnableAi = false;
|
||||
expect(store.userEnableAi).toBe(false);
|
||||
});
|
||||
|
||||
it("应该持久化 AI 助手设置", () => {
|
||||
const store = useSettingsStore();
|
||||
store.userEnableAi = true;
|
||||
|
||||
const newStore = useSettingsStore();
|
||||
expect(newStore.userEnableAi).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("页面切换动画", () => {
|
||||
it("应该修改页面切换动画", () => {
|
||||
const store = useSettingsStore();
|
||||
|
||||
store.pageSwitchingAnimation = "fade";
|
||||
expect(store.pageSwitchingAnimation).toBe("fade");
|
||||
|
||||
store.pageSwitchingAnimation = "fade-slide";
|
||||
expect(store.pageSwitchingAnimation).toBe("fade-slide");
|
||||
|
||||
store.pageSwitchingAnimation = "fade-scale";
|
||||
expect(store.pageSwitchingAnimation).toBe("fade-scale");
|
||||
|
||||
store.pageSwitchingAnimation = "none";
|
||||
expect(store.pageSwitchingAnimation).toBe("none");
|
||||
});
|
||||
|
||||
it("应该持久化页面切换动画设置", () => {
|
||||
const store = useSettingsStore();
|
||||
store.pageSwitchingAnimation = "fade-scale";
|
||||
|
||||
const newStore = useSettingsStore();
|
||||
expect(newStore.pageSwitchingAnimation).toBe("fade-scale");
|
||||
});
|
||||
|
||||
it("应该使用默认的页面切换动画", () => {
|
||||
const store = useSettingsStore();
|
||||
// 默认值应该是 "fade-slide"
|
||||
expect(store.pageSwitchingAnimation).toBe("fade-slide");
|
||||
});
|
||||
});
|
||||
|
||||
describe("重置设置", () => {
|
||||
it("应该重置所有设置为默认值", () => {
|
||||
const store = useSettingsStore();
|
||||
|
||||
// 修改所有设置
|
||||
store.showTagsView = false;
|
||||
store.showAppLogo = false;
|
||||
store.showWatermark = false;
|
||||
store.pageSwitchingAnimation = "fade-slide";
|
||||
store.userEnableAi = true;
|
||||
store.grayMode = true;
|
||||
store.colorWeak = true;
|
||||
store.theme = ThemeMode.DARK;
|
||||
store.themeColor = "#409EFF";
|
||||
|
||||
// 重置
|
||||
store.resetSettings();
|
||||
|
||||
// 验证已重置(具体值取决于 defaults)
|
||||
expect(store.userEnableAi).toBe(false);
|
||||
expect(store.grayMode).toBe(false);
|
||||
expect(store.colorWeak).toBe(false);
|
||||
expect(store.pageSwitchingAnimation).toBe("fade-slide");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AuthStorage, hasPerm } from "@/utils/auth";
|
||||
import { Storage } from "@/utils/storage";
|
||||
import { STORAGE_KEYS, ROLE_ROOT } from "@/constants";
|
||||
|
||||
// Mock Storage
|
||||
vi.mock("@/utils/storage", () => ({
|
||||
Storage: {
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
sessionGet: vi.fn(),
|
||||
sessionSet: vi.fn(),
|
||||
sessionRemove: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useUserStoreHook
|
||||
vi.mock("@/store/modules/user", () => ({
|
||||
useUserStoreHook: vi.fn(() => ({
|
||||
userInfo: {
|
||||
roles: ["admin"],
|
||||
perms: ["sys:user:create", "sys:user:update"],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("Auth 工具函数", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("AuthStorage", () => {
|
||||
describe("getAccessToken()", () => {
|
||||
it("记住我为 true 时应该从 localStorage 获取", () => {
|
||||
vi.mocked(Storage.get).mockReturnValueOnce(true); // rememberMe
|
||||
vi.mocked(Storage.get).mockReturnValueOnce("token123"); // accessToken
|
||||
|
||||
const token = AuthStorage.getAccessToken();
|
||||
|
||||
expect(Storage.get).toHaveBeenCalledWith(STORAGE_KEYS.REMEMBER_ME, false);
|
||||
expect(Storage.get).toHaveBeenCalledWith(STORAGE_KEYS.ACCESS_TOKEN, "");
|
||||
expect(token).toBe("token123");
|
||||
});
|
||||
|
||||
it("记住我为 false 时应该从 sessionStorage 获取", () => {
|
||||
vi.mocked(Storage.get).mockReturnValueOnce(false); // rememberMe
|
||||
vi.mocked(Storage.sessionGet).mockReturnValueOnce("session-token");
|
||||
|
||||
const token = AuthStorage.getAccessToken();
|
||||
|
||||
expect(Storage.sessionGet).toHaveBeenCalledWith(STORAGE_KEYS.ACCESS_TOKEN, "");
|
||||
expect(token).toBe("session-token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRefreshToken()", () => {
|
||||
it("记住我为 true 时应该从 localStorage 获取", () => {
|
||||
vi.mocked(Storage.get).mockReturnValueOnce(true);
|
||||
vi.mocked(Storage.get).mockReturnValueOnce("refresh123");
|
||||
|
||||
const token = AuthStorage.getRefreshToken();
|
||||
|
||||
expect(token).toBe("refresh123");
|
||||
});
|
||||
|
||||
it("记住我为 false 时应该从 sessionStorage 获取", () => {
|
||||
vi.mocked(Storage.get).mockReturnValueOnce(false);
|
||||
vi.mocked(Storage.sessionGet).mockReturnValueOnce("session-refresh");
|
||||
|
||||
const token = AuthStorage.getRefreshToken();
|
||||
|
||||
expect(token).toBe("session-refresh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTokens()", () => {
|
||||
it("记住我为 true 时应该存储到 localStorage", () => {
|
||||
AuthStorage.setTokens("access123", "refresh123", true);
|
||||
|
||||
expect(Storage.set).toHaveBeenCalledWith(STORAGE_KEYS.REMEMBER_ME, true);
|
||||
expect(Storage.set).toHaveBeenCalledWith(STORAGE_KEYS.ACCESS_TOKEN, "access123");
|
||||
expect(Storage.set).toHaveBeenCalledWith(STORAGE_KEYS.REFRESH_TOKEN, "refresh123");
|
||||
});
|
||||
|
||||
it("记住我为 false 时应该存储到 sessionStorage", () => {
|
||||
AuthStorage.setTokens("access123", "refresh123", false);
|
||||
|
||||
expect(Storage.set).toHaveBeenCalledWith(STORAGE_KEYS.REMEMBER_ME, false);
|
||||
expect(Storage.sessionSet).toHaveBeenCalledWith(STORAGE_KEYS.ACCESS_TOKEN, "access123");
|
||||
expect(Storage.sessionSet).toHaveBeenCalledWith(STORAGE_KEYS.REFRESH_TOKEN, "refresh123");
|
||||
expect(Storage.remove).toHaveBeenCalledWith(STORAGE_KEYS.ACCESS_TOKEN);
|
||||
expect(Storage.remove).toHaveBeenCalledWith(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearAuth()", () => {
|
||||
it("应该清理所有认证信息", () => {
|
||||
AuthStorage.clearAuth();
|
||||
|
||||
expect(Storage.remove).toHaveBeenCalledWith(STORAGE_KEYS.ACCESS_TOKEN);
|
||||
expect(Storage.remove).toHaveBeenCalledWith(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
expect(Storage.sessionRemove).toHaveBeenCalledWith(STORAGE_KEYS.ACCESS_TOKEN);
|
||||
expect(Storage.sessionRemove).toHaveBeenCalledWith(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRememberMe()", () => {
|
||||
it("应该获取记住我状态", () => {
|
||||
vi.mocked(Storage.get).mockReturnValueOnce(true);
|
||||
|
||||
const rememberMe = AuthStorage.getRememberMe();
|
||||
|
||||
expect(Storage.get).toHaveBeenCalledWith(STORAGE_KEYS.REMEMBER_ME, false);
|
||||
expect(rememberMe).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPerm()", () => {
|
||||
it("超级管理员应该拥有所有按钮权限", () => {
|
||||
const { useUserStoreHook } = await import("@/store/modules/user");
|
||||
vi.mocked(useUserStoreHook).mockReturnValueOnce({
|
||||
userInfo: {
|
||||
roles: [ROLE_ROOT],
|
||||
perms: [],
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(hasPerm("any:permission", "button")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该验证单个按钮权限", () => {
|
||||
expect(hasPerm("sys:user:create", "button")).toBe(true);
|
||||
expect(hasPerm("sys:user:delete", "button")).toBe(false);
|
||||
});
|
||||
|
||||
it("应该验证多个按钮权限(或关系)", () => {
|
||||
expect(hasPerm(["sys:user:create", "sys:user:delete"], "button")).toBe(true);
|
||||
expect(hasPerm(["sys:user:delete", "sys:user:export"], "button")).toBe(false);
|
||||
});
|
||||
|
||||
it("应该验证角色权限", () => {
|
||||
expect(hasPerm("admin", "role")).toBe(true);
|
||||
expect(hasPerm("user", "role")).toBe(false);
|
||||
});
|
||||
|
||||
it("用户信息不完整时应该返回 false", () => {
|
||||
const { useUserStoreHook } = await import("@/store/modules/user");
|
||||
vi.mocked(useUserStoreHook).mockReturnValueOnce({
|
||||
userInfo: {},
|
||||
} as any);
|
||||
|
||||
expect(hasPerm("any:permission")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatGrowthRate, formatFileSize, formatNumber, formatCurrency } from "@/utils/format";
|
||||
|
||||
describe("Format 工具函数", () => {
|
||||
describe("formatGrowthRate()", () => {
|
||||
it("应该格式化正增长率", () => {
|
||||
expect(formatGrowthRate(0.25)).toBe("+25.00%");
|
||||
expect(formatGrowthRate(0.5)).toBe("+50.00%");
|
||||
expect(formatGrowthRate(1.5)).toBe("+150.00%");
|
||||
});
|
||||
|
||||
it("应该格式化负增长率", () => {
|
||||
expect(formatGrowthRate(-0.25)).toBe("-25.00%");
|
||||
expect(formatGrowthRate(-0.5)).toBe("-50.00%");
|
||||
});
|
||||
|
||||
it("应该格式化零增长率", () => {
|
||||
expect(formatGrowthRate(0)).toBe("0.00%");
|
||||
});
|
||||
|
||||
it("应该处理小数精度", () => {
|
||||
expect(formatGrowthRate(0.12345)).toBe("+12.35%");
|
||||
expect(formatGrowthRate(0.12344)).toBe("+12.34%");
|
||||
});
|
||||
|
||||
it("应该处理 null 和 undefined", () => {
|
||||
expect(formatGrowthRate(null as any)).toBe("0.00%");
|
||||
expect(formatGrowthRate(undefined as any)).toBe("0.00%");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatFileSize()", () => {
|
||||
it("应该格式化字节", () => {
|
||||
expect(formatFileSize(0)).toBe("0 B");
|
||||
expect(formatFileSize(100)).toBe("100 B");
|
||||
expect(formatFileSize(1023)).toBe("1023 B");
|
||||
});
|
||||
|
||||
it("应该格式化 KB", () => {
|
||||
expect(formatFileSize(1024)).toBe("1.00 KB");
|
||||
expect(formatFileSize(1536)).toBe("1.50 KB");
|
||||
expect(formatFileSize(10240)).toBe("10.00 KB");
|
||||
});
|
||||
|
||||
it("应该格式化 MB", () => {
|
||||
expect(formatFileSize(1048576)).toBe("1.00 MB");
|
||||
expect(formatFileSize(5242880)).toBe("5.00 MB");
|
||||
});
|
||||
|
||||
it("应该格式化 GB", () => {
|
||||
expect(formatFileSize(1073741824)).toBe("1.00 GB");
|
||||
expect(formatFileSize(5368709120)).toBe("5.00 GB");
|
||||
});
|
||||
|
||||
it("应该格式化 TB", () => {
|
||||
expect(formatFileSize(1099511627776)).toBe("1.00 TB");
|
||||
});
|
||||
|
||||
it("应该处理负数", () => {
|
||||
expect(formatFileSize(-1024)).toBe("0 B");
|
||||
});
|
||||
|
||||
it("应该处理 null 和 undefined", () => {
|
||||
expect(formatFileSize(null as any)).toBe("0 B");
|
||||
expect(formatFileSize(undefined as any)).toBe("0 B");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatNumber()", () => {
|
||||
it("应该格式化整数", () => {
|
||||
expect(formatNumber(1000)).toBe("1,000");
|
||||
expect(formatNumber(1000000)).toBe("1,000,000");
|
||||
expect(formatNumber(123456789)).toBe("123,456,789");
|
||||
});
|
||||
|
||||
it("应该格式化小数", () => {
|
||||
expect(formatNumber(1000.5)).toBe("1,000.5");
|
||||
expect(formatNumber(1234.56)).toBe("1,234.56");
|
||||
});
|
||||
|
||||
it("应该处理小数位数", () => {
|
||||
expect(formatNumber(1234.5678, 2)).toBe("1,234.57");
|
||||
expect(formatNumber(1234.5, 2)).toBe("1,234.50");
|
||||
expect(formatNumber(1234, 2)).toBe("1,234.00");
|
||||
});
|
||||
|
||||
it("应该处理零", () => {
|
||||
expect(formatNumber(0)).toBe("0");
|
||||
expect(formatNumber(0, 2)).toBe("0.00");
|
||||
});
|
||||
|
||||
it("应该处理负数", () => {
|
||||
expect(formatNumber(-1000)).toBe("-1,000");
|
||||
expect(formatNumber(-1234.56, 2)).toBe("-1,234.56");
|
||||
});
|
||||
|
||||
it("应该处理 null 和 undefined", () => {
|
||||
expect(formatNumber(null as any)).toBe("0");
|
||||
expect(formatNumber(undefined as any)).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCurrency()", () => {
|
||||
it("应该格式化货币(默认人民币)", () => {
|
||||
expect(formatCurrency(1000)).toBe("¥1,000.00");
|
||||
expect(formatCurrency(1234.56)).toBe("¥1,234.56");
|
||||
expect(formatCurrency(1000000)).toBe("¥1,000,000.00");
|
||||
});
|
||||
|
||||
it("应该格式化美元", () => {
|
||||
expect(formatCurrency(1000, "USD")).toBe("$1,000.00");
|
||||
expect(formatCurrency(1234.56, "USD")).toBe("$1,234.56");
|
||||
});
|
||||
|
||||
it("应该格式化欧元", () => {
|
||||
expect(formatCurrency(1000, "EUR")).toBe("€1,000.00");
|
||||
});
|
||||
|
||||
it("应该格式化日元", () => {
|
||||
expect(formatCurrency(1000, "JPY")).toBe("¥1,000");
|
||||
});
|
||||
|
||||
it("应该处理零", () => {
|
||||
expect(formatCurrency(0)).toBe("¥0.00");
|
||||
});
|
||||
|
||||
it("应该处理负数", () => {
|
||||
expect(formatCurrency(-1000)).toBe("-¥1,000.00");
|
||||
});
|
||||
|
||||
it("应该处理 null 和 undefined", () => {
|
||||
expect(formatCurrency(null as any)).toBe("¥0.00");
|
||||
expect(formatCurrency(undefined as any)).toBe("¥0.00");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Storage } from "@/utils/storage";
|
||||
import { STORAGE_KEYS, APP_PREFIX } from "@/constants";
|
||||
|
||||
describe("Storage 工具类", () => {
|
||||
// 每个测试前清理存储
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
// 每个测试后清理存储
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("localStorage 操作", () => {
|
||||
it("应该能够存储和获取字符串", () => {
|
||||
Storage.set("test-key", "test-value");
|
||||
expect(Storage.get("test-key")).toBe("test-value");
|
||||
});
|
||||
|
||||
it("应该能够存储和获取对象", () => {
|
||||
const testObj = { name: "张三", age: 25 };
|
||||
Storage.set("test-obj", testObj);
|
||||
expect(Storage.get("test-obj")).toEqual(testObj);
|
||||
});
|
||||
|
||||
it("应该能够存储和获取数组", () => {
|
||||
const testArr = [1, 2, 3, 4, 5];
|
||||
Storage.set("test-arr", testArr);
|
||||
expect(Storage.get("test-arr")).toEqual(testArr);
|
||||
});
|
||||
|
||||
it("应该能够存储和获取布尔值", () => {
|
||||
Storage.set("test-bool", true);
|
||||
expect(Storage.get("test-bool")).toBe(true);
|
||||
});
|
||||
|
||||
it("获取不存在的键应该返回 undefined", () => {
|
||||
expect(Storage.get("non-existent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("获取不存在的键应该返回默认值", () => {
|
||||
expect(Storage.get("non-existent", "default")).toBe("default");
|
||||
});
|
||||
|
||||
it("应该能够删除存储项", () => {
|
||||
Storage.set("test-key", "test-value");
|
||||
Storage.remove("test-key");
|
||||
expect(Storage.get("test-key")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessionStorage 操作", () => {
|
||||
it("应该能够存储和获取字符串", () => {
|
||||
Storage.sessionSet("test-key", "test-value");
|
||||
expect(Storage.sessionGet("test-key")).toBe("test-value");
|
||||
});
|
||||
|
||||
it("应该能够存储和获取对象", () => {
|
||||
const testObj = { name: "李四", age: 30 };
|
||||
Storage.sessionSet("test-obj", testObj);
|
||||
expect(Storage.sessionGet("test-obj")).toEqual(testObj);
|
||||
});
|
||||
|
||||
it("获取不存在的键应该返回默认值", () => {
|
||||
expect(Storage.sessionGet("non-existent", "default")).toBe("default");
|
||||
});
|
||||
|
||||
it("应该能够删除存储项", () => {
|
||||
Storage.sessionSet("test-key", "test-value");
|
||||
Storage.sessionRemove("test-key");
|
||||
expect(Storage.sessionGet("test-key")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("批量清理操作", () => {
|
||||
it("clear() 应该同时清理 localStorage 和 sessionStorage", () => {
|
||||
Storage.set("test-key", "local-value");
|
||||
Storage.sessionSet("test-key", "session-value");
|
||||
|
||||
Storage.clear("test-key");
|
||||
|
||||
expect(Storage.get("test-key")).toBeUndefined();
|
||||
expect(Storage.sessionGet("test-key")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearMultiple() 应该批量清理多个键", () => {
|
||||
Storage.set("key1", "value1");
|
||||
Storage.set("key2", "value2");
|
||||
Storage.sessionSet("key1", "session1");
|
||||
|
||||
Storage.clearMultiple(["key1", "key2"]);
|
||||
|
||||
expect(Storage.get("key1")).toBeUndefined();
|
||||
expect(Storage.get("key2")).toBeUndefined();
|
||||
expect(Storage.sessionGet("key1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearByPrefix() 应该清理指定前缀的所有存储项", () => {
|
||||
Storage.set(`${APP_PREFIX}:auth:token`, "token123");
|
||||
Storage.set(`${APP_PREFIX}:auth:user`, "user123");
|
||||
Storage.set(`${APP_PREFIX}:ui:theme`, "dark");
|
||||
Storage.set("other:key", "other-value");
|
||||
|
||||
Storage.clearByPrefix(`${APP_PREFIX}:auth:`);
|
||||
|
||||
expect(Storage.get(`${APP_PREFIX}:auth:token`)).toBeUndefined();
|
||||
expect(Storage.get(`${APP_PREFIX}:auth:user`)).toBeUndefined();
|
||||
expect(Storage.get(`${APP_PREFIX}:ui:theme`)).toBe("dark");
|
||||
expect(Storage.get("other:key")).toBe("other-value");
|
||||
});
|
||||
|
||||
it("clearAllProject() 应该清理所有项目相关的存储", () => {
|
||||
Storage.set(STORAGE_KEYS.ACCESS_TOKEN, "token123");
|
||||
Storage.set(STORAGE_KEYS.THEME, "dark");
|
||||
Storage.set("other:key", "other-value");
|
||||
|
||||
Storage.clearAllProject();
|
||||
|
||||
expect(Storage.get(STORAGE_KEYS.ACCESS_TOKEN)).toBeUndefined();
|
||||
expect(Storage.get(STORAGE_KEYS.THEME)).toBeUndefined();
|
||||
expect(Storage.get("other:key")).toBe("other-value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("边界情况", () => {
|
||||
it("应该能够处理 null 值", () => {
|
||||
Storage.set("test-null", null);
|
||||
expect(Storage.get("test-null")).toBeNull();
|
||||
});
|
||||
|
||||
it("应该能够处理空字符串", () => {
|
||||
Storage.set("test-empty", "");
|
||||
expect(Storage.get("test-empty")).toBe("");
|
||||
});
|
||||
|
||||
it("应该能够处理数字 0", () => {
|
||||
Storage.set("test-zero", 0);
|
||||
expect(Storage.get("test-zero")).toBe(0);
|
||||
});
|
||||
|
||||
it("应该能够处理 false", () => {
|
||||
Storage.set("test-false", false);
|
||||
expect(Storage.get("test-false")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isExternal, isValidURL, isEmail, isMobile, VALIDATORS } from "@/utils/validate";
|
||||
|
||||
describe("Validate 工具函数", () => {
|
||||
describe("isExternal()", () => {
|
||||
it("应该识别外部链接", () => {
|
||||
expect(isExternal("https://www.example.com")).toBe(true);
|
||||
expect(isExternal("http://example.com")).toBe(true);
|
||||
expect(isExternal("//example.com")).toBe(true);
|
||||
expect(isExternal("mailto:test@example.com")).toBe(true);
|
||||
expect(isExternal("tel:1234567890")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该识别内部链接", () => {
|
||||
expect(isExternal("/dashboard")).toBe(false);
|
||||
expect(isExternal("dashboard")).toBe(false);
|
||||
expect(isExternal("./dashboard")).toBe(false);
|
||||
expect(isExternal("../dashboard")).toBe(false);
|
||||
});
|
||||
|
||||
it("应该处理空字符串", () => {
|
||||
expect(isExternal("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidURL()", () => {
|
||||
it("应该验证有效的 URL", () => {
|
||||
expect(isValidURL("https://www.example.com")).toBe(true);
|
||||
expect(isValidURL("http://example.com")).toBe(true);
|
||||
expect(isValidURL("https://example.com/path?query=1")).toBe(true);
|
||||
expect(isValidURL("http://localhost:3000")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该拒绝无效的 URL", () => {
|
||||
expect(isValidURL("not-a-url")).toBe(false);
|
||||
expect(isValidURL("//example.com")).toBe(false);
|
||||
expect(isValidURL("/path")).toBe(false);
|
||||
expect(isValidURL("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isEmail()", () => {
|
||||
it("应该验证有效的邮箱", () => {
|
||||
expect(isEmail("test@example.com")).toBe(true);
|
||||
expect(isEmail("user.name@example.com")).toBe(true);
|
||||
expect(isEmail("user+tag@example.co.uk")).toBe(true);
|
||||
expect(isEmail("123@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该拒绝无效的邮箱", () => {
|
||||
expect(isEmail("invalid")).toBe(false);
|
||||
expect(isEmail("@example.com")).toBe(false);
|
||||
expect(isEmail("user@")).toBe(false);
|
||||
expect(isEmail("user @example.com")).toBe(false);
|
||||
expect(isEmail("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMobile()", () => {
|
||||
it("应该验证有效的手机号", () => {
|
||||
expect(isMobile("13800138000")).toBe(true);
|
||||
expect(isMobile("15912345678")).toBe(true);
|
||||
expect(isMobile("18612345678")).toBe(true);
|
||||
expect(isMobile("19912345678")).toBe(true);
|
||||
});
|
||||
|
||||
it("应该拒绝无效的手机号", () => {
|
||||
expect(isMobile("12345678901")).toBe(false); // 不是 1 开头的有效号段
|
||||
expect(isMobile("1381234567")).toBe(false); // 少于 11 位
|
||||
expect(isMobile("138123456789")).toBe(false); // 多于 11 位
|
||||
expect(isMobile("abcdefghijk")).toBe(false); // 非数字
|
||||
expect(isMobile("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VALIDATORS 对象", () => {
|
||||
describe("required 验证器", () => {
|
||||
it("应该验证必填项", () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
VALIDATORS.required({}, "test", callback);
|
||||
expect(callback).toHaveBeenCalledWith(new Error("此项为必填项"));
|
||||
|
||||
callback.mockClear();
|
||||
VALIDATORS.required({}, "", callback);
|
||||
expect(callback).toHaveBeenCalledWith(new Error("此项为必填项"));
|
||||
|
||||
callback.mockClear();
|
||||
VALIDATORS.required({}, null, callback);
|
||||
expect(callback).toHaveBeenCalledWith(new Error("此项为必填项"));
|
||||
|
||||
callback.mockClear();
|
||||
VALIDATORS.required({}, undefined, callback);
|
||||
expect(callback).toHaveBeenCalledWith(new Error("此项为必填项"));
|
||||
});
|
||||
|
||||
it("应该通过有效值", () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
VALIDATORS.required({}, "value", callback);
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
|
||||
callback.mockClear();
|
||||
VALIDATORS.required({}, 0, callback);
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
|
||||
callback.mockClear();
|
||||
VALIDATORS.required({}, false, callback);
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe("email 验证器", () => {
|
||||
it("应该验证邮箱格式", () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
VALIDATORS.email({}, "invalid", callback);
|
||||
expect(callback).toHaveBeenCalledWith(new Error("请输入正确的邮箱地址"));
|
||||
|
||||
callback.mockClear();
|
||||
VALIDATORS.email({}, "test@example.com", callback);
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("应该允许空值", () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
VALIDATORS.email({}, "", callback);
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
|
||||
callback.mockClear();
|
||||
VALIDATORS.email({}, null, callback);
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mobile 验证器", () => {
|
||||
it("应该验证手机号格式", () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
VALIDATORS.mobile({}, "12345678901", callback);
|
||||
expect(callback).toHaveBeenCalledWith(new Error("请输入正确的手机号码"));
|
||||
|
||||
callback.mockClear();
|
||||
VALIDATORS.mobile({}, "13800138000", callback);
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("应该允许空值", () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
VALIDATORS.mobile({}, "", callback);
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
|
||||
describe("url 验证器", () => {
|
||||
it("应该验证 URL 格式", () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
VALIDATORS.url({}, "not-a-url", callback);
|
||||
expect(callback).toHaveBeenCalledWith(new Error("请输入正确的 URL 地址"));
|
||||
|
||||
callback.mockClear();
|
||||
VALIDATORS.url({}, "https://example.com", callback);
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it("应该允许空值", () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
VALIDATORS.url({}, "", callback);
|
||||
expect(callback).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
161
vite.config.ts
161
vite.config.ts
@@ -8,7 +8,6 @@ import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
||||
import { mockDevServerPlugin } from "vite-plugin-mock-dev-server";
|
||||
|
||||
import UnoCSS from "unocss/vite";
|
||||
import { resolve } from "path";
|
||||
import { name, version, engines, dependencies, devDependencies } from "./package.json";
|
||||
|
||||
// 平台的名称、版本、运行所需的 node 版本、依赖、构建时间的类型提示
|
||||
@@ -17,8 +16,6 @@ const __APP_INFO__ = {
|
||||
buildTimestamp: Date.now(),
|
||||
};
|
||||
|
||||
const pathSrc = resolve(__dirname, "src");
|
||||
|
||||
// Vite配置 https://cn.vitejs.dev/config
|
||||
export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
@@ -26,15 +23,14 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
|
||||
return {
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": pathSrc,
|
||||
},
|
||||
// Vite 8 新特性:自动读取 tsconfig.json 中的 paths 别名
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
// 定义全局 SCSS 变量
|
||||
scss: {
|
||||
additionalData: `@use "@/styles/variables.scss" as *;`,
|
||||
additionalData: `@use "@/styles/_variables.scss" as *;`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -62,7 +58,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
imports: ["vue", "@vueuse/core", "pinia", "vue-router", "vue-i18n"],
|
||||
resolvers: [
|
||||
// 导入 Element Plus函数,如:ElMessage, ElMessageBox 等
|
||||
ElementPlusResolver({ importStyle: "sass" }),
|
||||
ElementPlusResolver(),
|
||||
],
|
||||
eslintrc: {
|
||||
enabled: false,
|
||||
@@ -78,7 +74,7 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
Components({
|
||||
resolvers: [
|
||||
// 导入 Element Plus 组件
|
||||
ElementPlusResolver({ importStyle: "sass" }),
|
||||
ElementPlusResolver(),
|
||||
],
|
||||
// 指定自定义组件位置(默认:src/components)
|
||||
dirs: ["src/components", "src/**/components"],
|
||||
@@ -108,84 +104,83 @@ export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
|
||||
"sortablejs",
|
||||
"qs",
|
||||
"path-browserify",
|
||||
"@stomp/stompjs",
|
||||
"@element-plus/icons-vue",
|
||||
"element-plus/es",
|
||||
"element-plus/es/locale/lang/en",
|
||||
"element-plus/es/locale/lang/zh-cn",
|
||||
"element-plus/es/components/alert/style/index",
|
||||
"element-plus/es/components/avatar/style/index",
|
||||
"element-plus/es/components/backtop/style/index",
|
||||
"element-plus/es/components/badge/style/index",
|
||||
"element-plus/es/components/base/style/index",
|
||||
"element-plus/es/components/breadcrumb-item/style/index",
|
||||
"element-plus/es/components/breadcrumb/style/index",
|
||||
"element-plus/es/components/button/style/index",
|
||||
"element-plus/es/components/card/style/index",
|
||||
"element-plus/es/components/cascader/style/index",
|
||||
"element-plus/es/components/checkbox-group/style/index",
|
||||
"element-plus/es/components/checkbox/style/index",
|
||||
"element-plus/es/components/col/style/index",
|
||||
"element-plus/es/components/color-picker/style/index",
|
||||
"element-plus/es/components/config-provider/style/index",
|
||||
"element-plus/es/components/date-picker/style/index",
|
||||
"element-plus/es/components/descriptions-item/style/index",
|
||||
"element-plus/es/components/descriptions/style/index",
|
||||
"element-plus/es/components/dialog/style/index",
|
||||
"element-plus/es/components/divider/style/index",
|
||||
"element-plus/es/components/drawer/style/index",
|
||||
"element-plus/es/components/dropdown-item/style/index",
|
||||
"element-plus/es/components/dropdown-menu/style/index",
|
||||
"element-plus/es/components/dropdown/style/index",
|
||||
"element-plus/es/components/empty/style/index",
|
||||
"element-plus/es/components/form-item/style/index",
|
||||
"element-plus/es/components/form/style/index",
|
||||
"element-plus/es/components/icon/style/index",
|
||||
"element-plus/es/components/image-viewer/style/index",
|
||||
"element-plus/es/components/image/style/index",
|
||||
"element-plus/es/components/input-number/style/index",
|
||||
"element-plus/es/components/input-tag/style/index",
|
||||
"element-plus/es/components/input/style/index",
|
||||
"element-plus/es/components/link/style/index",
|
||||
"element-plus/es/components/loading/style/index",
|
||||
"element-plus/es/components/menu-item/style/index",
|
||||
"element-plus/es/components/menu/style/index",
|
||||
"element-plus/es/components/message-box/style/index",
|
||||
"element-plus/es/components/message/style/index",
|
||||
"element-plus/es/components/notification/style/index",
|
||||
"element-plus/es/components/option/style/index",
|
||||
"element-plus/es/components/pagination/style/index",
|
||||
"element-plus/es/components/popover/style/index",
|
||||
"element-plus/es/components/progress/style/index",
|
||||
"element-plus/es/components/radio-button/style/index",
|
||||
"element-plus/es/components/radio-group/style/index",
|
||||
"element-plus/es/components/radio/style/index",
|
||||
"element-plus/es/components/row/style/index",
|
||||
"element-plus/es/components/scrollbar/style/index",
|
||||
"element-plus/es/components/select/style/index",
|
||||
"element-plus/es/components/skeleton-item/style/index",
|
||||
"element-plus/es/components/skeleton/style/index",
|
||||
"element-plus/es/components/step/style/index",
|
||||
"element-plus/es/components/steps/style/index",
|
||||
"element-plus/es/components/sub-menu/style/index",
|
||||
"element-plus/es/components/switch/style/index",
|
||||
"element-plus/es/components/tab-pane/style/index",
|
||||
"element-plus/es/components/table-column/style/index",
|
||||
"element-plus/es/components/table/style/index",
|
||||
"element-plus/es/components/tabs/style/index",
|
||||
"element-plus/es/components/tag/style/index",
|
||||
"element-plus/es/components/text/style/index",
|
||||
"element-plus/es/components/time-picker/style/index",
|
||||
"element-plus/es/components/time-select/style/index",
|
||||
"element-plus/es/components/timeline-item/style/index",
|
||||
"element-plus/es/components/timeline/style/index",
|
||||
"element-plus/es/components/tooltip/style/index",
|
||||
"element-plus/es/components/tree-select/style/index",
|
||||
"element-plus/es/components/tree/style/index",
|
||||
"element-plus/es/components/upload/style/index",
|
||||
"element-plus/es/components/watermark/style/index",
|
||||
"element-plus/es/components/checkbox-button/style/index",
|
||||
"element-plus/es/components/space/style/index",
|
||||
"element-plus/es/components/alert/style/css",
|
||||
"element-plus/es/components/avatar/style/css",
|
||||
"element-plus/es/components/backtop/style/css",
|
||||
"element-plus/es/components/badge/style/css",
|
||||
"element-plus/es/components/base/style/css",
|
||||
"element-plus/es/components/breadcrumb-item/style/css",
|
||||
"element-plus/es/components/breadcrumb/style/css",
|
||||
"element-plus/es/components/button/style/css",
|
||||
"element-plus/es/components/card/style/css",
|
||||
"element-plus/es/components/cascader/style/css",
|
||||
"element-plus/es/components/checkbox-group/style/css",
|
||||
"element-plus/es/components/checkbox/style/css",
|
||||
"element-plus/es/components/col/style/css",
|
||||
"element-plus/es/components/color-picker/style/css",
|
||||
"element-plus/es/components/config-provider/style/css",
|
||||
"element-plus/es/components/date-picker/style/css",
|
||||
"element-plus/es/components/descriptions-item/style/css",
|
||||
"element-plus/es/components/descriptions/style/css",
|
||||
"element-plus/es/components/dialog/style/css",
|
||||
"element-plus/es/components/divider/style/css",
|
||||
"element-plus/es/components/drawer/style/css",
|
||||
"element-plus/es/components/dropdown-item/style/css",
|
||||
"element-plus/es/components/dropdown-menu/style/css",
|
||||
"element-plus/es/components/dropdown/style/css",
|
||||
"element-plus/es/components/empty/style/css",
|
||||
"element-plus/es/components/form-item/style/css",
|
||||
"element-plus/es/components/form/style/css",
|
||||
"element-plus/es/components/icon/style/css",
|
||||
"element-plus/es/components/image-viewer/style/css",
|
||||
"element-plus/es/components/image/style/css",
|
||||
"element-plus/es/components/input-number/style/css",
|
||||
"element-plus/es/components/input-tag/style/css",
|
||||
"element-plus/es/components/input/style/css",
|
||||
"element-plus/es/components/link/style/css",
|
||||
"element-plus/es/components/loading/style/css",
|
||||
"element-plus/es/components/menu-item/style/css",
|
||||
"element-plus/es/components/menu/style/css",
|
||||
"element-plus/es/components/message-box/style/css",
|
||||
"element-plus/es/components/message/style/css",
|
||||
"element-plus/es/components/notification/style/css",
|
||||
"element-plus/es/components/option/style/css",
|
||||
"element-plus/es/components/pagination/style/css",
|
||||
"element-plus/es/components/popover/style/css",
|
||||
"element-plus/es/components/progress/style/css",
|
||||
"element-plus/es/components/radio-button/style/css",
|
||||
"element-plus/es/components/radio-group/style/css",
|
||||
"element-plus/es/components/radio/style/css",
|
||||
"element-plus/es/components/row/style/css",
|
||||
"element-plus/es/components/scrollbar/style/css",
|
||||
"element-plus/es/components/select/style/css",
|
||||
"element-plus/es/components/skeleton-item/style/css",
|
||||
"element-plus/es/components/skeleton/style/css",
|
||||
"element-plus/es/components/step/style/css",
|
||||
"element-plus/es/components/steps/style/css",
|
||||
"element-plus/es/components/sub-menu/style/css",
|
||||
"element-plus/es/components/switch/style/css",
|
||||
"element-plus/es/components/tab-pane/style/css",
|
||||
"element-plus/es/components/table-column/style/css",
|
||||
"element-plus/es/components/table/style/css",
|
||||
"element-plus/es/components/tabs/style/css",
|
||||
"element-plus/es/components/tag/style/css",
|
||||
"element-plus/es/components/text/style/css",
|
||||
"element-plus/es/components/time-picker/style/css",
|
||||
"element-plus/es/components/time-select/style/css",
|
||||
"element-plus/es/components/timeline-item/style/css",
|
||||
"element-plus/es/components/timeline/style/css",
|
||||
"element-plus/es/components/tooltip/style/css",
|
||||
"element-plus/es/components/tree-select/style/css",
|
||||
"element-plus/es/components/tree/style/css",
|
||||
"element-plus/es/components/upload/style/css",
|
||||
"element-plus/es/components/watermark/style/css",
|
||||
"element-plus/es/components/checkbox-button/style/css",
|
||||
"element-plus/es/components/space/style/css",
|
||||
],
|
||||
},
|
||||
// 构建配置
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { resolve } from "path";
|
||||
import AutoImport from "unplugin-auto-import/vite";
|
||||
import Components from "unplugin-vue-components/vite";
|
||||
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
// API 自动导入
|
||||
AutoImport({
|
||||
imports: ["vue", "@vueuse/core", "pinia", "vue-router", "vue-i18n"],
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: false,
|
||||
}),
|
||||
// 组件自动导入
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: false,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
// 使用 happy-dom 作为测试环境(比 jsdom 快)
|
||||
environment: "happy-dom",
|
||||
|
||||
// 全局测试 API(describe, it, expect 等)
|
||||
globals: true,
|
||||
|
||||
// 测试环境设置文件
|
||||
setupFiles: ["./tests/setup.ts"],
|
||||
|
||||
// 覆盖率配置
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html"],
|
||||
exclude: [
|
||||
"node_modules/",
|
||||
"tests/",
|
||||
"**/*.d.ts",
|
||||
"**/*.config.*",
|
||||
"**/mockData",
|
||||
"**/.{idea,git,cache,output,temp}",
|
||||
],
|
||||
},
|
||||
|
||||
// 测试文件匹配规则
|
||||
include: ["tests/**/*.{test,spec}.{js,ts}"],
|
||||
|
||||
// 测试超时时间
|
||||
testTimeout: 10000,
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user