chore: 移除单元测试、升级Vite8的配置和依赖、css目录重构和WebSocket 迁移到 SSE 实现实时推送

This commit is contained in:
Ray.Hao
2026-03-18 17:47:16 +08:00
parent c35403e197
commit e49ca1ef54
45 changed files with 968 additions and 3727 deletions

View File

@@ -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:关闭)

View File

@@ -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
# ============================================
# 🎛️ 功能开关

View File

@@ -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 配置文件)

View File

@@ -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",
},
},
]);

View File

@@ -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",
},
},
]);

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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";

View 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;
}

View 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;
}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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();
// 5WebSocket 初始化
setupWebSocket();
// 6⃣ 挂载应用
// 5挂载应用
app.mount("#app");

View File

@@ -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 时生效)

View File

@@ -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();
}
/**

View File

@@ -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;

View File

@@ -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%);

View File

@@ -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,
)
);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

@@ -0,0 +1,10 @@
/**
* 第三方库vendors样式适配入口
*
* vendors = 第三方供应商代码
* 包含Element Plus、Vxe Table、WangEditor 等
*/
@use "./element-plus";
@use "./vxe-table";
@use "./wangeditor";

View File

@@ -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 {

View File

@@ -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]";
});

View File

@@ -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?.();
});

View File

@@ -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(),
};

View File

@@ -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();
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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");
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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");
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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");
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});
});
});

View File

@@ -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",
],
},
// 构建配置

View File

@@ -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",
// 全局测试 APIdescribe, 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"),
},
},
});