mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-26 06:19:04 +00:00
feat: 迁移前端资源文件并重构项目结构
refactor: 优化前端代码结构和资源管理 style: 调整前端代码格式和样式 chore: 更新.gitignore和构建配置 fix: 修复前端资源路径和引用问题 docs: 更新前端文档和注释 perf: 优化前端性能和加载速度 test: 更新前端测试用例 build: 调整前端构建配置 ci: 更新CI/CD配置
This commit is contained in:
@@ -1,86 +0,0 @@
|
||||
import { Storage } from "./storage";
|
||||
import { AUTH_KEYS } from "@/constants";
|
||||
|
||||
/**
|
||||
* 身份验证工具类
|
||||
* 集中管理所有与认证相关的功能,包括:
|
||||
* - 登录状态判断
|
||||
* - Token 的存取
|
||||
* - 记住我功能的状态管理
|
||||
*/
|
||||
export class Auth {
|
||||
/**
|
||||
* 判断用户是否已登录
|
||||
* @returns 是否已登录
|
||||
*/
|
||||
static isLoggedIn(): boolean {
|
||||
return !!Auth.getAccessToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效的访问令牌
|
||||
* 会根据"记住我"状态从适当的存储位置获取
|
||||
* @returns 当前有效的访问令牌
|
||||
*/
|
||||
static getAccessToken(): string {
|
||||
const isRememberMe = Storage.get<boolean>(AUTH_KEYS.REMEMBER_ME, false);
|
||||
// 根据"记住我"状态决定从哪个存储位置获取token
|
||||
return isRememberMe
|
||||
? Storage.get(AUTH_KEYS.ACCESS_TOKEN, "")
|
||||
: Storage.sessionGet(AUTH_KEYS.ACCESS_TOKEN, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取刷新令牌
|
||||
* @returns 当前有效的刷新令牌
|
||||
*/
|
||||
static getRefreshToken(): string {
|
||||
const isRememberMe = Storage.get<boolean>(AUTH_KEYS.REMEMBER_ME, false);
|
||||
return isRememberMe
|
||||
? Storage.get(AUTH_KEYS.REFRESH_TOKEN, "")
|
||||
: Storage.sessionGet(AUTH_KEYS.REFRESH_TOKEN, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置访问令牌和刷新令牌
|
||||
* @param accessToken 访问令牌
|
||||
* @param refreshToken 刷新令牌
|
||||
* @param rememberMe 是否记住我
|
||||
*/
|
||||
static setTokens(accessToken: string, refreshToken: string, rememberMe: boolean): void {
|
||||
// 保存"记住我"状态
|
||||
Storage.set(AUTH_KEYS.REMEMBER_ME, rememberMe);
|
||||
|
||||
if (rememberMe) {
|
||||
// 使用localStorage长期保存
|
||||
Storage.set(AUTH_KEYS.ACCESS_TOKEN, accessToken);
|
||||
Storage.set(AUTH_KEYS.REFRESH_TOKEN, refreshToken);
|
||||
} else {
|
||||
// 使用sessionStorage临时保存
|
||||
Storage.sessionSet(AUTH_KEYS.ACCESS_TOKEN, accessToken);
|
||||
Storage.sessionSet(AUTH_KEYS.REFRESH_TOKEN, refreshToken);
|
||||
// 清除localStorage中可能存在的token
|
||||
Storage.remove(AUTH_KEYS.ACCESS_TOKEN);
|
||||
Storage.remove(AUTH_KEYS.REFRESH_TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有身份验证相关的数据
|
||||
*/
|
||||
static clearAuth(): void {
|
||||
Storage.remove(AUTH_KEYS.ACCESS_TOKEN);
|
||||
Storage.remove(AUTH_KEYS.REFRESH_TOKEN);
|
||||
Storage.sessionRemove(AUTH_KEYS.ACCESS_TOKEN);
|
||||
Storage.sessionRemove(AUTH_KEYS.REFRESH_TOKEN);
|
||||
// 不清除记住我设置,保留用户偏好
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取"记住我"状态
|
||||
* @returns 是否记住我
|
||||
*/
|
||||
static getRememberMe(): boolean {
|
||||
return Storage.get<boolean>(AUTH_KEYS.REMEMBER_ME, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/** Auth utilities (web-style, flat module). */
|
||||
|
||||
const AUTH_KEYS = {
|
||||
ACCESS_TOKEN: "access_token",
|
||||
REFRESH_TOKEN: "refresh_token",
|
||||
REMEMBER_ME: "remember_me",
|
||||
} as const;
|
||||
|
||||
export class Auth {
|
||||
static isLoggedIn(): boolean {
|
||||
return !!Auth.getAccessToken();
|
||||
}
|
||||
|
||||
static getAccessToken(): string {
|
||||
const isRememberMe = Auth.getRememberMe();
|
||||
return isRememberMe
|
||||
? localStorage.getItem(AUTH_KEYS.ACCESS_TOKEN) || ""
|
||||
: sessionStorage.getItem(AUTH_KEYS.ACCESS_TOKEN) || "";
|
||||
}
|
||||
|
||||
static getRefreshToken(): string {
|
||||
const isRememberMe = Auth.getRememberMe();
|
||||
return isRememberMe
|
||||
? localStorage.getItem(AUTH_KEYS.REFRESH_TOKEN) || ""
|
||||
: sessionStorage.getItem(AUTH_KEYS.REFRESH_TOKEN) || "";
|
||||
}
|
||||
|
||||
static setTokens(accessToken: string, refreshToken: string, rememberMe: boolean): void {
|
||||
localStorage.setItem(AUTH_KEYS.REMEMBER_ME, String(rememberMe));
|
||||
|
||||
if (rememberMe) {
|
||||
localStorage.setItem(AUTH_KEYS.ACCESS_TOKEN, accessToken);
|
||||
localStorage.setItem(AUTH_KEYS.REFRESH_TOKEN, refreshToken);
|
||||
} else {
|
||||
sessionStorage.setItem(AUTH_KEYS.ACCESS_TOKEN, accessToken);
|
||||
sessionStorage.setItem(AUTH_KEYS.REFRESH_TOKEN, refreshToken);
|
||||
localStorage.removeItem(AUTH_KEYS.ACCESS_TOKEN);
|
||||
localStorage.removeItem(AUTH_KEYS.REFRESH_TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
static clearAuth(): void {
|
||||
localStorage.removeItem(AUTH_KEYS.ACCESS_TOKEN);
|
||||
localStorage.removeItem(AUTH_KEYS.REFRESH_TOKEN);
|
||||
sessionStorage.removeItem(AUTH_KEYS.ACCESS_TOKEN);
|
||||
sessionStorage.removeItem(AUTH_KEYS.REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
static getRememberMe(): boolean {
|
||||
return localStorage.getItem(AUTH_KEYS.REMEMBER_ME) === "true";
|
||||
}
|
||||
}
|
||||
|
||||
export { AUTH_KEYS };
|
||||
|
||||
import { router } from "@/router";
|
||||
import { useUserStore } from "@stores/modules/user.store";
|
||||
import { ElMessage, ElNotification } from "element-plus";
|
||||
|
||||
/** 登录页跳转进行中,合并并发调用,避免重复通知与重复路由 */
|
||||
let redirectToLoginInFlight: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* 认证失效或需重新登录时跳转登录页:清空本地会话并带上 redirect。
|
||||
* 与 HTTP 拦截器、改密后重登等场景共用;并发只执行一次。
|
||||
*/
|
||||
export async function redirectToLogin(message: string = "请重新登录"): Promise<void> {
|
||||
if (redirectToLoginInFlight) return redirectToLoginInFlight;
|
||||
|
||||
redirectToLoginInFlight = (async () => {
|
||||
try {
|
||||
ElNotification({
|
||||
title: "提示",
|
||||
message,
|
||||
type: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
await useUserStore().resetAllState();
|
||||
|
||||
const currentPath = router.currentRoute.value.fullPath;
|
||||
await router.push(`/login?redirect=${encodeURIComponent(currentPath)}`);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message ?? String(error));
|
||||
} finally {
|
||||
redirectToLoginInFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return redirectToLoginInFlight;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import router from "@/router";
|
||||
import { useUserStoreHook } from "@/store/modules/user.store";
|
||||
import { ElMessage, ElNotification } from "element-plus";
|
||||
|
||||
/** 登录页跳转进行中,合并并发调用,避免重复通知与重复路由 */
|
||||
let redirectToLoginInFlight: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* 认证失效或需重新登录时跳转登录页:清空本地会话并带上 redirect。
|
||||
* 与 HTTP 拦截器、改密后重登等场景共用;并发只执行一次。
|
||||
*/
|
||||
export async function redirectToLogin(message: string = "请重新登录"): Promise<void> {
|
||||
if (redirectToLoginInFlight) {
|
||||
return redirectToLoginInFlight;
|
||||
}
|
||||
redirectToLoginInFlight = (async () => {
|
||||
try {
|
||||
ElNotification({
|
||||
title: "提示",
|
||||
message,
|
||||
type: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
await useUserStoreHook().resetAllState();
|
||||
|
||||
const currentPath = router.currentRoute.value.fullPath;
|
||||
await router.push(`/login?redirect=${encodeURIComponent(currentPath)}`);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message ?? String(error));
|
||||
} finally {
|
||||
redirectToLoginInFlight = null;
|
||||
}
|
||||
})();
|
||||
return redirectToLoginInFlight;
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// 问候语:根据当前小时返回不同问候语
|
||||
export function greetings() {
|
||||
// 当前时间(用于计算问候语)
|
||||
const currentDate = new Date();
|
||||
const hours = currentDate.getHours();
|
||||
if (hours >= 6 && hours < 8) {
|
||||
return "晨起披衣出草堂,轩窗已自喜微凉🌅!";
|
||||
} else if (hours >= 8 && hours < 12) {
|
||||
return `上午好!`;
|
||||
} else if (hours >= 12 && hours < 14) {
|
||||
return `中午好!`;
|
||||
} else if (hours >= 14 && hours < 18) {
|
||||
return `下午好!`;
|
||||
} else if (hours >= 18 && hours < 24) {
|
||||
return `晚上好!`;
|
||||
} else {
|
||||
return "偷偷向银河要了一把碎星,只等你闭上眼睛撒入你的梦中,晚安🌛!";
|
||||
}
|
||||
}
|
||||
|
||||
export function getRangeDate(startDate: string | number | Date, endDate: string | number | Date) {
|
||||
const targetArr = [];
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const startDateInfo = {
|
||||
year: start.getFullYear(),
|
||||
month: start.getMonth() + 1,
|
||||
day: start.getDate(),
|
||||
};
|
||||
const endDateInfo = {
|
||||
year: end.getFullYear(),
|
||||
month: end.getMonth() + 1,
|
||||
day: end.getDate(),
|
||||
};
|
||||
if (startDateInfo.year === endDateInfo.year) {
|
||||
//同年
|
||||
if (startDateInfo.month !== endDateInfo.month) {
|
||||
//同年,不同月份
|
||||
//获取开始时间所在月的月底日期
|
||||
const startMax = new Date(startDateInfo.year, startDateInfo.month, 0).getDate();
|
||||
const endNum = startMax - startDateInfo.day + endDateInfo.day;
|
||||
for (let i = startDateInfo.day; i <= startDateInfo.day + endNum; i++) {
|
||||
if (i > startMax) {
|
||||
targetArr.push(
|
||||
`${endDateInfo.year}-${
|
||||
endDateInfo.month < 10 ? "0" + endDateInfo.month : endDateInfo.month
|
||||
}-${i - startMax < 10 ? "0" + (i - startMax) : i - startMax}`
|
||||
);
|
||||
} else {
|
||||
targetArr.push(
|
||||
`${startDateInfo.year}-${
|
||||
startDateInfo.month < 10 ? "0" + startDateInfo.month : startDateInfo.month
|
||||
}-${i < 10 ? "0" + i : i}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//同年同月
|
||||
for (let i = startDateInfo.day; i <= endDateInfo.day; i++) {
|
||||
targetArr.push(
|
||||
`${startDateInfo.year}-${
|
||||
startDateInfo.month < 10 ? "0" + startDateInfo.month : startDateInfo.month
|
||||
}-${i < 10 ? "0" + i : i}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//不同年 【既然不同年那肯定也不同月】
|
||||
const startMax = new Date(startDateInfo.year, startDateInfo.month, 0).getDate();
|
||||
const endNum = startMax - startDateInfo.day + endDateInfo.day;
|
||||
for (let i = startDateInfo.day; i <= startDateInfo.day + endNum; i++) {
|
||||
if (i > startMax) {
|
||||
targetArr.push(
|
||||
`${endDateInfo.year}-${
|
||||
endDateInfo.month < 10 ? "0" + endDateInfo.month : endDateInfo.month
|
||||
}-${i - startMax < 10 ? "0" + (i - startMax) : i - startMax}`
|
||||
);
|
||||
} else {
|
||||
targetArr.push(
|
||||
`${startDateInfo.year}-${
|
||||
startDateInfo.month < 10 ? "0" + startDateInfo.month : startDateInfo.month
|
||||
}-${i < 10 ? "0" + i : i}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targetArr;
|
||||
}
|
||||
|
||||
export function listToTree(list: any[]) {
|
||||
const map: { [key: string | number]: any } = {};
|
||||
// 创建映射表,保留每个节点的 parent_id 等原始字段
|
||||
list.forEach((item) => {
|
||||
map[item.id] = { ...item };
|
||||
});
|
||||
|
||||
const tree: any[] = [];
|
||||
list.forEach((item) => {
|
||||
const parentId = item.parent_id;
|
||||
if (parentId && map[parentId]) {
|
||||
// 将当前节点加入其父节点的 children 数组中
|
||||
if (!map[parentId].children) {
|
||||
map[parentId].children = [];
|
||||
}
|
||||
map[parentId].children.push(map[item.id]);
|
||||
} else if (parentId === null || parentId === undefined) {
|
||||
// 根节点
|
||||
tree.push(map[item.id]);
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
// 加载部门选项
|
||||
export function formatTree(nodes: any[]): any[] {
|
||||
return nodes.map((node) => {
|
||||
const formattedNode = {
|
||||
value: node.id,
|
||||
label: node.name,
|
||||
disabled: node.status === false || String(node.status) === "false",
|
||||
};
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
Object.assign(formattedNode, { children: formatTree(node.children) });
|
||||
}
|
||||
|
||||
return formattedNode;
|
||||
});
|
||||
}
|
||||
|
||||
export function cloneDeep(obj: any) {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
export function isEmpty(obj: string | null | undefined) {
|
||||
if (obj === undefined || obj === null || obj === "") {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证是否为blob格式
|
||||
export function blobValidate(data: Blob): boolean {
|
||||
return data.type !== "application/json";
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
/**
|
||||
* 通用工具函数模块
|
||||
*
|
||||
* 提供项目中常用的通用工具函数,包括:
|
||||
* - 问候语生成
|
||||
* - 日期范围处理
|
||||
* - 树形结构转换
|
||||
* - 对象深拷贝
|
||||
* - 空值判断
|
||||
* - Blob 格式验证
|
||||
*
|
||||
* @module utils/common
|
||||
*/
|
||||
|
||||
/**
|
||||
* 根据当前时间生成问候语
|
||||
*
|
||||
* 根据当前小时返回不同的问候语,支持中文问候和特殊时段的温馨提示
|
||||
*
|
||||
* @returns {string} 问候语字符串
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const greeting = greetings();
|
||||
* console.log(greeting); // 输出:"上午好!" 或其他时段的问候语
|
||||
* ```
|
||||
*/
|
||||
export function greetings(): string {
|
||||
const currentDate = new Date();
|
||||
const hours = currentDate.getHours();
|
||||
|
||||
if (hours >= 6 && hours < 8) {
|
||||
return "晨起披衣出草堂,轩窗已自喜微凉🌅!";
|
||||
} else if (hours >= 8 && hours < 12) {
|
||||
return "上午好!";
|
||||
} else if (hours >= 12 && hours < 14) {
|
||||
return "中午好!";
|
||||
} else if (hours >= 14 && hours < 18) {
|
||||
return "下午好!";
|
||||
} else if (hours >= 18 && hours < 24) {
|
||||
return "晚上好!";
|
||||
} else {
|
||||
return "偷偷向银河要了一把碎星,只等你闭上眼睛撒入你的梦中,晚安🌛!";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日期范围内的所有日期
|
||||
*
|
||||
* 根据起始日期和结束日期,生成期间所有日期的字符串数组
|
||||
* 支持跨月份和跨年度的日期范围
|
||||
*
|
||||
* @param {string | number | Date} startDate - 起始日期
|
||||
* @param {string | number | Date} endDate - 结束日期
|
||||
* @returns {string[]} 日期字符串数组,格式为 YYYY-MM-DD
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const dates = getRangeDate('2024-01-30', '2024-02-02');
|
||||
* // 输出:['2024-01-30', '2024-01-31', '2024-02-01', '2024-02-02']
|
||||
* ```
|
||||
*/
|
||||
export function getRangeDate(
|
||||
startDate: string | number | Date,
|
||||
endDate: string | number | Date
|
||||
): string[] {
|
||||
const targetArr: string[] = [];
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
const startDateInfo = {
|
||||
year: start.getFullYear(),
|
||||
month: start.getMonth() + 1,
|
||||
day: start.getDate(),
|
||||
};
|
||||
|
||||
const endDateInfo = {
|
||||
year: end.getFullYear(),
|
||||
month: end.getMonth() + 1,
|
||||
day: end.getDate(),
|
||||
};
|
||||
|
||||
if (startDateInfo.year === endDateInfo.year) {
|
||||
if (startDateInfo.month !== endDateInfo.month) {
|
||||
// 同年不同月
|
||||
const startMax = new Date(startDateInfo.year, startDateInfo.month, 0).getDate();
|
||||
const endNum = startMax - startDateInfo.day + endDateInfo.day;
|
||||
|
||||
for (let i = startDateInfo.day; i <= startDateInfo.day + endNum; i++) {
|
||||
if (i > startMax) {
|
||||
targetArr.push(
|
||||
`${endDateInfo.year}-${
|
||||
endDateInfo.month < 10 ? "0" + endDateInfo.month : endDateInfo.month
|
||||
}-${i - startMax < 10 ? "0" + (i - startMax) : i - startMax}`
|
||||
);
|
||||
} else {
|
||||
targetArr.push(
|
||||
`${startDateInfo.year}-${
|
||||
startDateInfo.month < 10 ? "0" + startDateInfo.month : startDateInfo.month
|
||||
}-${i < 10 ? "0" + i : i}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 同年同月
|
||||
for (let i = startDateInfo.day; i <= endDateInfo.day; i++) {
|
||||
targetArr.push(
|
||||
`${startDateInfo.year}-${
|
||||
startDateInfo.month < 10 ? "0" + startDateInfo.month : startDateInfo.month
|
||||
}-${i < 10 ? "0" + i : i}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 不同年
|
||||
const startMax = new Date(startDateInfo.year, startDateInfo.month, 0).getDate();
|
||||
const endNum = startMax - startDateInfo.day + endDateInfo.day;
|
||||
|
||||
for (let i = startDateInfo.day; i <= startDateInfo.day + endNum; i++) {
|
||||
if (i > startMax) {
|
||||
targetArr.push(
|
||||
`${endDateInfo.year}-${
|
||||
endDateInfo.month < 10 ? "0" + endDateInfo.month : endDateInfo.month
|
||||
}-${i - startMax < 10 ? "0" + (i - startMax) : i - startMax}`
|
||||
);
|
||||
} else {
|
||||
targetArr.push(
|
||||
`${startDateInfo.year}-${
|
||||
startDateInfo.month < 10 ? "0" + startDateInfo.month : startDateInfo.month
|
||||
}-${i < 10 ? "0" + i : i}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targetArr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扁平列表转换为树形结构
|
||||
*
|
||||
* 通过 parent_id 字段将扁平数组转换为嵌套的树形结构
|
||||
* 保留原始数据的所有字段
|
||||
*
|
||||
* @param {any[]} list - 扁平列表数据,每个项必须包含 id 和 parent_id 字段
|
||||
* @returns {any[]} 树形结构数组
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const list = [
|
||||
* { id: 1, name: '节点1', parent_id: null },
|
||||
* { id: 2, name: '节点2', parent_id: 1 },
|
||||
* ];
|
||||
* const tree = listToTree(list);
|
||||
* // 输出:[{ id: 1, name: '节点1', parent_id: null, children: [{ id: 2, name: '节点2', parent_id: 1 }] }]
|
||||
* ```
|
||||
*/
|
||||
export function listToTree(list: any[]): any[] {
|
||||
const map: { [key: string | number]: any } = {};
|
||||
|
||||
// 创建映射表,保留每个节点的 parent_id 等原始字段
|
||||
list.forEach((item) => {
|
||||
map[item.id] = { ...item };
|
||||
});
|
||||
|
||||
const tree: any[] = [];
|
||||
|
||||
list.forEach((item) => {
|
||||
const parentId = item.parent_id;
|
||||
|
||||
if (parentId && map[parentId]) {
|
||||
// 将当前节点加入其父节点的 children 数组中
|
||||
if (!map[parentId].children) {
|
||||
map[parentId].children = [];
|
||||
}
|
||||
map[parentId].children.push(map[item.id]);
|
||||
} else if (parentId === null || parentId === undefined) {
|
||||
// 根节点
|
||||
tree.push(map[item.id]);
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化树形结构为级联选择器格式
|
||||
*
|
||||
* 将树形数据转换为适合 Element Plus Cascader 组件使用的格式
|
||||
* 包含 value、label、disabled 和 children 字段
|
||||
*
|
||||
* @param {any[]} nodes - 树形结构数据
|
||||
* @returns {any[]} 格式化后的树形结构
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const nodes = [{ id: 1, name: '部门1', status: true, children: [...] }];
|
||||
* const formatted = formatTree(nodes);
|
||||
* // 输出:[{ value: 1, label: '部门1', disabled: false, children: [...] }]
|
||||
* ```
|
||||
*/
|
||||
export function formatTree(nodes: any[]): any[] {
|
||||
return nodes.map((node) => {
|
||||
const formattedNode: any = {
|
||||
value: node.id,
|
||||
label: node.name,
|
||||
disabled: node.status === false || String(node.status) === "false",
|
||||
};
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
formattedNode.children = formatTree(node.children);
|
||||
}
|
||||
|
||||
return formattedNode;
|
||||
});
|
||||
}
|
||||
|
||||
export function hasClass(ele: HTMLElement, cls: string): boolean {
|
||||
return !!ele.className.match(new RegExp("(\\s|^)" + cls + "(\\s|$)"));
|
||||
}
|
||||
|
||||
export function addClass(ele: HTMLElement, cls: string): void {
|
||||
if (!hasClass(ele, cls)) {
|
||||
ele.className += " " + cls;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeClass(ele: HTMLElement, cls: string): void {
|
||||
if (hasClass(ele, cls)) {
|
||||
const reg = new RegExp("(\\s|^)" + cls + "(\\s|$)");
|
||||
ele.className = ele.className.replace(reg, " ");
|
||||
}
|
||||
}
|
||||
|
||||
export function isExternal(path: string): boolean {
|
||||
return /^(https?:|http?:|mailto:|tel:)/.test(path);
|
||||
}
|
||||
|
||||
export function formatGrowthRate(growthRate: number): string {
|
||||
if (growthRate === 0) return "-";
|
||||
return (
|
||||
Math.abs(growthRate * 100)
|
||||
.toFixed(2)
|
||||
.replace(/\.?0+$/, "") + "%"
|
||||
);
|
||||
}
|
||||
|
||||
export const beautifierConf = {
|
||||
html: {
|
||||
indent_size: "2",
|
||||
indent_char: " ",
|
||||
max_preserve_newlines: "-1",
|
||||
preserve_newlines: false,
|
||||
keep_array_indentation: false,
|
||||
break_chained_methods: false,
|
||||
indent_scripts: "separate",
|
||||
brace_style: "end-expand",
|
||||
space_before_conditional: true,
|
||||
unescape_strings: false,
|
||||
jslint_happy: false,
|
||||
end_with_newline: true,
|
||||
wrap_line_length: "110",
|
||||
indent_inner_html: true,
|
||||
comma_first: false,
|
||||
e4x: true,
|
||||
indent_empty_lines: true,
|
||||
},
|
||||
js: {
|
||||
indent_size: "2",
|
||||
indent_char: " ",
|
||||
max_preserve_newlines: "-1",
|
||||
preserve_newlines: false,
|
||||
keep_array_indentation: false,
|
||||
break_chained_methods: false,
|
||||
indent_scripts: "normal",
|
||||
brace_style: "end-expand",
|
||||
space_before_conditional: true,
|
||||
unescape_strings: false,
|
||||
jslint_happy: true,
|
||||
end_with_newline: true,
|
||||
wrap_line_length: "110",
|
||||
indent_inner_html: true,
|
||||
comma_first: false,
|
||||
e4x: true,
|
||||
indent_empty_lines: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 深拷贝对象
|
||||
*
|
||||
* 使用 JSON 序列化和反序列化实现对象的深拷贝
|
||||
* 注意:此方法不支持函数、Symbol、循环引用等特殊类型
|
||||
*
|
||||
* @param {any} obj - 需要拷贝的对象
|
||||
* @returns {any} 拷贝后的新对象
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const original = { a: 1, b: { c: 2 } };
|
||||
* const copy = cloneDeep(original);
|
||||
* copy.b.c = 3; // 不影响原对象
|
||||
* ```
|
||||
*/
|
||||
export function cloneDeep(obj: any): any {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为空
|
||||
*
|
||||
* 检查字符串是否为 undefined、null 或空字符串
|
||||
*
|
||||
* @param {string | null | undefined} obj - 需要检查的字符串
|
||||
* @returns {boolean} 是否为空
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* isEmpty(''); // true
|
||||
* isEmpty(null); // true
|
||||
* isEmpty(undefined); // true
|
||||
* isEmpty('hello'); // false
|
||||
* ```
|
||||
*/
|
||||
export function isEmpty(obj: string | null | undefined): boolean {
|
||||
return obj === undefined || obj === null || obj === "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证数据是否为 Blob 格式
|
||||
*
|
||||
* 通过检查 Content-Type 是否为 application/json 来判断
|
||||
*
|
||||
* @param {Blob} data - 需要验证的数据
|
||||
* @returns {boolean} 是否为 Blob 格式
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const blob = new Blob(['test'], { type: 'text/plain' });
|
||||
* blobValidate(blob); // true
|
||||
* ```
|
||||
*/
|
||||
export function blobValidate(data: Blob): boolean {
|
||||
return data.type !== "application/json";
|
||||
}
|
||||
|
||||
/** Date helpers (dayjs). */
|
||||
|
||||
import { reactive, toRefs } from "vue";
|
||||
import { tryOnMounted, tryOnUnmounted } from "@vueuse/core";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const DATE_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
|
||||
|
||||
const DATE_FORMAT = "YYYY-MM-DD";
|
||||
|
||||
export function formatToDateTime(
|
||||
date?: dayjs.ConfigType,
|
||||
format: string = DATE_TIME_FORMAT
|
||||
): string {
|
||||
return dayjs(date).format(format);
|
||||
}
|
||||
|
||||
export function formatToDate(date?: dayjs.ConfigType, format: string = DATE_FORMAT): string {
|
||||
return dayjs(date).format(format);
|
||||
}
|
||||
|
||||
export function formatToTime(time?: dayjs.ConfigType, format: string = "HH:mm:ss"): string {
|
||||
return dayjs(time).format(format);
|
||||
}
|
||||
|
||||
export const useNow = (immediate: boolean = true) => {
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
|
||||
const state = reactive({
|
||||
year: 0,
|
||||
month: 0,
|
||||
week: "",
|
||||
day: 0,
|
||||
hour: "",
|
||||
minute: "",
|
||||
second: 0,
|
||||
meridiem: "",
|
||||
});
|
||||
|
||||
const update = () => {
|
||||
const now = dayjs();
|
||||
|
||||
const h = now.format("HH");
|
||||
const m = now.format("mm");
|
||||
const s = now.get("s");
|
||||
|
||||
state.year = now.get("y");
|
||||
state.month = now.get("M") + 1;
|
||||
state.week = "星期" + ["日", "一", "二", "三", "四", "五", "六"][now.day()];
|
||||
state.day = now.get("date");
|
||||
state.hour = h;
|
||||
state.minute = m;
|
||||
state.second = s;
|
||||
state.meridiem = now.format("A");
|
||||
};
|
||||
|
||||
function start(): void {
|
||||
update();
|
||||
clearInterval(timer);
|
||||
timer = setInterval(() => update(), 1000);
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
clearInterval(timer);
|
||||
}
|
||||
|
||||
tryOnMounted(() => {
|
||||
if (immediate) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
tryOnUnmounted(() => {
|
||||
stop();
|
||||
});
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 分页数据全量获取工具
|
||||
*
|
||||
* 用于需要获取全量数据的场景,如数据导出、批量操作等
|
||||
* 自动处理分页逻辑,将多页数据合并为一个完整的数组
|
||||
*
|
||||
* @module utils/fetchAllPages
|
||||
*/
|
||||
|
||||
/**
|
||||
* fetchAllPages 函数的配置选项类型
|
||||
*/
|
||||
export interface FetchAllPagesOptions<T> {
|
||||
/** 每页条数,默认 1000 */
|
||||
pageSize?: number;
|
||||
/** 初始查询条件(会被拷贝后写入 page_no / page_size) */
|
||||
initialQuery: Record<string, unknown>;
|
||||
/** 页码字段名,默认 'page_no' */
|
||||
pageNoKey?: string;
|
||||
/** 每页条数字段名,默认 'page_size' */
|
||||
pageSizeKey?: string;
|
||||
/**
|
||||
* 拉取单页数据的函数
|
||||
* @param query - 查询参数,包含分页信息
|
||||
* @returns Promise<{ total: number; list: T[] }> - 包含总数和当前页数据
|
||||
*/
|
||||
fetchPage: (query: Record<string, unknown>) => Promise<{ total: number; list: T[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按分页拉取全量列表
|
||||
*
|
||||
* 自动遍历所有分页,将数据合并为一个完整的数组返回
|
||||
* 适用于数据导出等需要全量数据的场景
|
||||
*
|
||||
* @template T - 数据项类型
|
||||
* @param {FetchAllPagesOptions<T>} options - 配置选项
|
||||
* @returns {Promise<T[]>} 合并后的全量数据数组
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { fetchAllPages } from '@utils/fetchAllPages';
|
||||
*
|
||||
* // 获取所有用户数据
|
||||
* const allUsers = await fetchAllPages<User>({
|
||||
* pageSize: 500,
|
||||
* initialQuery: { status: 'active' },
|
||||
* fetchPage: async (query) => {
|
||||
* const response = await api.get({ url: '/users', params: query });
|
||||
* return {
|
||||
* total: response.data.total,
|
||||
* list: response.data.list,
|
||||
* };
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function fetchAllPages<T>(options: FetchAllPagesOptions<T>): Promise<T[]> {
|
||||
const pageSize = options.pageSize ?? 1000;
|
||||
const pageNoKey = options.pageNoKey ?? "page_no";
|
||||
const pageSizeKey = options.pageSizeKey ?? "page_size";
|
||||
const query = { ...options.initialQuery };
|
||||
query[pageNoKey] = 1;
|
||||
query[pageSizeKey] = pageSize;
|
||||
const all: T[] = [];
|
||||
|
||||
while (true) {
|
||||
const { total, list } = await options.fetchPage(query);
|
||||
all.push(...list);
|
||||
|
||||
// 当已获取的数据达到总数或当前页为空时停止
|
||||
if (all.length >= total || list.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
query[pageNoKey] = (query[pageNoKey] as number) + 1;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速开始链接管理器
|
||||
*
|
||||
* 管理工作台「我的收藏」功能,支持添加、删除、更新和监听快速链接
|
||||
*
|
||||
* ## 功能特性
|
||||
*
|
||||
* - 支持收藏数量上限(默认 15 个)
|
||||
* - 自动持久化到 localStorage
|
||||
* - 支持观察者模式,数据变化时通知监听者
|
||||
* - 支持从路由信息自动创建链接
|
||||
*
|
||||
* @module utils/quickStartManager
|
||||
*/
|
||||
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
/** 收藏数量上限(工作台「我的收藏」为 3 列 × 最多 5 排,共 15 个) */
|
||||
export const QUICK_LINK_MAX = 15;
|
||||
|
||||
/**
|
||||
* 快速链接数据类型
|
||||
*/
|
||||
export interface QuickLink {
|
||||
/** 链接标题 */
|
||||
title: string;
|
||||
/** 图标名称 */
|
||||
icon: string;
|
||||
/** 跳转路径 */
|
||||
href: string;
|
||||
/** 唯一标识(可选) */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速开始管理器类
|
||||
*
|
||||
* 提供快速链接的增删改查功能,并支持观察者模式
|
||||
*/
|
||||
class QuickStartManager {
|
||||
/** 本地存储键名 */
|
||||
private storageKey = "quick-start-links";
|
||||
|
||||
/** 监听器列表 */
|
||||
private listeners: Array<(links: QuickLink[]) => void> = [];
|
||||
|
||||
/**
|
||||
* 获取所有快速链接
|
||||
*
|
||||
* @returns {QuickLink[]} 快速链接数组
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const links = quickStartManager.getQuickLinks();
|
||||
* console.log(links);
|
||||
* ```
|
||||
*/
|
||||
getQuickLinks(): QuickLink[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
return stored ? JSON.parse(stored) : this.getDefaultLinks();
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick links:", error);
|
||||
return this.getDefaultLinks();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认链接(空数组)
|
||||
*
|
||||
* @returns {QuickLink[]} 默认链接数组
|
||||
*/
|
||||
private getDefaultLinks(): QuickLink[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存快速链接到本地存储
|
||||
*
|
||||
* @param {QuickLink[]} links - 要保存的快速链接数组
|
||||
*/
|
||||
saveQuickLinks(links: QuickLink[]): void {
|
||||
try {
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(links));
|
||||
this.notifyListeners(links);
|
||||
} catch (error) {
|
||||
console.error("Failed to save quick links:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加或更新快速链接
|
||||
*
|
||||
* 如果链接已存在(通过 href 判断),则更新该链接;否则添加新链接
|
||||
* 如果收藏数量已达上限,则提示并返回 false
|
||||
*
|
||||
* @param {QuickLink} link - 要添加或更新的快速链接
|
||||
* @returns {boolean} 是否已保存成功
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const success = quickStartManager.addQuickLink({
|
||||
* title: '仪表盘',
|
||||
* icon: 'dashboard',
|
||||
* href: '/dashboard',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
addQuickLink(link: QuickLink): boolean {
|
||||
const links = this.getQuickLinks();
|
||||
|
||||
// 检查是否已存在相同 href 的链接
|
||||
const existingIndex = links.findIndex((l) => l.href === link.href);
|
||||
if (existingIndex !== -1) {
|
||||
// 更新现有链接
|
||||
links[existingIndex] = { ...links[existingIndex], ...link };
|
||||
ElMessage.success(`已更新快速链接:${link.title}`);
|
||||
this.saveQuickLinks(links);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否达到收藏上限
|
||||
if (links.length >= QUICK_LINK_MAX) {
|
||||
ElMessage.warning(`收藏已满(最多 ${QUICK_LINK_MAX} 个),请先移除后再添加`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 添加新链接
|
||||
links.push(link);
|
||||
this.saveQuickLinks(links);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 id 删除快速链接
|
||||
*
|
||||
* @param {string} id - 要删除的链接 id
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* quickStartManager.removeQuickLink('link-123');
|
||||
* ```
|
||||
*/
|
||||
removeQuickLink(id: string): void {
|
||||
const links = this.getQuickLinks();
|
||||
const filteredLinks = links.filter((link) => link.id !== id);
|
||||
|
||||
if (filteredLinks.length < links.length) {
|
||||
this.saveQuickLinks(filteredLinks);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路由路径删除快速链接
|
||||
*
|
||||
* 用于兼容没有 id 的旧数据
|
||||
*
|
||||
* @param {string} href - 要删除的链接路径
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* quickStartManager.removeQuickLinkByHref('/dashboard');
|
||||
* ```
|
||||
*/
|
||||
removeQuickLinkByHref(href: string): void {
|
||||
const links = this.getQuickLinks();
|
||||
const filteredLinks = links.filter((link) => link.href !== href);
|
||||
if (filteredLinks.length < links.length) {
|
||||
this.saveQuickLinks(filteredLinks);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有快速链接
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* quickStartManager.clearQuickLinks();
|
||||
* ```
|
||||
*/
|
||||
clearQuickLinks(): void {
|
||||
this.saveQuickLinks([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从路由或菜单信息创建快速链接
|
||||
*
|
||||
* @param {any} route - 路由或菜单对象
|
||||
* @param {string} [customTitle] - 自定义标题(可选)
|
||||
* @returns {QuickLink} 创建的快速链接对象
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const link = quickStartManager.createQuickLinkFromRoute(route);
|
||||
* quickStartManager.addQuickLink(link);
|
||||
* ```
|
||||
*/
|
||||
createQuickLinkFromRoute(route: any, customTitle?: string): QuickLink {
|
||||
// 确定最终使用的标题 - 优先使用 customTitle
|
||||
const finalTitle = customTitle || route.title || route.name || "未命名页面";
|
||||
|
||||
return {
|
||||
title: finalTitle,
|
||||
icon: route.icon,
|
||||
href: route.fullPath || route.path,
|
||||
id: `route-${route.path.replace(/\//g, "-")}-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加数据变化监听器
|
||||
*
|
||||
* @param {(links: QuickLink[]) => void} callback - 回调函数,当数据变化时触发
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const callback = (links) => {
|
||||
* console.log('链接列表已更新:', links);
|
||||
* };
|
||||
* quickStartManager.addListener(callback);
|
||||
* ```
|
||||
*/
|
||||
addListener(callback: (links: QuickLink[]) => void): void {
|
||||
this.listeners.push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除数据变化监听器
|
||||
*
|
||||
* @param {(links: QuickLink[]) => void} callback - 要移除的回调函数
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* quickStartManager.removeListener(callback);
|
||||
* ```
|
||||
*/
|
||||
removeListener(callback: (links: QuickLink[]) => void): void {
|
||||
const index = this.listeners.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this.listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知所有监听器数据已变化
|
||||
*
|
||||
* @param {QuickLink[]} links - 当前的快速链接数组
|
||||
*/
|
||||
private notifyListeners(links: QuickLink[]): void {
|
||||
this.listeners.forEach((callback) => {
|
||||
try {
|
||||
callback(links);
|
||||
} catch (error) {
|
||||
console.error("Error in quick start listener:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查链接是否已存在
|
||||
*
|
||||
* @param {string} href - 要检查的链接路径
|
||||
* @returns {boolean} 是否存在
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const exists = quickStartManager.isLinkExists('/dashboard');
|
||||
* ```
|
||||
*/
|
||||
isLinkExists(href: string): boolean {
|
||||
const links = this.getQuickLinks();
|
||||
return links.some((link) => link.href === href);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速开始管理器全局实例
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { quickStartManager } from '@utils/common/quickStartManager';
|
||||
*
|
||||
* // 获取所有链接
|
||||
* const links = quickStartManager.getQuickLinks();
|
||||
*
|
||||
* // 添加新链接
|
||||
* quickStartManager.addQuickLink({ title: '设置', icon: 'settings', href: '/settings' });
|
||||
* ```
|
||||
*/
|
||||
export const quickStartManager = new QuickStartManager();
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Constants (flattened). */
|
||||
|
||||
export const WEB_LINKS = {
|
||||
GITHUB_HOME: "https://github.com/fastapiadmin",
|
||||
GITHUB: "https://github.com/fastapiadmin/FastapiAdmin",
|
||||
/** 备用镜像 / 国内仓库(与旧版顶栏一致时可指向业务 Gitee) */
|
||||
GITEE: "https://gitee.com/fastapiadmin/FastapiAdmin",
|
||||
BLOG: "https://blog.csdn.net/weixin_46768253?type=blog",
|
||||
DOCS: "https://service.fastapiadmin.com/overview/",
|
||||
LiteVersion: "https://gitee.com/fastapiadmin/FastCloud",
|
||||
OldVersion: "https://github.com/fastapiadmin/FastapiAdmin/tree/v2.0.0",
|
||||
COMMUNITY: "https://service.fastapiadmin.com",
|
||||
BILIBILI: "https://space.bilibili.com/425500936?spm_id_from=333.1007.0.0",
|
||||
INTRODUCE: "https://service.fastapiadmin.com",
|
||||
};
|
||||
+2
-4
@@ -1,8 +1,6 @@
|
||||
interface ErrorCodeMap {
|
||||
[key: string]: string;
|
||||
}
|
||||
/** Error code map (flattened). */
|
||||
|
||||
const errorCode: ErrorCodeMap = {
|
||||
const errorCode: Record<string, string> = {
|
||||
"401": "认证失败,无法访问系统资源",
|
||||
"403": "当前操作没有权限",
|
||||
"404": "访问资源不存在",
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 全局常量与错误码文案
|
||||
*/
|
||||
|
||||
export * from "./definitions";
|
||||
export { default as errorCode } from "./error-code";
|
||||
@@ -1,77 +0,0 @@
|
||||
import { reactive, toRefs } from "vue";
|
||||
import { tryOnMounted, tryOnUnmounted } from "@vueuse/core";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const DATE_TIME_FORMAT = "YYYY-MM-DD HH:mm:ss";
|
||||
const DATE_FORMAT = "YYYY-MM-DD";
|
||||
|
||||
export function formatToDateTime(date?: dayjs.ConfigType, format = DATE_TIME_FORMAT): string {
|
||||
return dayjs(date).format(format);
|
||||
}
|
||||
|
||||
export function formatToDate(date?: dayjs.ConfigType, format = DATE_FORMAT): string {
|
||||
return dayjs(date).format(format);
|
||||
}
|
||||
|
||||
export function formatToTime(time?: dayjs.ConfigType, format = "HH:mm:ss"): string {
|
||||
return dayjs(time).format(format);
|
||||
}
|
||||
|
||||
export const useNow = (immediate = true) => {
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
|
||||
const state = reactive({
|
||||
year: 0,
|
||||
month: 0,
|
||||
week: "",
|
||||
day: 0,
|
||||
hour: "",
|
||||
minute: "",
|
||||
second: 0,
|
||||
meridiem: "",
|
||||
});
|
||||
|
||||
const update = () => {
|
||||
const now = dayjs();
|
||||
|
||||
const h = now.format("HH");
|
||||
const m = now.format("mm");
|
||||
const s = now.get("s");
|
||||
|
||||
state.year = now.get("y");
|
||||
state.month = now.get("M") + 1;
|
||||
state.week = "星期" + ["日", "一", "二", "三", "四", "五", "六"][now.day()];
|
||||
state.day = now.get("date");
|
||||
state.hour = h;
|
||||
state.minute = m;
|
||||
state.second = s;
|
||||
|
||||
state.meridiem = now.format("A");
|
||||
};
|
||||
|
||||
function start() {
|
||||
update();
|
||||
clearInterval(timer);
|
||||
timer = setInterval(() => update(), 1000);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearInterval(timer);
|
||||
}
|
||||
|
||||
tryOnMounted(() => {
|
||||
if (immediate) {
|
||||
start();
|
||||
}
|
||||
});
|
||||
|
||||
tryOnUnmounted(() => {
|
||||
stop();
|
||||
});
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
start,
|
||||
stop,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 通用文件下载(axios + blob),非 Vue 插件。
|
||||
*/
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import { ElLoading, ElMessage } from "element-plus";
|
||||
import { saveAs as fileSaverSaveAs } from "file-saver";
|
||||
import { Auth } from "@utils/auth";
|
||||
import { errorCode } from "@utils/constants";
|
||||
import { blobValidate } from "@utils/common";
|
||||
|
||||
const baseURL = import.meta.env.VITE_APP_BASE_API;
|
||||
let downloadLoadingInstance: any;
|
||||
|
||||
interface DownloadUtil {
|
||||
name(name: string, isDelete?: boolean): void;
|
||||
resource(resource: string): void;
|
||||
zip(url: string, name: string): void;
|
||||
saveAs(text: Blob | string, name: string, opts?: any): void;
|
||||
printErrMsg(data: Blob): Promise<void>;
|
||||
}
|
||||
|
||||
const download: DownloadUtil = {
|
||||
name(name: string, isDelete: boolean = true): void {
|
||||
const url =
|
||||
baseURL + "/common/download?fileName=" + encodeURIComponent(name) + "&delete=" + isDelete;
|
||||
axios({
|
||||
method: "get",
|
||||
url,
|
||||
responseType: "blob",
|
||||
headers: { Authorization: "Bearer " + Auth.getAccessToken() },
|
||||
}).then((res: AxiosResponse<Blob>) => {
|
||||
const isBlob = blobValidate(res.data);
|
||||
if (isBlob) {
|
||||
const blob = new Blob([res.data]);
|
||||
download.saveAs(blob, decodeURIComponent(res.headers["download-filename"]));
|
||||
} else {
|
||||
download.printErrMsg(res.data);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
resource(resource: string): void {
|
||||
const url = baseURL + "/common/download/resource?resource=" + encodeURIComponent(resource);
|
||||
axios({
|
||||
method: "get",
|
||||
url,
|
||||
responseType: "blob",
|
||||
headers: { Authorization: "Bearer " + Auth.getAccessToken() },
|
||||
}).then((res: AxiosResponse<Blob>) => {
|
||||
const isBlob = blobValidate(res.data);
|
||||
if (isBlob) {
|
||||
const blob = new Blob([res.data]);
|
||||
download.saveAs(blob, decodeURIComponent(res.headers["download-filename"]));
|
||||
} else {
|
||||
download.printErrMsg(res.data);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
zip(url: string, name: string): void {
|
||||
const fullUrl = baseURL + url;
|
||||
downloadLoadingInstance = ElLoading.service({
|
||||
text: "正在下载数据,请稍候",
|
||||
background: "rgba(0, 0, 0, 0.7)",
|
||||
});
|
||||
axios({
|
||||
method: "get",
|
||||
url: fullUrl,
|
||||
responseType: "blob",
|
||||
headers: { Authorization: "Bearer " + Auth.getAccessToken() },
|
||||
})
|
||||
.then((res: AxiosResponse<Blob>) => {
|
||||
const isBlob = blobValidate(res.data);
|
||||
if (isBlob) {
|
||||
const blob = new Blob([res.data], { type: "application/zip" });
|
||||
download.saveAs(blob, name);
|
||||
} else {
|
||||
download.printErrMsg(res.data);
|
||||
}
|
||||
downloadLoadingInstance.close();
|
||||
})
|
||||
.catch((r: any) => {
|
||||
console.error(r);
|
||||
ElMessage.error("下载文件出现错误,请联系管理员!");
|
||||
downloadLoadingInstance.close();
|
||||
});
|
||||
},
|
||||
|
||||
saveAs(text: Blob | string, name: string, opts?: any): void {
|
||||
fileSaverSaveAs(text, name, opts);
|
||||
},
|
||||
|
||||
async printErrMsg(data: Blob): Promise<void> {
|
||||
const resText = await data.text();
|
||||
const rspObj = JSON.parse(resText);
|
||||
const errMsg = errorCode[rspObj.code] || rspObj.msg || errorCode["default"];
|
||||
ElMessage.error(errMsg);
|
||||
},
|
||||
};
|
||||
|
||||
export default download;
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* 按分页拉取全量列表(用于 ExportModal 等「全量数据」场景)
|
||||
*/
|
||||
export async function fetchAllPages<T>(options: {
|
||||
/** 每页条数,默认 1000 */
|
||||
pageSize?: number;
|
||||
/** 初始查询条件(会拷贝后写入 page_no / page_size) */
|
||||
initialQuery: Record<string, unknown>;
|
||||
/** 页码字段名,默认 page_no */
|
||||
pageNoKey?: string;
|
||||
/** 每页条数字段名,默认 page_size */
|
||||
pageSizeKey?: string;
|
||||
/** 拉取一页,返回 total 与 list */
|
||||
fetchPage: (query: Record<string, unknown>) => Promise<{ total: number; list: T[] }>;
|
||||
}): Promise<T[]> {
|
||||
const pageSize = options.pageSize ?? 1000;
|
||||
const pageNoKey = options.pageNoKey ?? "page_no";
|
||||
const pageSizeKey = options.pageSizeKey ?? "page_size";
|
||||
const query = { ...options.initialQuery };
|
||||
query[pageNoKey] = 1;
|
||||
query[pageSizeKey] = pageSize;
|
||||
const all: T[] = [];
|
||||
while (true) {
|
||||
const { total, list } = await options.fetchPage(query);
|
||||
all.push(...list);
|
||||
if (all.length >= total || list.length === 0) break;
|
||||
query[pageNoKey] = (query[pageNoKey] as number) + 1;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 将 Data URL(base64)转为 {@link File},便于上传裁剪结果。
|
||||
*/
|
||||
export function dataURLToFile(dataURL: string, filename: string): File {
|
||||
const comma = dataURL.indexOf(",");
|
||||
if (comma === -1) {
|
||||
throw new Error("Invalid data URL");
|
||||
}
|
||||
const header = dataURL.slice(0, comma);
|
||||
const base64 = dataURL.slice(comma + 1);
|
||||
const mimeMatch = header.match(/data:(.*?);/);
|
||||
const mime = mimeMatch?.[1] ?? "image/png";
|
||||
|
||||
const binary = atob(base64);
|
||||
const len = binary.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new File([bytes], filename, { type: mime });
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/** Form helpers: validation + responsive layout. */
|
||||
|
||||
// -----------------------------
|
||||
// Responsive layout
|
||||
// -----------------------------
|
||||
|
||||
export type ResponsiveBreakpoint = "xs" | "sm" | "md" | "lg" | "xl";
|
||||
|
||||
interface BreakpointConfig {
|
||||
threshold: number;
|
||||
fallback: number;
|
||||
}
|
||||
|
||||
const BREAKPOINT_CONFIG: Record<ResponsiveBreakpoint, BreakpointConfig | null> = {
|
||||
xs: { threshold: 12, fallback: 24 },
|
||||
sm: { threshold: 12, fallback: 12 },
|
||||
md: { threshold: 8, fallback: 8 },
|
||||
lg: null,
|
||||
xl: null,
|
||||
};
|
||||
|
||||
export function calculateResponsiveSpan(
|
||||
itemSpan: number | undefined,
|
||||
defaultSpan: number,
|
||||
breakpoint: ResponsiveBreakpoint
|
||||
): number {
|
||||
const finalSpan = itemSpan ?? defaultSpan;
|
||||
const config = BREAKPOINT_CONFIG[breakpoint];
|
||||
if (!config) return finalSpan;
|
||||
return finalSpan >= config.threshold ? finalSpan : config.fallback;
|
||||
}
|
||||
|
||||
export function createResponsiveSpanCalculator(defaultSpan: number) {
|
||||
return (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
||||
return calculateResponsiveSpan(itemSpan, defaultSpan, breakpoint);
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Validation
|
||||
// -----------------------------
|
||||
|
||||
export enum PasswordStrength {
|
||||
WEAK = "弱",
|
||||
MEDIUM = "中",
|
||||
STRONG = "强",
|
||||
}
|
||||
|
||||
export function trimSpaces(value: string): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function validatePhone(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
return phoneRegex.test(value.trim());
|
||||
}
|
||||
|
||||
export function validateTelPhone(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const telRegex = /^0\d{2,3}-?\d{7,8}$/;
|
||||
return telRegex.test(value.trim().replace(/\s+/g, ""));
|
||||
}
|
||||
|
||||
export function validateAccount(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const accountRegex = /^[a-zA-Z][a-zA-Z0-9_]{4,19}$/;
|
||||
return accountRegex.test(value.trim());
|
||||
}
|
||||
|
||||
export function validatePassword(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue.length < 6 || trimmedValue.length > 20) return false;
|
||||
|
||||
const hasLetter = /[a-zA-Z]/.test(trimmedValue);
|
||||
const hasNumber = /\d/.test(trimmedValue);
|
||||
return hasLetter && hasNumber;
|
||||
}
|
||||
|
||||
export function validateStrongPassword(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (trimmedValue.length < 8 || trimmedValue.length > 20) return false;
|
||||
|
||||
const hasUpperCase = /[A-Z]/.test(trimmedValue);
|
||||
const hasLowerCase = /[a-z]/.test(trimmedValue);
|
||||
const hasNumber = /\d/.test(trimmedValue);
|
||||
const hasSpecialChar = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(trimmedValue);
|
||||
return hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar;
|
||||
}
|
||||
|
||||
export function getPasswordStrength(value: string): PasswordStrength {
|
||||
if (!value || typeof value !== "string") return PasswordStrength.WEAK;
|
||||
const trimmedValue = value.trim();
|
||||
if (trimmedValue.length < 6) return PasswordStrength.WEAK;
|
||||
|
||||
const hasUpperCase = /[A-Z]/.test(trimmedValue);
|
||||
const hasLowerCase = /[a-z]/.test(trimmedValue);
|
||||
const hasNumber = /\d/.test(trimmedValue);
|
||||
const hasSpecialChar = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(trimmedValue);
|
||||
const typeCount = [hasUpperCase, hasLowerCase, hasNumber, hasSpecialChar].filter(Boolean).length;
|
||||
|
||||
if (typeCount >= 3) return PasswordStrength.STRONG;
|
||||
if (typeCount >= 2) return PasswordStrength.MEDIUM;
|
||||
return PasswordStrength.WEAK;
|
||||
}
|
||||
|
||||
export function validateIPv4Address(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const trimmedValue = value.trim();
|
||||
const ipRegex = /^((25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(25[0-5]|2[0-4]\d|[01]?\d{1,2})$/;
|
||||
|
||||
if (!ipRegex.test(trimmedValue)) return false;
|
||||
|
||||
const segments = trimmedValue.split(".");
|
||||
return segments.every((segment) => {
|
||||
const num = parseInt(segment, 10);
|
||||
return num >= 0 && num <= 255;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateEmail(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
const emailRegex =
|
||||
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
return emailRegex.test(trimmedValue) && trimmedValue.length <= 254;
|
||||
}
|
||||
|
||||
export function validateURL(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
|
||||
try {
|
||||
new URL(value.trim());
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateChineseIDCard(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
const idCardRegex =
|
||||
/^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/;
|
||||
if (!idCardRegex.test(trimmedValue)) return false;
|
||||
|
||||
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
||||
const checkCodes = ["1", "0", "X", "9", "8", "7", "6", "5", "4", "3", "2"];
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 17; i++) sum += parseInt(trimmedValue[i]) * weights[i];
|
||||
|
||||
const checkCode = checkCodes[sum % 11];
|
||||
return trimmedValue[17].toUpperCase() === checkCode;
|
||||
}
|
||||
|
||||
export function validateBankCard(value: string): boolean {
|
||||
if (!value || typeof value !== "string") return false;
|
||||
const trimmedValue = value.trim().replace(/\s+/g, "");
|
||||
|
||||
if (!/^\d{13,19}$/.test(trimmedValue)) return false;
|
||||
|
||||
let sum = 0;
|
||||
let shouldDouble = false;
|
||||
|
||||
for (let i = trimmedValue.length - 1; i >= 0; i--) {
|
||||
let digit = parseInt(trimmedValue[i]);
|
||||
|
||||
if (shouldDouble) {
|
||||
digit *= 2;
|
||||
if (digit > 9) digit = (digit % 10) + 1;
|
||||
}
|
||||
|
||||
sum += digit;
|
||||
shouldDouble = !shouldDouble;
|
||||
}
|
||||
|
||||
return sum % 10 === 0;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Axios 实例与请求 / 响应拦截器
|
||||
*/
|
||||
|
||||
import axios, {
|
||||
type AxiosInstance,
|
||||
type AxiosResponse,
|
||||
type AxiosError,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Auth, redirectToLogin } from "@utils/auth";
|
||||
import { ResultEnum } from "@/enums/api/result.enum";
|
||||
import { $t } from "@/locales";
|
||||
import { defaultConfig, NO_AUTH_FLAG } from "./config";
|
||||
import { ApiStatus, HttpError } from "./error";
|
||||
|
||||
const handleRequest = (config: InternalAxiosRequestConfig) => {
|
||||
const accessToken = Auth.getAccessToken();
|
||||
const auth = config.headers.Authorization;
|
||||
|
||||
if (auth === NO_AUTH_FLAG) {
|
||||
delete config.headers.Authorization;
|
||||
return config;
|
||||
}
|
||||
|
||||
if (!auth && accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const handleRequestError = (error: unknown) => {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
ElMessage.error(msg);
|
||||
return Promise.reject(error);
|
||||
};
|
||||
|
||||
const handleResponse = (response: AxiosResponse<ApiResponse>) => {
|
||||
if (response.config.responseType === "blob") {
|
||||
return response;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
if (data.code !== ResultEnum.SUCCESS) {
|
||||
ElMessage.error(data.msg);
|
||||
return Promise.reject(response);
|
||||
}
|
||||
|
||||
if (
|
||||
response.config.method?.toUpperCase() !== "GET" &&
|
||||
!response.config.url?.includes("login") &&
|
||||
!response.config.url?.includes("logout")
|
||||
) {
|
||||
ElMessage.success(data.msg);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const handleResponseError = async (error: AxiosError<ApiResponse>) => {
|
||||
if (!error.response) {
|
||||
let errorMessage = $t("httpMsg.networkError");
|
||||
if (error.message?.includes("ECONNREFUSED")) {
|
||||
errorMessage = "服务器连接失败,请检查后端服务是否正常运行";
|
||||
} else if (error.message?.includes("timeout")) {
|
||||
errorMessage = "请求超时,请稍后重试";
|
||||
} else if (error.message?.includes("Network Error")) {
|
||||
errorMessage = "网络连接错误,请检查您的网络设置";
|
||||
}
|
||||
console.error("网络请求失败:", error);
|
||||
ElMessage.error(errorMessage);
|
||||
return Promise.reject(new Error(errorMessage));
|
||||
}
|
||||
|
||||
const data = error.response?.data;
|
||||
|
||||
if (error.response?.config.responseType === "blob" && error.response.data instanceof Blob) {
|
||||
try {
|
||||
const text = await new Response(error.response.data).text();
|
||||
const jsonData: ApiResponse = JSON.parse(text);
|
||||
|
||||
if (jsonData.code === ResultEnum.ERROR) {
|
||||
ElMessage.error(jsonData.msg || "请求错误");
|
||||
return Promise.reject(new Error(jsonData.msg || "请求错误"));
|
||||
}
|
||||
if (jsonData.code === ResultEnum.EXCEPTION) {
|
||||
ElMessage.error(jsonData.msg || "服务异常");
|
||||
return Promise.reject(new Error(jsonData.msg || "服务异常"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("请求异常:", e);
|
||||
ElMessage.error("数据解析失败");
|
||||
return Promise.reject(new Error("数据解析失败"));
|
||||
}
|
||||
}
|
||||
|
||||
const status = error.response.status;
|
||||
|
||||
const hasApiCode =
|
||||
data !== undefined &&
|
||||
data !== null &&
|
||||
typeof data === "object" &&
|
||||
"code" in data &&
|
||||
typeof (data as ApiResponse).code === "number";
|
||||
|
||||
if (status === 401 && !hasApiCode) {
|
||||
await redirectToLogin("登录已失效,请重新登录");
|
||||
return Promise.reject(new HttpError("Unauthorized", ApiStatus.unauthorized));
|
||||
}
|
||||
|
||||
if (data?.code === ResultEnum.TOKEN_EXPIRED) {
|
||||
await redirectToLogin("登录已过期,请重新登录");
|
||||
const msg = data.msg || "登录已过期,请重新登录";
|
||||
return Promise.reject(new HttpError(msg, ApiStatus.unauthorized));
|
||||
}
|
||||
if (data?.code === ResultEnum.ERROR) {
|
||||
ElMessage.error(data.msg || "请求错误");
|
||||
return Promise.reject(new Error(data.msg || "请求错误"));
|
||||
}
|
||||
if (data?.code === ResultEnum.UNAUTHORIZED) {
|
||||
ElMessage.error(data.msg || "暂无权限");
|
||||
return Promise.reject(new Error(data.msg || "请求错误"));
|
||||
}
|
||||
if (data?.code === ResultEnum.EXCEPTION) {
|
||||
ElMessage.error(data.msg || "服务异常");
|
||||
return Promise.reject(new Error(data.msg || "服务异常"));
|
||||
}
|
||||
|
||||
ElMessage.error("请求处理失败,请稍后重试");
|
||||
return Promise.reject(new Error("请求处理失败"));
|
||||
};
|
||||
|
||||
const httpRequest: AxiosInstance = axios.create(defaultConfig);
|
||||
|
||||
httpRequest.interceptors.request.use(handleRequest, handleRequestError);
|
||||
httpRequest.interceptors.response.use(handleResponse, handleResponseError);
|
||||
|
||||
export default httpRequest;
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Axios 默认配置与请求相关类型
|
||||
*/
|
||||
|
||||
import type { AxiosRequestConfig } from "axios";
|
||||
import * as qs from "qs";
|
||||
|
||||
export const defaultConfig: AxiosRequestConfig = {
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||
timeout: Number(import.meta.env.VITE_TIMEOUT) || 15000,
|
||||
headers: { "Content-Type": "application/json;charset=utf-8" },
|
||||
paramsSerializer: (params) => qs.stringify(params, { indices: false }),
|
||||
};
|
||||
|
||||
/** 跳过鉴权:与单接口 `headers.Authorization` 约定一致 */
|
||||
export const NO_AUTH_FLAG = "no-auth";
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS";
|
||||
|
||||
export interface ExtendedRequestConfig extends AxiosRequestConfig {
|
||||
skipAuth?: boolean;
|
||||
showSuccessMessage?: boolean;
|
||||
showErrorMessage?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* HTTP 语义状态码、HttpError 与错误辅助函数
|
||||
*/
|
||||
|
||||
import type { AxiosError } from "axios";
|
||||
import { $t } from "@/locales";
|
||||
|
||||
export enum ApiStatus {
|
||||
success = 200,
|
||||
error = 400,
|
||||
unauthorized = 401,
|
||||
forbidden = 403,
|
||||
notFound = 404,
|
||||
methodNotAllowed = 405,
|
||||
requestTimeout = 408,
|
||||
internalServerError = 500,
|
||||
notImplemented = 501,
|
||||
badGateway = 502,
|
||||
serviceUnavailable = 503,
|
||||
gatewayTimeout = 504,
|
||||
httpVersionNotSupported = 505,
|
||||
}
|
||||
|
||||
export interface ErrorLogData {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
timestamp: string;
|
||||
url?: string;
|
||||
method?: string;
|
||||
stack?: string;
|
||||
}
|
||||
|
||||
export class HttpError extends Error {
|
||||
public readonly code: number;
|
||||
public readonly data?: unknown;
|
||||
public readonly timestamp: string;
|
||||
public readonly url?: string;
|
||||
public readonly method?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: number,
|
||||
options?: {
|
||||
data?: unknown;
|
||||
url?: string;
|
||||
method?: string;
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpError";
|
||||
this.code = code;
|
||||
this.data = options?.data;
|
||||
this.timestamp = new Date().toISOString();
|
||||
this.url = options?.url;
|
||||
this.method = options?.method;
|
||||
}
|
||||
|
||||
public toLogData(): ErrorLogData {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
data: this.data,
|
||||
timestamp: this.timestamp,
|
||||
url: this.url,
|
||||
method: this.method,
|
||||
stack: this.stack,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const getErrorMessage = (status: number): string => {
|
||||
const errorMap: Record<number, string> = {
|
||||
[ApiStatus.unauthorized]: "httpMsg.unauthorized",
|
||||
[ApiStatus.forbidden]: "httpMsg.forbidden",
|
||||
[ApiStatus.notFound]: "httpMsg.notFound",
|
||||
[ApiStatus.methodNotAllowed]: "httpMsg.methodNotAllowed",
|
||||
[ApiStatus.requestTimeout]: "httpMsg.requestTimeout",
|
||||
[ApiStatus.internalServerError]: "httpMsg.internalServerError",
|
||||
[ApiStatus.badGateway]: "httpMsg.badGateway",
|
||||
[ApiStatus.serviceUnavailable]: "httpMsg.serviceUnavailable",
|
||||
[ApiStatus.gatewayTimeout]: "httpMsg.gatewayTimeout",
|
||||
};
|
||||
|
||||
return $t(errorMap[status] || "httpMsg.internalServerError");
|
||||
};
|
||||
|
||||
export function handleError(error: AxiosError<ApiResponse>): never {
|
||||
if (error.code === "ERR_CANCELED") {
|
||||
console.warn("Request cancelled:", error.message);
|
||||
throw new HttpError($t("httpMsg.requestCancelled"), ApiStatus.error);
|
||||
}
|
||||
|
||||
const statusCode = error.response?.status;
|
||||
const errorMessage = error.response?.data?.msg || error.message;
|
||||
const requestConfig = error.config;
|
||||
|
||||
if (!error.response) {
|
||||
throw new HttpError($t("httpMsg.networkError"), ApiStatus.error, {
|
||||
url: requestConfig?.url,
|
||||
method: requestConfig?.method?.toUpperCase(),
|
||||
});
|
||||
}
|
||||
|
||||
const message = statusCode
|
||||
? getErrorMessage(statusCode)
|
||||
: errorMessage || $t("httpMsg.requestFailed");
|
||||
throw new HttpError(message, statusCode || ApiStatus.error, {
|
||||
data: error.response.data,
|
||||
url: requestConfig?.url,
|
||||
method: requestConfig?.method?.toUpperCase(),
|
||||
});
|
||||
}
|
||||
|
||||
export function showError(error: HttpError, showMessage: boolean = true): void {
|
||||
import("element-plus").then(({ ElMessage: EM }) => {
|
||||
if (showMessage) {
|
||||
EM.error(error.message);
|
||||
}
|
||||
});
|
||||
console.error("[HTTP Error]", error.toLogData());
|
||||
}
|
||||
|
||||
export function showSuccess(message: string, showMessage: boolean = true): void {
|
||||
import("element-plus").then(({ ElMessage: EM }) => {
|
||||
if (showMessage) {
|
||||
EM.success(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const isHttpError = (error: unknown): error is HttpError => {
|
||||
return error instanceof HttpError;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* HTTP 客户端(配置 / 错误类型 / Axios 实例)
|
||||
*
|
||||
* @module utils/http
|
||||
*/
|
||||
|
||||
export * from "./config";
|
||||
export * from "./error";
|
||||
export { default } from "./client";
|
||||
export type { AxiosInstance } from "axios";
|
||||
@@ -1,12 +0,0 @@
|
||||
// translate router.meta.title, be used in breadcrumb sidebar tagsview
|
||||
import i18n from "@/locales/index";
|
||||
|
||||
export function translateRouteTitle(title: any) {
|
||||
// 判断是否存在国际化配置,如果没有原生返回
|
||||
const hasKey = i18n.global.te("route." + title);
|
||||
if (hasKey) {
|
||||
const translatedTitle = i18n.global.t("route." + title);
|
||||
return translatedTitle;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import i18n from "@/locales";
|
||||
|
||||
export function translateRouteTitle(title: unknown): string {
|
||||
if (typeof title !== "string") return String(title);
|
||||
|
||||
const key = `route.${title}`;
|
||||
if (i18n.global.te(key)) {
|
||||
const t = i18n.global.t as (key: string) => string;
|
||||
return t(key);
|
||||
}
|
||||
return title;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 本地图标资源(`src/assets/images/svg`)
|
||||
*
|
||||
* @module utils/icons
|
||||
*/
|
||||
|
||||
export { resolveLocalIconUrl, hasLocalIconUrl, listLocalIconBasenames } from "./localSvgUrls";
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 加载 `src/assets/images/svg/*.svg` 为 Vite 静态 URL。
|
||||
*
|
||||
* 构建时用 `import.meta.glob(..., { query: '?url' })` 固定打进产物,避免仅靠 Tailwind
|
||||
* 动态类名 `i-svg:*` 时扫描不到资源的问题。
|
||||
*
|
||||
* @module utils/icons/localSvgUrls
|
||||
*/
|
||||
|
||||
const raw = import.meta.glob("../../assets/images/svg/*.svg", {
|
||||
eager: true,
|
||||
query: "?url",
|
||||
import: "default",
|
||||
}) as Record<string, string>;
|
||||
|
||||
/** 文件名(不含 .svg)→ 打包后 URL */
|
||||
const urlByExactName = new Map<string, string>();
|
||||
|
||||
for (const [fullPath, url] of Object.entries(raw)) {
|
||||
const m = fullPath.match(/\/([^/]+)\.svg$/);
|
||||
if (m) {
|
||||
urlByExactName.set(m[1], url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 `assets/images/svg` 下的 SVG **文件名(不含扩展名)** 解析为可给 `<img :src>` 使用的地址。
|
||||
* 支持大小写不敏感匹配(如 `Python` 与 `python.svg`)。
|
||||
*
|
||||
* @param basename 例如 `python`、`file-json`、`menu-home`
|
||||
* @returns 存在则返回 URL,否则 `undefined`
|
||||
*/
|
||||
export function resolveLocalIconUrl(basename: string): string | undefined {
|
||||
const key = basename.trim();
|
||||
if (!key) return undefined;
|
||||
|
||||
const direct = urlByExactName.get(key);
|
||||
if (direct) return direct;
|
||||
|
||||
const lower = key.toLowerCase();
|
||||
for (const [fileBase, url] of urlByExactName.entries()) {
|
||||
if (fileBase.toLowerCase() === lower) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 是否能在本地 icons 目录找到对应 SVG */
|
||||
export function hasLocalIconUrl(basename: string): boolean {
|
||||
return resolveLocalIconUrl(basename) !== undefined;
|
||||
}
|
||||
|
||||
/** 当前构建中包含的所有本地 SVG 基名(不含 `.svg`),已排序 */
|
||||
export function listLocalIconBasenames(): string[] {
|
||||
return [...urlByExactName.keys()].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
+29
-110
@@ -1,120 +1,39 @@
|
||||
/**
|
||||
* Check if an element has a class
|
||||
* @param {HTMLElement} ele
|
||||
* @param {string} cls
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasClass(ele: HTMLElement, cls: string) {
|
||||
return !!ele.className.match(new RegExp("(\\s|^)" + cls + "(\\s|$)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add class to element
|
||||
* @param {HTMLElement} ele
|
||||
* @param {string} cls
|
||||
*/
|
||||
export function addClass(ele: HTMLElement, cls: string) {
|
||||
if (!hasClass(ele, cls)) ele.className += " " + cls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove class from element
|
||||
* @param {HTMLElement} ele
|
||||
* @param {string} cls
|
||||
*/
|
||||
export function removeClass(ele: HTMLElement, cls: string) {
|
||||
if (hasClass(ele, cls)) {
|
||||
const reg = new RegExp("(\\s|^)" + cls + "(\\s|$)");
|
||||
ele.className = ele.className.replace(reg, " ");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是外部链接
|
||||
* 工具与横切能力统一导出入口
|
||||
*
|
||||
* @param {string} path
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
export function isExternal(path: string) {
|
||||
const isExternal = /^(https?:|http?:|mailto:|tel:)/.test(path);
|
||||
return isExternal;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化增长率,保留两位小数 ,并且去掉末尾的0 取绝对值
|
||||
* 目录约定:按领域分子目录,各包以 `index.ts` 为入口(如 `http/`、`storage/`)。
|
||||
*
|
||||
* @param growthRate
|
||||
* @returns
|
||||
* @module utils/index
|
||||
*/
|
||||
export function formatGrowthRate(growthRate: number) {
|
||||
if (growthRate === 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const formattedRate = Math.abs(growthRate * 100)
|
||||
.toFixed(2)
|
||||
.replace(/\.?0+$/, "");
|
||||
return formattedRate + "%";
|
||||
}
|
||||
// 认证 & OAuth
|
||||
export * from "./auth";
|
||||
export * from "./oauth";
|
||||
|
||||
//表单生成相关
|
||||
// 通用
|
||||
export * from "./common";
|
||||
export { default as download } from "./download";
|
||||
export * from "./constants";
|
||||
export * from "./form";
|
||||
export * from "./i18n";
|
||||
|
||||
export function isNumberStr(str: string): boolean {
|
||||
return /^[+-]?(0|([1-9]\d*))(\.\d+)?$/g.test(str);
|
||||
}
|
||||
// 网络
|
||||
export * from "./http";
|
||||
export * from "./socket";
|
||||
|
||||
export function titleCase(str: string): string {
|
||||
return str.replace(/([A-Z])/g, " $1").replace(/^./, function (str) {
|
||||
return str.toUpperCase();
|
||||
});
|
||||
}
|
||||
// 浏览器 / 系统
|
||||
export * from "./storage";
|
||||
export * from "./sys";
|
||||
export * from "./ui";
|
||||
|
||||
export function makeMap(str: string, expectsLowerCase?: boolean) {
|
||||
const map = Object.create(null);
|
||||
const list = str.split(",");
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
map[list[i]] = true;
|
||||
}
|
||||
return expectsLowerCase ? (val: string) => map[val.toLowerCase()] : (val: string) => map[val];
|
||||
}
|
||||
// 路由与导航
|
||||
export * from "./navigation";
|
||||
|
||||
export const beautifierConf = {
|
||||
html: {
|
||||
indent_size: "2",
|
||||
indent_char: " ",
|
||||
max_preserve_newlines: "-1",
|
||||
preserve_newlines: false,
|
||||
keep_array_indentation: false,
|
||||
break_chained_methods: false,
|
||||
indent_scripts: "separate",
|
||||
brace_style: "end-expand",
|
||||
space_before_conditional: true,
|
||||
unescape_strings: false,
|
||||
jslint_happy: false,
|
||||
end_with_newline: true,
|
||||
wrap_line_length: "110",
|
||||
indent_inner_html: true,
|
||||
comma_first: false,
|
||||
e4x: true,
|
||||
indent_empty_lines: true,
|
||||
},
|
||||
js: {
|
||||
indent_size: "2",
|
||||
indent_char: " ",
|
||||
max_preserve_newlines: "-1",
|
||||
preserve_newlines: false,
|
||||
keep_array_indentation: false,
|
||||
break_chained_methods: false,
|
||||
indent_scripts: "normal",
|
||||
brace_style: "end-expand",
|
||||
space_before_conditional: true,
|
||||
unescape_strings: false,
|
||||
jslint_happy: true,
|
||||
end_with_newline: true,
|
||||
wrap_line_length: "110",
|
||||
indent_inner_html: true,
|
||||
comma_first: false,
|
||||
e4x: true,
|
||||
indent_empty_lines: true,
|
||||
},
|
||||
};
|
||||
// 数据展示
|
||||
export * from "./table";
|
||||
|
||||
// 菜单与图标
|
||||
export * from "./menuIcon";
|
||||
|
||||
// 文件
|
||||
export * from "./file/dataUrl";
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { Component } from "vue";
|
||||
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
|
||||
|
||||
/**
|
||||
* 菜单 / IconSelect 共用的图标存值约定(与 `components/IconSelect` 一致):
|
||||
* - Element Plus:`el-icon-{组件名}`,或与 `@element-plus/icons-vue` 导出键一致的裸名(如 `PieChart`,兼容旧库手写)
|
||||
* - 历史自定义 SVG 文件名:原 `assets/images/svg` + `i-svg:` 展示,现由 `menuIcon/remix` 的 `resolveIconForArtSvgIcon` 映射为 Iconify(默认 Remix `ri:`)
|
||||
* - Iconify:`collection:name`(含冒号,如 `ri:home-line`)
|
||||
*/
|
||||
|
||||
export function isElementPlusStoredIcon(icon?: string | null): boolean {
|
||||
const s = icon?.trim();
|
||||
return !!s && s.startsWith("el-icon");
|
||||
}
|
||||
|
||||
/** 与历史 `MenuSearch` 中 `resolveEpMenuIcon` 一致:`pie-chart` → `PieChart` */
|
||||
function kebabSnakeBodyToPascalKey(body: string): string {
|
||||
return body
|
||||
.split(/[-_]/)
|
||||
.filter(Boolean)
|
||||
.map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).toLowerCase())
|
||||
.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析为 Element Plus 图标组件;否则 null(再走 Iconify / Remix 映射)。
|
||||
* 对齐旧版 `layouts/old/components/Menu/components/MenuItemContent.vue`(el-icon / 自定义文件名)
|
||||
* 及 `MenuSearch` 里对 `el-icon-*` 主体的 Pascal 推导。
|
||||
*/
|
||||
export function resolveElementPlusIconComponent(icon?: string | null): Component | null {
|
||||
const ic = icon?.trim();
|
||||
if (!ic) return null;
|
||||
|
||||
const body = isElementPlusStoredIcon(ic) ? ic.replace(/^el-icon-?/i, "").trim() : ic;
|
||||
|
||||
if (!body) return null;
|
||||
|
||||
const mod = ElementPlusIconsVue as Record<string, Component | undefined>;
|
||||
|
||||
let comp = mod[body];
|
||||
if (comp) return comp;
|
||||
|
||||
if (/[-_]/.test(body)) {
|
||||
const pascal = kebabSnakeBodyToPascalKey(body);
|
||||
comp = mod[pascal];
|
||||
if (comp) return comp;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Iconify 完整 id(侧栏 ArtSvgIcon 使用) */
|
||||
export function isIconifyStoredIcon(icon?: string | null): boolean {
|
||||
const s = icon?.trim();
|
||||
return !!s && s.includes(":");
|
||||
}
|
||||
|
||||
function pascalOrPlainToKebab(name: string): string {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
if (!/[A-Z]/.test(trimmed)) {
|
||||
return trimmed.replace(/_/g, "-").toLowerCase();
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.replace(/([a-z\d])([A-Z])/g, "$1-$2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* `el-icon-Xxx` 无法映射到 EP 组件时的兜底,转为 Iconify `ep:`(与 Element Plus 图标集对应)
|
||||
*/
|
||||
export function elementMenuIconToEpIconify(icon: string): string {
|
||||
const name = icon.replace(/^el-icon-/i, "").trim();
|
||||
const kebab = pascalOrPlainToKebab(name);
|
||||
return kebab ? `ep:${kebab}` : "ep:menu";
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史:本地 `assets/images/svg/*.svg` 文件名作菜单存值,配合 `i-svg:` 类展示。
|
||||
* 现统一映射为 Iconify Remix Icon(`ri:`),由 `ArtSvgIcon` 渲染。
|
||||
*/
|
||||
|
||||
const FILE_SUFFIX: Record<string, string> = {
|
||||
close: "ri:file-close-line",
|
||||
copy: "ri:file-copy-line",
|
||||
css: "ri:file-code-line",
|
||||
dir: "ri:folder-line",
|
||||
excel: "ri:file-excel-2-line",
|
||||
exe: "ri:file-settings-line",
|
||||
html: "ri:file-code-line",
|
||||
image: "ri:file-image-line",
|
||||
js: "ri:file-code-line",
|
||||
json: "ri:file-code-line",
|
||||
music: "ri:file-music-line",
|
||||
open: "ri:folder-open-line",
|
||||
other: "ri:file-unknow-line",
|
||||
pdf: "ri:file-pdf-line",
|
||||
ppt: "ri:file-ppt-line",
|
||||
rar: "ri:file-zip-line",
|
||||
txt: "ri:file-text-line",
|
||||
video: "ri:file-video-line",
|
||||
wps: "ri:file-word-line",
|
||||
zip: "ri:file-zip-line",
|
||||
};
|
||||
|
||||
const REMIX_BY_NAME: Record<string, string> = {
|
||||
ai: "ri:robot-2-line",
|
||||
alipay: "ri:alipay-fill",
|
||||
api: "ri:plug-line",
|
||||
arco: "ri:circle-line",
|
||||
"avatar-man": "ri:user-line",
|
||||
"avatar-woman": "ri:user-smile-line",
|
||||
backtop: "ri:arrow-up-circle-line",
|
||||
bell: "ri:notification-3-line",
|
||||
bilibili: "ri:movie-2-line",
|
||||
browser: "ri:chrome-line",
|
||||
captcha: "ri:shield-keyhole-line",
|
||||
cascader: "ri:filter-3-line",
|
||||
client: "ri:computer-line",
|
||||
close: "ri:close-line",
|
||||
close_all: "ri:close-circle-line",
|
||||
close_left: "ri:arrow-left-s-line",
|
||||
close_other: "ri:links-line",
|
||||
close_right: "ri:arrow-right-s-line",
|
||||
cnblogs: "ri:article-line",
|
||||
code: "ri:code-s-slash-line",
|
||||
collapse: "ri:menu-fold-line",
|
||||
csdn: "ri:article-line",
|
||||
dict: "ri:book-2-line",
|
||||
document: "ri:file-text-line",
|
||||
down: "ri:arrow-down-s-line",
|
||||
download: "ri:download-cloud-line",
|
||||
enter: "ri:login-box-line",
|
||||
esc: "ri:close-line",
|
||||
file: "ri:file-text-line",
|
||||
fullscreen: "ri:fullscreen-line",
|
||||
"fullscreen-exit": "ri:fullscreen-exit-line",
|
||||
gitcode: "ri:git-repository-line",
|
||||
gitee: "ri:git-branch-line",
|
||||
github: "ri:github-fill",
|
||||
homepage: "ri:home-4-line",
|
||||
java: "ri:cup-line",
|
||||
juejin: "ri:book-read-line",
|
||||
language: "ri:translate-2",
|
||||
layout_leftbar_close_line: "ri:menu-fold-line",
|
||||
layout_leftbar_open_line: "ri:menu-unfold-line",
|
||||
menu: "ri:menu-line",
|
||||
message: "ri:message-3-line",
|
||||
monitor: "ri:computer-line",
|
||||
project: "ri:projector-line",
|
||||
python: "ri:terminal-box-line",
|
||||
qq: "ri:qq-fill",
|
||||
refresh: "ri:refresh-line",
|
||||
role: "ri:admin-line",
|
||||
search: "ri:search-line",
|
||||
setting: "ri:settings-3-line",
|
||||
size: "ri:font-size-2",
|
||||
sql: "ri:database-2-line",
|
||||
system: "ri:settings-2-line",
|
||||
table: "ri:table-line",
|
||||
time: "ri:time-line",
|
||||
todo: "ri:checkbox-line",
|
||||
tree: "ri:node-tree",
|
||||
typescript: "ri:typescript-line",
|
||||
up: "ri:arrow-up-s-line",
|
||||
upload_file: "ri:upload-cloud-2-line",
|
||||
"upload-file": "ri:upload-cloud-2-line",
|
||||
upload_folder: "ri:folder-upload-line",
|
||||
"upload-folder": "ri:folder-upload-line",
|
||||
user: "ri:user-line",
|
||||
visitor: "ri:user-heart-line",
|
||||
vite: "ri:rocket-line",
|
||||
vue: "ri:vuejs-line",
|
||||
wechat: "ri:wechat-fill",
|
||||
xml: "ri:code-s-slash-line",
|
||||
people: "ri:team-line",
|
||||
|
||||
"menu-about": "ri:information-line",
|
||||
"menu-analyse": "ri:line-chart-line",
|
||||
"menu-crud": "ri:database-2-line",
|
||||
"menu-detail": "ri:file-list-line",
|
||||
"menu-document": "ri:file-text-line",
|
||||
"menu-error": "ri:error-warning-line",
|
||||
"menu-example": "ri:lightbulb-line",
|
||||
"menu-file": "ri:folder-2-line",
|
||||
"menu-form": "ri:file-list-3-line",
|
||||
"menu-gitee": "ri:git-branch-line",
|
||||
"menu-home": "ri:home-4-line",
|
||||
"menu-layout": "ri:layout-line",
|
||||
"menu-multi": "ri:layout-grid-line",
|
||||
"menu-result": "ri:bar-chart-box-line",
|
||||
"menu-system": "ri:settings-3-line",
|
||||
"menu-table": "ri:table-line",
|
||||
"menu-test": "ri:test-tube-line",
|
||||
|
||||
"icon-msg": "ri:message-3-line",
|
||||
"icon-notice": "ri:notification-3-line",
|
||||
"icon-num": "ri:numbers-line",
|
||||
"icon-user": "ri:user-line",
|
||||
"icon-wait": "ri:timer-line",
|
||||
|
||||
"item-angular": "ri:angularjs-line",
|
||||
"item-github": "ri:github-fill",
|
||||
"item-html5": "ri:html5-fill",
|
||||
"item-js": "ri:javascript-line",
|
||||
"item-react": "ri:reactjs-line",
|
||||
"item-vue": "ri:vuejs-line",
|
||||
|
||||
"ai copy": "ri:robot-2-line",
|
||||
"backtop copy": "ri:arrow-up-circle-line",
|
||||
"file copy": "ri:file-text-line",
|
||||
"vue copy": "ri:vuejs-line",
|
||||
"wechat copy": "ri:wechat-fill",
|
||||
};
|
||||
|
||||
function remixForFileKey(key: string): string {
|
||||
const lower = key.toLowerCase();
|
||||
const rest = lower.startsWith("file-") ? lower.slice(5) : lower;
|
||||
return FILE_SUFFIX[rest] ?? "ri:file-text-line";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将历史本地 SVG 文件名解析为 Remix Icon(Iconify `ri:`)。
|
||||
*/
|
||||
export function localSvgNameToRemixIcon(name: string): string {
|
||||
const raw = name.trim();
|
||||
if (!raw) return "ri:apps-line";
|
||||
|
||||
if (REMIX_BY_NAME[raw]) return REMIX_BY_NAME[raw];
|
||||
|
||||
const lower = raw.toLowerCase();
|
||||
if (REMIX_BY_NAME[lower]) return REMIX_BY_NAME[lower];
|
||||
|
||||
if (lower.startsWith("file-") || lower.startsWith("file_")) {
|
||||
return remixForFileKey(lower.replace(/_/g, "-"));
|
||||
}
|
||||
|
||||
if (lower.startsWith("menu-")) {
|
||||
return REMIX_BY_NAME[lower] ?? "ri:menu-add-line";
|
||||
}
|
||||
|
||||
return "ri:apps-line";
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单 / 表格等场景:存值可能是 EP、Iconify、或历史 SVG 文件名 → 统一为 Iconify id。
|
||||
*/
|
||||
export function resolveIconForArtSvgIcon(stored?: string | null): string {
|
||||
const s = stored?.trim() ?? "";
|
||||
if (!s) return "ri:file-3-line";
|
||||
|
||||
if (isIconifyStoredIcon(s)) return s;
|
||||
|
||||
if (resolveElementPlusIconComponent(s)) {
|
||||
return elementMenuIconToEpIconify(isElementPlusStoredIcon(s) ? s : s);
|
||||
}
|
||||
|
||||
if (isElementPlusStoredIcon(s)) {
|
||||
return elementMenuIconToEpIconify(s);
|
||||
}
|
||||
|
||||
return localSvgNameToRemixIcon(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地 SVG URL(与 `@utils/icons` 同源)。
|
||||
* @deprecated 新代码请使用 {@link resolveLocalIconUrl} from `@utils/icons`
|
||||
*/
|
||||
export { resolveLocalIconUrl as resolveMenuLocalSvgUrl } from "@utils/icons";
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Remix / Iconify 解析(与 `menuIcon/index` 同源,便于按需引用)。
|
||||
*/
|
||||
export { resolveIconForArtSvgIcon } from "./index";
|
||||
@@ -0,0 +1,120 @@
|
||||
/** Navigation helpers (flattened). */
|
||||
|
||||
import type { RouteLocationNormalized, RouteRecordRaw } from "vue-router";
|
||||
import { router } from "@/router";
|
||||
import type { AppRouteRecord, AppRouteRecord as AppRouteRecordFromTypes } from "@/types";
|
||||
import i18n, { $t } from "@/locales";
|
||||
import AppConfig from "@/config";
|
||||
import { useWorktabStore } from "@stores/modules/worktab.store";
|
||||
import { useSettingsStore } from "@stores/modules/setting.store";
|
||||
import { IframeRouteManager } from "@/router";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
|
||||
export type AppRouteRecordRaw = RouteRecordRaw & { hidden?: boolean };
|
||||
|
||||
export const setPageTitle = (to: RouteLocationNormalized): void => {
|
||||
const { title } = to.meta;
|
||||
if (!title) return;
|
||||
|
||||
setTimeout(() => {
|
||||
document.title = `${formatMenuTitle(String(title))} - ${AppConfig.systemInfo.name}`;
|
||||
}, 150);
|
||||
};
|
||||
|
||||
export const formatMenuTitle = (title: string): string => {
|
||||
if (!title) return "";
|
||||
|
||||
if (title.startsWith("menus.")) {
|
||||
if (i18n.global.te(title)) return $t(title);
|
||||
return title.split(".").pop() || title;
|
||||
}
|
||||
|
||||
return title;
|
||||
};
|
||||
|
||||
export function isIframe(url: string): boolean {
|
||||
return url.startsWith("/outside/iframe/");
|
||||
}
|
||||
|
||||
export const isNavigableMenuItem = (menuItem: AppRouteRecordFromTypes): boolean => {
|
||||
if (!menuItem.path || !menuItem.path.trim()) return false;
|
||||
return !menuItem.meta?.isHide;
|
||||
};
|
||||
|
||||
const normalizePath = (path: string): string => (path.startsWith("/") ? path : `/${path}`);
|
||||
|
||||
export const getFirstMenuPath = (menuList: AppRouteRecordFromTypes[]): string => {
|
||||
if (!Array.isArray(menuList) || menuList.length === 0) return "";
|
||||
|
||||
for (const menuItem of menuList) {
|
||||
if (!isNavigableMenuItem(menuItem)) continue;
|
||||
|
||||
if (menuItem.children?.length) {
|
||||
const childPath = getFirstMenuPath(menuItem.children);
|
||||
if (childPath) return childPath;
|
||||
}
|
||||
|
||||
return normalizePath(menuItem.path!);
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
export const openExternalLink = (link: string) => window.open(link, "_blank");
|
||||
|
||||
export const handleMenuJump = (item: AppRouteRecord, jumpToFirst: boolean = false) => {
|
||||
const { link, isIframe: menuIsIframe } = item.meta;
|
||||
if (link && !menuIsIframe) return openExternalLink(link);
|
||||
|
||||
if (!jumpToFirst || !item.children?.length) return router.push(item.path);
|
||||
|
||||
const findFirstLeafMenu = (items: AppRouteRecord[]): AppRouteRecord | undefined => {
|
||||
for (const child of items) {
|
||||
if (isNavigableMenuItem(child)) {
|
||||
return child.children?.length ? findFirstLeafMenu(child.children) || child : child;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const firstChild = findFirstLeafMenu(item.children);
|
||||
if (!firstChild) return router.push(item.path);
|
||||
if (firstChild.meta?.link) return openExternalLink(firstChild.meta.link);
|
||||
return router.push(firstChild.path);
|
||||
};
|
||||
|
||||
export const setWorktab = (to: RouteLocationNormalized): void => {
|
||||
const worktabStore = useWorktabStore();
|
||||
const { meta, path, name, params, query } = to;
|
||||
if (meta.isHideTab) return;
|
||||
|
||||
if (isIframe(path)) {
|
||||
const iframeRoute = IframeRouteManager.getInstance().findByPath(to.path);
|
||||
if (!iframeRoute?.meta) return;
|
||||
|
||||
worktabStore.openTab({
|
||||
title: iframeRoute.meta.title,
|
||||
icon: meta.icon as string,
|
||||
path,
|
||||
name: name as string,
|
||||
keepAlive: meta.keepAlive as boolean,
|
||||
params,
|
||||
query,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (useSettingsStore().showWorkTab || path === useCommon().homePath.value) {
|
||||
worktabStore.openTab({
|
||||
title: meta.title as string,
|
||||
icon: meta.icon as string,
|
||||
path,
|
||||
name: name as string,
|
||||
keepAlive: meta.keepAlive as boolean,
|
||||
params,
|
||||
query,
|
||||
fixedTab: meta.fixedTab as boolean,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* NProgress 进度条配置
|
||||
*/
|
||||
import NProgress from "nprogress";
|
||||
import "nprogress/nprogress.css";
|
||||
|
||||
// 进度条
|
||||
NProgress.configure({
|
||||
// 动画方式
|
||||
easing: "ease",
|
||||
// 递增进度条的速度
|
||||
speed: 500,
|
||||
// 是否显示加载ico
|
||||
showSpinner: false,
|
||||
// 自动递增间隔
|
||||
trickleSpeed: 200,
|
||||
// 初始化时的最小百分比
|
||||
minimum: 0.3,
|
||||
});
|
||||
|
||||
export default NProgress;
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { OAuthProvider } from "@/api/module_system/auth";
|
||||
|
||||
/**
|
||||
* 跳转浏览器至后端 OAuth 入口,授权完成后回到 `redirect_uri`(通常为当前站点 /login)。
|
||||
*/
|
||||
export function startOAuthLogin(provider: OAuthProvider): void {
|
||||
const base = (import.meta.env.VITE_APP_BASE_API || "/api/v1").replace(/\/$/, "");
|
||||
const redirectUri = `${window.location.origin}/login`;
|
||||
const url = `${base}/system/auth/oauth/${provider}/login?redirect_uri=${encodeURIComponent(redirectUri)}`;
|
||||
window.location.href = url;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
/** 收藏数量上限(工作台「我的收藏」为 3 列 × 最多 5 排,共 15 个) */
|
||||
export const QUICK_LINK_MAX = 15;
|
||||
|
||||
// 快速链接数据类型
|
||||
export interface QuickLink {
|
||||
title: string;
|
||||
icon: string;
|
||||
href: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// 快速开始管理器类
|
||||
class QuickStartManager {
|
||||
private storageKey = "quick-start-links";
|
||||
private listeners: Array<(links: QuickLink[]) => void> = [];
|
||||
|
||||
// 获取所有快速链接
|
||||
getQuickLinks(): QuickLink[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
return stored ? JSON.parse(stored) : this.getDefaultLinks();
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick links:", error);
|
||||
return this.getDefaultLinks();
|
||||
}
|
||||
}
|
||||
|
||||
// 获取默认链接
|
||||
private getDefaultLinks(): QuickLink[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 保存快速链接
|
||||
saveQuickLinks(links: QuickLink[]): void {
|
||||
try {
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(links));
|
||||
this.notifyListeners(links);
|
||||
} catch (error) {
|
||||
console.error("Failed to save quick links:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加或更新快速链接。
|
||||
* @returns 是否已保存;新增时若已达上限则提示并返回 false
|
||||
*/
|
||||
addQuickLink(link: QuickLink): boolean {
|
||||
const links = this.getQuickLinks();
|
||||
|
||||
const existingIndex = links.findIndex((l) => l.href === link.href);
|
||||
if (existingIndex !== -1) {
|
||||
links[existingIndex] = { ...links[existingIndex], ...link };
|
||||
ElMessage.success(`已更新快速链接:${link.title}`);
|
||||
this.saveQuickLinks(links);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (links.length >= QUICK_LINK_MAX) {
|
||||
ElMessage.warning(`收藏已满(最多 ${QUICK_LINK_MAX} 个),请先移除后再添加`);
|
||||
return false;
|
||||
}
|
||||
|
||||
links.push(link);
|
||||
this.saveQuickLinks(links);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 删除快速链接
|
||||
removeQuickLink(id: string): void {
|
||||
const links = this.getQuickLinks();
|
||||
const filteredLinks = links.filter((link) => link.id !== id);
|
||||
|
||||
if (filteredLinks.length < links.length) {
|
||||
this.saveQuickLinks(filteredLinks);
|
||||
}
|
||||
}
|
||||
|
||||
/** 无 id 的旧数据可按路由路径移除 */
|
||||
removeQuickLinkByHref(href: string): void {
|
||||
const links = this.getQuickLinks();
|
||||
const filteredLinks = links.filter((link) => link.href !== href);
|
||||
if (filteredLinks.length < links.length) {
|
||||
this.saveQuickLinks(filteredLinks);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有快速链接
|
||||
clearQuickLinks(): void {
|
||||
this.saveQuickLinks([]);
|
||||
}
|
||||
// 从路由或菜单信息创建快速链接
|
||||
createQuickLinkFromRoute(route: any, customTitle?: string): QuickLink {
|
||||
// 确定最终使用的标题 - 优先使用route.title
|
||||
const finalTitle = customTitle || route.title || route.name || "未命名页面";
|
||||
|
||||
return {
|
||||
title: finalTitle,
|
||||
icon: route.icon,
|
||||
href: route.fullPath || route.path,
|
||||
id: `route-${route.path.replace(/\//g, "-")}-${Date.now()}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 添加监听器
|
||||
addListener(callback: (links: QuickLink[]) => void): void {
|
||||
this.listeners.push(callback);
|
||||
}
|
||||
|
||||
// 移除监听器
|
||||
removeListener(callback: (links: QuickLink[]) => void): void {
|
||||
const index = this.listeners.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this.listeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 通知所有监听器
|
||||
private notifyListeners(links: QuickLink[]): void {
|
||||
this.listeners.forEach((callback) => {
|
||||
try {
|
||||
callback(links);
|
||||
} catch (error) {
|
||||
console.error("Error in quick start listener:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 检查链接是否已存在
|
||||
isLinkExists(href: string): boolean {
|
||||
const links = this.getQuickLinks();
|
||||
return links.some((link) => link.href === href);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局实例
|
||||
export const quickStartManager = new QuickStartManager();
|
||||
@@ -1,158 +0,0 @@
|
||||
import axios, {
|
||||
type InternalAxiosRequestConfig,
|
||||
type AxiosResponse,
|
||||
type AxiosInstance,
|
||||
type AxiosError,
|
||||
} from "axios";
|
||||
import qs from "qs";
|
||||
import { ResultEnum } from "@/enums/api/result.enum";
|
||||
import { Auth } from "@/utils/auth";
|
||||
import { redirectToLogin } from "@/utils/authRedirect";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
/**
|
||||
* 创建 HTTP 请求实例
|
||||
*/
|
||||
const httpRequest: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API,
|
||||
timeout: import.meta.env.VITE_TIMEOUT,
|
||||
headers: { "Content-Type": "application/json;charset=utf-8" },
|
||||
paramsSerializer: (params) => qs.stringify(params, { indices: false }),
|
||||
});
|
||||
|
||||
/**
|
||||
* 请求拦截器 - 添加 Authorization 头
|
||||
*/
|
||||
httpRequest.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const accessToken = Auth.getAccessToken();
|
||||
const auth = config.headers.Authorization;
|
||||
|
||||
// 显式跳过鉴权(与单接口 headers 约定一致)
|
||||
if (auth === "no-auth") {
|
||||
delete config.headers.Authorization;
|
||||
return config;
|
||||
}
|
||||
|
||||
// 未手动设置 Authorization 时自动附加 Bearer;已设置则保留(避免误删调用方传入的令牌)
|
||||
if (!auth && accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
ElMessage.error(msg);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 响应拦截器 - 统一处理响应和错误
|
||||
*/
|
||||
httpRequest.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
// 如果响应是二进制流,则直接返回(用于文件下载、Excel 导出等)
|
||||
if (response.config.responseType === "blob") {
|
||||
return response;
|
||||
}
|
||||
|
||||
const data = response.data;
|
||||
|
||||
// 检查请求是否失败
|
||||
if (data.code !== ResultEnum.SUCCESS) {
|
||||
ElMessage.error(data.msg);
|
||||
return Promise.reject(response);
|
||||
}
|
||||
|
||||
// 如果请求不是 GET 请求,且不是登录或退出登录接口,请求成功时显示成功提示
|
||||
if (
|
||||
response.config.method?.toUpperCase() !== "GET" &&
|
||||
!response.config.url?.includes("login") &&
|
||||
!response.config.url?.includes("logout")
|
||||
) {
|
||||
ElMessage.success(data.msg);
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError<ApiResponse>) => {
|
||||
// 处理网络错误(连接拒绝、超时等)
|
||||
if (!error.response) {
|
||||
let errorMessage = "网络连接异常";
|
||||
|
||||
// 根据错误类型提供更友好的提示
|
||||
if (error.message?.includes("ECONNREFUSED")) {
|
||||
errorMessage = "服务器连接失败,请检查后端服务是否正常运行";
|
||||
} else if (error.message?.includes("timeout")) {
|
||||
errorMessage = "请求超时,请稍后重试";
|
||||
} else if (error.message?.includes("Network Error")) {
|
||||
errorMessage = "网络连接错误,请检查您的网络设置";
|
||||
}
|
||||
|
||||
console.error("网络请求失败:", error);
|
||||
ElMessage.error(errorMessage);
|
||||
return Promise.reject(new Error(errorMessage));
|
||||
}
|
||||
|
||||
const data = error.response?.data;
|
||||
|
||||
// 处理blob类型的错误响应
|
||||
if (error.response?.config.responseType === "blob" && error.response.data instanceof Blob) {
|
||||
try {
|
||||
// 将blob转换为JSON
|
||||
const text = await new Response(error.response.data).text();
|
||||
const jsonData: ApiResponse = JSON.parse(text);
|
||||
|
||||
if (jsonData.code === ResultEnum.ERROR) {
|
||||
ElMessage.error(jsonData.msg || "请求错误");
|
||||
return Promise.reject(new Error(jsonData.msg || "请求错误"));
|
||||
} else if (jsonData.code === ResultEnum.EXCEPTION) {
|
||||
ElMessage.error(jsonData.msg || "服务异常");
|
||||
return Promise.reject(new Error(jsonData.msg || "服务异常"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("请求异常:", e);
|
||||
// 如果无法解析为JSON,则使用默认错误处理
|
||||
ElMessage.error("数据解析失败");
|
||||
return Promise.reject(new Error("数据解析失败"));
|
||||
}
|
||||
}
|
||||
|
||||
const status = error.response.status;
|
||||
|
||||
/** 是否为后端约定的 JSON 业务码结构 */
|
||||
const hasApiCode =
|
||||
data !== undefined &&
|
||||
data !== null &&
|
||||
typeof data === "object" &&
|
||||
"code" in data &&
|
||||
typeof (data as ApiResponse).code === "number";
|
||||
|
||||
// HTTP 401 且无约定 body(如网关仅返回状态码、HTML、空 body):按登录失效处理
|
||||
if (status === 401 && !hasApiCode) {
|
||||
await redirectToLogin("登录已失效,请重新登录");
|
||||
return Promise.reject(new Error("Unauthorized"));
|
||||
}
|
||||
|
||||
if (data?.code === ResultEnum.TOKEN_EXPIRED) {
|
||||
await redirectToLogin("登录已过期,请重新登录");
|
||||
return Promise.reject(new Error(data.msg));
|
||||
} else if (data?.code === ResultEnum.ERROR) {
|
||||
ElMessage.error(data.msg || "请求错误");
|
||||
return Promise.reject(new Error(data.msg || "请求错误"));
|
||||
} else if (data?.code === ResultEnum.UNAUTHORIZED) {
|
||||
ElMessage.error(data.msg || "暂无权限");
|
||||
return Promise.reject(new Error(data.msg || "请求错误"));
|
||||
} else if (data?.code === ResultEnum.EXCEPTION) {
|
||||
ElMessage.error(data.msg || "服务异常");
|
||||
return Promise.reject(new Error(data.msg || "服务异常"));
|
||||
} else {
|
||||
ElMessage.error("请求处理失败,请稍后重试");
|
||||
return Promise.reject(new Error("请求处理失败"));
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default httpRequest;
|
||||
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* WebSocket 服务管理
|
||||
*
|
||||
* @description
|
||||
* 统一管理应用中的所有 WebSocket 连接
|
||||
* - 字典同步 WebSocket
|
||||
* - 在线用户计数 WebSocket
|
||||
* - 其他业务 WebSocket
|
||||
*
|
||||
* @author fastapiadmin
|
||||
*/
|
||||
|
||||
import { Auth } from "@utils/auth";
|
||||
|
||||
/**
|
||||
* WebSocket 服务实例约定接口
|
||||
*/
|
||||
type WebSocketService = {
|
||||
disconnect?: () => void;
|
||||
closeWebSocket?: () => void;
|
||||
cleanup?: () => void;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* 全局 WebSocket 实例管理
|
||||
*/
|
||||
const websocketInstances = new Map<string, WebSocketService>();
|
||||
|
||||
/**
|
||||
* 防止重复初始化的状态标记
|
||||
*/
|
||||
let isInitialized = false;
|
||||
|
||||
/**
|
||||
* 注册 WebSocket 实例
|
||||
*/
|
||||
export function registerWebSocketInstance(key: string, instance: WebSocketService) {
|
||||
websocketInstances.set(key, instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 实例
|
||||
*/
|
||||
export function getWebSocketInstance(key: string) {
|
||||
return websocketInstances.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 WebSocket 服务
|
||||
*/
|
||||
export function setupWebSocket() {
|
||||
if (isInitialized) {
|
||||
console.warn("[WebSocket] 已初始化,跳过重复初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Auth.getAccessToken()) {
|
||||
console.warn("[WebSocket] 未登录,跳过 WebSocket 初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isInitialized = true;
|
||||
console.log("[WebSocket] 初始化成功");
|
||||
} catch (error) {
|
||||
console.error("[WebSocket] 初始化失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有 WebSocket 连接
|
||||
*/
|
||||
export function cleanupWebSocket() {
|
||||
console.log("[WebSocket] 开始清理连接...");
|
||||
|
||||
websocketInstances.forEach((instance, key) => {
|
||||
try {
|
||||
if (instance.disconnect) {
|
||||
instance.disconnect();
|
||||
} else if (instance.closeWebSocket) {
|
||||
instance.closeWebSocket();
|
||||
} else if (instance.cleanup) {
|
||||
instance.cleanup();
|
||||
}
|
||||
console.log(`[WebSocket] ${key} 已断开`);
|
||||
} catch (error) {
|
||||
console.error(`[WebSocket] ${key} 清理失败:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
websocketInstances.clear();
|
||||
isInitialized = false;
|
||||
console.log("[WebSocket] 清理完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新初始化 WebSocket
|
||||
*/
|
||||
export function reinitializeWebSocket() {
|
||||
cleanupWebSocket();
|
||||
setupWebSocket();
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
cleanupWebSocket();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 客户端类
|
||||
*
|
||||
* 提供 WebSocket 连接管理功能,支持自动重连、心跳检测和消息队列
|
||||
*
|
||||
* @module utils/socket
|
||||
*/
|
||||
|
||||
interface WebSocketOptions {
|
||||
url?: string;
|
||||
messageHandler: (event: MessageEvent) => void;
|
||||
reconnectInterval?: number; // 重连间隔(ms)
|
||||
heartbeatInterval?: number; // 心跳检测间隔(ms)
|
||||
pingInterval?: number; // 发送ping间隔(ms)
|
||||
reconnectTimeout?: number; // 重连超时时间(ms)
|
||||
maxReconnectAttempts?: number; // 最大重连次数
|
||||
connectionTimeout?: number; // 连接建立超时时间(ms)
|
||||
}
|
||||
|
||||
export default class WebSocketClient {
|
||||
private static instance: WebSocketClient | null = null;
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private messageHandler: (event: MessageEvent) => void;
|
||||
private reconnectInterval: number;
|
||||
private heartbeatInterval: number;
|
||||
private pingInterval: number;
|
||||
private reconnectTimeout: number;
|
||||
private maxReconnectAttempts: number;
|
||||
private connectionTimeout: number;
|
||||
private reconnectAttempts: number = 0; // 当前重连次数
|
||||
|
||||
// 消息队列 - 缓存连接建立前的消息
|
||||
private messageQueue: Array<string | ArrayBufferLike | Blob | ArrayBufferView> = [];
|
||||
|
||||
// 定时器
|
||||
private detectionTimer: NodeJS.Timeout | null = null;
|
||||
private timeoutTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private pingTimer: NodeJS.Timeout | null = null;
|
||||
private connectionTimer: NodeJS.Timeout | null = null; // 连接超时定时器
|
||||
|
||||
// 状态标识
|
||||
private isConnected: boolean = false;
|
||||
private isConnecting: boolean = false; // 是否正在连接中
|
||||
private stopReconnect: boolean = false;
|
||||
private isReconnecting: boolean = false;
|
||||
|
||||
private constructor(options: WebSocketOptions) {
|
||||
this.url = options.url || (process.env.VUE_APP_LOGIN_WEBSOCKET as string);
|
||||
this.messageHandler = options.messageHandler;
|
||||
this.reconnectInterval = options.reconnectInterval ?? 20 * 1000; // 默认20秒
|
||||
this.heartbeatInterval = options.heartbeatInterval ?? 5 * 1000; // 默认5秒
|
||||
this.pingInterval = options.pingInterval ?? 10 * 1000; // 默认10秒
|
||||
this.reconnectTimeout = options.reconnectTimeout ?? 30 * 1000; // 默认30秒
|
||||
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10; // 默认最多重连10次
|
||||
this.connectionTimeout = options.connectionTimeout ?? 10 * 1000; // 连接超时10秒
|
||||
}
|
||||
|
||||
// 单例模式获取实例
|
||||
static getInstance(options: WebSocketOptions): WebSocketClient {
|
||||
if (!WebSocketClient.instance) {
|
||||
WebSocketClient.instance = new WebSocketClient(options);
|
||||
} else {
|
||||
// 更新消息处理器
|
||||
WebSocketClient.instance.messageHandler = options.messageHandler;
|
||||
// 如果提供了新的URL,则更新并重新连接
|
||||
if (options.url && WebSocketClient.instance.url !== options.url) {
|
||||
WebSocketClient.instance.url = options.url;
|
||||
WebSocketClient.instance.reconnectAttempts = 0;
|
||||
WebSocketClient.instance.init();
|
||||
}
|
||||
}
|
||||
return WebSocketClient.instance;
|
||||
}
|
||||
|
||||
// 初始化连接
|
||||
init(): void {
|
||||
this.connect(true);
|
||||
}
|
||||
|
||||
private connect(resetReconnectAttempts: boolean = false): void {
|
||||
// 如果正在连接中,不重复连接
|
||||
if (this.isConnecting) {
|
||||
console.log("正在建立WebSocket连接中...");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已连接,不重复连接
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
console.warn("WebSocket连接已存在");
|
||||
this.flushMessageQueue(); // 确保队列中的消息被发送
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isConnecting = true;
|
||||
this.stopReconnect = false;
|
||||
if (resetReconnectAttempts) {
|
||||
this.reconnectAttempts = 0;
|
||||
this.isReconnecting = false;
|
||||
this.clearTimer("reconnectTimer");
|
||||
}
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
// 设置连接超时检测
|
||||
this.clearTimer("connectionTimer");
|
||||
this.connectionTimer = setTimeout(() => {
|
||||
console.error(`WebSocket连接超时 (${this.connectionTimeout}ms):${this.url}`);
|
||||
this.handleConnectionTimeout();
|
||||
}, this.connectionTimeout);
|
||||
|
||||
this.ws.onopen = (event) => this.handleOpen(event);
|
||||
this.ws.onmessage = (event) => this.handleMessage(event);
|
||||
this.ws.onclose = (event) => this.handleClose(event);
|
||||
this.ws.onerror = (event) => this.handleError(event);
|
||||
} catch (error) {
|
||||
console.error("WebSocket初始化失败:", error);
|
||||
this.isConnecting = false;
|
||||
this.reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 处理连接超时
|
||||
private handleConnectionTimeout(): void {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||
console.error("WebSocket连接超时,强制关闭连接");
|
||||
this.ws?.close(1000, "Connection timeout");
|
||||
this.isConnecting = false;
|
||||
this.reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭连接
|
||||
close(force?: boolean): void {
|
||||
this.clearAllTimers();
|
||||
this.stopReconnect = true;
|
||||
this.isReconnecting = false;
|
||||
this.isConnecting = false;
|
||||
|
||||
if (this.ws) {
|
||||
// 1000 表示正常关闭
|
||||
this.ws.close(force ? 1001 : 1000, force ? "Force closed" : "Normal close");
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
// 发送消息 - 增加消息队列
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView, immediate: boolean = false): void {
|
||||
// 如果要求立即发送且未连接,则直接报错
|
||||
if (immediate && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
|
||||
console.error("WebSocket未连接,无法立即发送消息");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果未连接且不要求立即发送,则加入消息队列
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.log("WebSocket未连接,消息已加入队列等待发送");
|
||||
this.messageQueue.push(data);
|
||||
// 如果未在重连中,则尝试重连
|
||||
if (!this.isConnecting && !this.stopReconnect) {
|
||||
this.init();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws.send(data);
|
||||
} catch (error) {
|
||||
console.error("WebSocket发送消息失败:", error);
|
||||
// 发送失败时将消息加入队列,等待重连后重试
|
||||
this.messageQueue.push(data);
|
||||
this.reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 发送队列中的消息
|
||||
private flushMessageQueue(): void {
|
||||
if (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
|
||||
console.log(`发送队列中的${this.messageQueue.length}条消息`);
|
||||
while (this.messageQueue.length > 0) {
|
||||
const data = this.messageQueue.shift();
|
||||
if (data) {
|
||||
try {
|
||||
this.ws?.send(data);
|
||||
} catch (error) {
|
||||
console.error("发送队列消息失败:", error);
|
||||
// 如果发送失败,将消息放回队列头部
|
||||
if (data) this.messageQueue.unshift(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理连接打开
|
||||
private handleOpen(event: Event): void {
|
||||
console.log("WebSocket连接成功", event);
|
||||
this.clearTimer("connectionTimer"); // 清除连接超时定时器
|
||||
this.isConnected = true;
|
||||
this.isConnecting = false;
|
||||
this.isReconnecting = false;
|
||||
this.stopReconnect = false;
|
||||
this.reconnectAttempts = 0; // 重置重连次数
|
||||
this.startHeartbeat();
|
||||
this.startPing();
|
||||
this.flushMessageQueue(); // 发送队列中的消息
|
||||
}
|
||||
|
||||
// 处理收到的消息
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
console.log("收到WebSocket消息:", event);
|
||||
this.resetHeartbeat();
|
||||
this.messageHandler(event);
|
||||
}
|
||||
|
||||
// 处理连接关闭
|
||||
private handleClose(event: CloseEvent): void {
|
||||
console.log(
|
||||
`WebSocket断开: 代码=${event.code}, 原因=${event.reason}, 干净关闭=${event.wasClean}`
|
||||
);
|
||||
|
||||
// 1000 是正常关闭代码
|
||||
const isNormalClose = event.code === 1000;
|
||||
|
||||
this.isConnected = false;
|
||||
this.isConnecting = false;
|
||||
this.clearConnectionTimers();
|
||||
this.ws = null;
|
||||
|
||||
if (!this.stopReconnect && !isNormalClose) {
|
||||
this.reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// 处理错误 - 增加详细错误信息
|
||||
private handleError(event: Event): void {
|
||||
console.error("WebSocket连接错误:");
|
||||
console.error("错误事件:", event);
|
||||
console.error(
|
||||
"当前连接状态:",
|
||||
this.ws?.readyState ? this.getReadyStateText(this.ws.readyState) : "未初始化"
|
||||
);
|
||||
|
||||
this.isConnected = false;
|
||||
this.isConnecting = false;
|
||||
|
||||
// 只有在未停止重连的情况下才尝试重连
|
||||
if (!this.stopReconnect) {
|
||||
this.reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private closeCurrentSocketForReconnect(): void {
|
||||
this.clearConnectionTimers();
|
||||
this.isConnected = false;
|
||||
this.isConnecting = false;
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
|
||||
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
|
||||
this.ws.close(1001, "Reconnect");
|
||||
}
|
||||
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 转换连接状态为文本描述
|
||||
private getReadyStateText(state: number): string {
|
||||
switch (state) {
|
||||
case WebSocket.CONNECTING:
|
||||
return "CONNECTING (0) - 正在连接";
|
||||
case WebSocket.OPEN:
|
||||
return "OPEN (1) - 已连接";
|
||||
case WebSocket.CLOSING:
|
||||
return "CLOSING (2) - 正在关闭";
|
||||
case WebSocket.CLOSED:
|
||||
return "CLOSED (3) - 已关闭";
|
||||
default:
|
||||
return `未知状态 (${state})`;
|
||||
}
|
||||
}
|
||||
|
||||
// 开始心跳检测
|
||||
private startHeartbeat(): void {
|
||||
this.clearTimer("detectionTimer");
|
||||
this.clearTimer("timeoutTimer");
|
||||
|
||||
this.detectionTimer = setTimeout(() => {
|
||||
this.isConnected = this.ws?.readyState === WebSocket.OPEN;
|
||||
|
||||
if (!this.isConnected) {
|
||||
console.warn("WebSocket心跳检测失败,尝试重连");
|
||||
this.reconnect();
|
||||
|
||||
this.timeoutTimer = setTimeout(() => {
|
||||
console.warn("WebSocket重连超时");
|
||||
this.close();
|
||||
}, this.reconnectTimeout);
|
||||
}
|
||||
}, this.heartbeatInterval);
|
||||
}
|
||||
|
||||
// 重置心跳检测
|
||||
private resetHeartbeat(): void {
|
||||
this.clearTimer("detectionTimer");
|
||||
this.clearTimer("timeoutTimer");
|
||||
this.startHeartbeat();
|
||||
}
|
||||
|
||||
// 开始发送ping消息
|
||||
private startPing(): void {
|
||||
this.clearTimer("pingTimer");
|
||||
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||
console.warn("WebSocket未连接,停止发送ping");
|
||||
this.clearTimer("pingTimer");
|
||||
this.reconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws.send("ping");
|
||||
console.log("发送ping消息");
|
||||
} catch (error) {
|
||||
console.error("发送ping消息失败:", error);
|
||||
this.clearTimer("pingTimer");
|
||||
this.reconnect();
|
||||
}
|
||||
}, this.pingInterval);
|
||||
}
|
||||
|
||||
// 重连 - 增加重连次数限制
|
||||
private reconnect(): void {
|
||||
if (this.stopReconnect || this.isConnecting || this.reconnectInterval <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否超过最大重连次数
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error(`已达到最大重连次数(${this.maxReconnectAttempts}),停止重连`);
|
||||
this.close(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
this.isReconnecting = true;
|
||||
this.closeCurrentSocketForReconnect();
|
||||
|
||||
const delay = this.calculateReconnectDelay();
|
||||
console.log(
|
||||
`将在${delay / 1000}秒后尝试重新连接(第${this.reconnectAttempts}/${this.maxReconnectAttempts}次)`
|
||||
);
|
||||
|
||||
this.clearTimer("reconnectTimer");
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
console.log(`尝试重新连接WebSocket(第${this.reconnectAttempts}次)`);
|
||||
this.connect(false);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// 计算重连延迟 - 指数退避策略
|
||||
private calculateReconnectDelay(): number {
|
||||
// 基础延迟 + 随机值,避免多个客户端同时重连
|
||||
const jitter = Math.random() * 1000; // 0-1秒的随机延迟
|
||||
const baseDelay = Math.min(
|
||||
this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
|
||||
this.reconnectInterval * 5
|
||||
);
|
||||
return baseDelay + jitter;
|
||||
}
|
||||
|
||||
// 清除指定定时器
|
||||
private clearTimer(
|
||||
timerName:
|
||||
| "detectionTimer"
|
||||
| "timeoutTimer"
|
||||
| "reconnectTimer"
|
||||
| "pingTimer"
|
||||
| "connectionTimer"
|
||||
): void {
|
||||
if (this[timerName]) {
|
||||
clearTimeout(this[timerName] as NodeJS.Timeout);
|
||||
this[timerName] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 清除所有定时器
|
||||
private clearAllTimers(): void {
|
||||
this.clearConnectionTimers();
|
||||
this.clearTimer("reconnectTimer");
|
||||
}
|
||||
|
||||
private clearConnectionTimers(): void {
|
||||
this.clearTimer("detectionTimer");
|
||||
this.clearTimer("timeoutTimer");
|
||||
this.clearTimer("pingTimer");
|
||||
this.clearTimer("connectionTimer");
|
||||
}
|
||||
|
||||
// 获取当前连接状态
|
||||
get isWebSocketConnected(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
// 获取当前连接状态文本
|
||||
get connectionStatusText(): string {
|
||||
if (this.isConnecting) return "正在连接";
|
||||
if (this.isConnected) return "已连接";
|
||||
if (this.isReconnecting && this.reconnectAttempts > 0)
|
||||
return `重连中(${this.reconnectAttempts}/${this.maxReconnectAttempts})`;
|
||||
return "已断开";
|
||||
}
|
||||
|
||||
// 销毁实例
|
||||
static destroyInstance(): void {
|
||||
if (WebSocketClient.instance) {
|
||||
WebSocketClient.instance.close();
|
||||
WebSocketClient.instance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* 存储工具类
|
||||
* 提供localStorage和sessionStorage操作方法
|
||||
*/
|
||||
export class Storage {
|
||||
/**
|
||||
* localStorage 存储
|
||||
*/
|
||||
static set(key: string, value: any): void {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
static get<T>(key: string, defaultValue?: T): T {
|
||||
const value = localStorage.getItem(key);
|
||||
if (!value) return defaultValue as T;
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
// 如果解析失败,返回原始字符串
|
||||
return value as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
static remove(key: string): void {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage 清空
|
||||
*/
|
||||
static clear(): void {
|
||||
localStorage.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* sessionStorage 存储
|
||||
*/
|
||||
static sessionSet(key: string, value: any): void {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
static sessionGet<T>(key: string, defaultValue?: T): T {
|
||||
const value = sessionStorage.getItem(key);
|
||||
if (!value) return defaultValue as T;
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
// 如果解析失败,返回原始字符串
|
||||
return value as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
static sessionRemove(key: string): void {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
/**
|
||||
* sessionStorage 清空
|
||||
*/
|
||||
static sessionClear(): void {
|
||||
sessionStorage.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
/** LocalStorage compatibility checks + recovery helpers.
|
||||
*
|
||||
* 禁止在此文件顶层 `import "@/router"` / user store:会触发
|
||||
* router → guards → navigation → locales → storage 的循环依赖,
|
||||
* 并在 locales 同步导入 storage 时出现 StorageKeyManager TDZ。
|
||||
* 登出逻辑在 `performSystemLogout` 内动态 import。
|
||||
*/
|
||||
|
||||
/** Storage config + versioned key helpers. */
|
||||
export class StorageConfig {
|
||||
/** 当前应用版本 */
|
||||
static readonly CURRENT_VERSION = __APP_VERSION__;
|
||||
|
||||
/** 存储键前缀 */
|
||||
static readonly STORAGE_PREFIX = "sys-v";
|
||||
|
||||
/** 版本键名 */
|
||||
static readonly VERSION_KEY = "sys-version";
|
||||
|
||||
/** 主题键名(index.html中使用了,如果修改,需要同步修改) */
|
||||
static readonly THEME_KEY = "sys-theme";
|
||||
|
||||
/** 上次登录用户ID键名(用于判断是否为同一用户登录) */
|
||||
static readonly LAST_USER_ID_KEY = "sys-last-user-id";
|
||||
|
||||
/** 响应式布局切换时暂存桌面端菜单类型 */
|
||||
static readonly RESPONSIVE_MENU_TYPE_KEY = "sys-responsive-menu-type";
|
||||
|
||||
/** 跳过升级检查的版本 */
|
||||
static readonly SKIP_UPGRADE_VERSION = "1.0.0";
|
||||
|
||||
/** 升级处理延迟时间(毫秒) */
|
||||
static readonly UPGRADE_DELAY = 1000;
|
||||
|
||||
/** 登出延迟时间(毫秒) */
|
||||
static readonly LOGOUT_DELAY = 1000;
|
||||
|
||||
/**
|
||||
* 生成版本化的存储键名
|
||||
* @param storeId 存储ID
|
||||
* @param version 版本号,默认使用当前版本
|
||||
*/
|
||||
static generateStorageKey(storeId: string, version: string = this.CURRENT_VERSION): string {
|
||||
return `${this.STORAGE_PREFIX}${version}-${storeId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成旧版本的存储键名(不带分隔符)
|
||||
* @param version 版本号,默认使用当前版本
|
||||
*/
|
||||
static generateLegacyKey(version: string = this.CURRENT_VERSION): string {
|
||||
return `${this.STORAGE_PREFIX}${version}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建存储键匹配的正则表达式
|
||||
* @param storeId 存储ID
|
||||
*/
|
||||
static createKeyPattern(storeId: string): RegExp {
|
||||
return new RegExp(`^${this.STORAGE_PREFIX}[^-]+-${storeId}$`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建当前版本存储键匹配的正则表达式
|
||||
*/
|
||||
static createCurrentVersionPattern(): RegExp {
|
||||
return new RegExp(`^${this.STORAGE_PREFIX}${this.CURRENT_VERSION}-`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建任意版本存储键匹配的正则表达式
|
||||
*/
|
||||
static createVersionPattern(): RegExp {
|
||||
return new RegExp(`^${this.STORAGE_PREFIX}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为当前版本的键
|
||||
*/
|
||||
static isCurrentVersionKey(key: string): boolean {
|
||||
return key.startsWith(`${this.STORAGE_PREFIX}${this.CURRENT_VERSION}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为版本化的键
|
||||
*/
|
||||
static isVersionedKey(key: string): boolean {
|
||||
return key.startsWith(this.STORAGE_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从存储键中提取版本号
|
||||
*/
|
||||
static extractVersionFromKey(key: string): string | null {
|
||||
const match = key.match(new RegExp(`^${this.STORAGE_PREFIX}([^-]+)`));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从存储键中提取存储ID
|
||||
*/
|
||||
static extractStoreIdFromKey(key: string): string | null {
|
||||
const match = key.match(new RegExp(`^${this.STORAGE_PREFIX}[^-]+-(.+)$`));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
}
|
||||
|
||||
class StorageCompatibilityManager {
|
||||
/**
|
||||
* 获取系统版本号
|
||||
*/
|
||||
getSystemVersion(): string | null {
|
||||
return localStorage.getItem(StorageConfig.VERSION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统存储数据(兼容旧格式)
|
||||
*/
|
||||
getSystemStorage(): any {
|
||||
const version = this.getSystemVersion() || StorageConfig.CURRENT_VERSION;
|
||||
const legacyKey = StorageConfig.generateLegacyKey(version);
|
||||
const data = localStorage.getItem(legacyKey);
|
||||
return data ? JSON.parse(data) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前版本是否有存储数据
|
||||
*/
|
||||
private hasCurrentVersionStorage(): boolean {
|
||||
const storageKeys = Object.keys(localStorage);
|
||||
const currentVersionPattern = StorageConfig.createCurrentVersionPattern();
|
||||
|
||||
return storageKeys.some(
|
||||
(key) => currentVersionPattern.test(key) && localStorage.getItem(key) !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否存在任何版本的存储数据
|
||||
*/
|
||||
private hasAnyVersionStorage(): boolean {
|
||||
const storageKeys = Object.keys(localStorage);
|
||||
const versionPattern = StorageConfig.createVersionPattern();
|
||||
|
||||
return storageKeys.some(
|
||||
(key) => versionPattern.test(key) && localStorage.getItem(key) !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取旧格式的本地存储数据
|
||||
*/
|
||||
private getLegacyStorageData(): Record<string, any> {
|
||||
try {
|
||||
const systemStorage = this.getSystemStorage();
|
||||
return systemStorage || {};
|
||||
} catch (error) {
|
||||
console.warn("[Storage] 解析旧格式存储数据失败:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示存储错误消息
|
||||
*/
|
||||
private showStorageError(): void {
|
||||
ElMessage({
|
||||
type: "error",
|
||||
offset: 40,
|
||||
duration: 5000,
|
||||
message: "系统检测到本地数据异常,请重新登录系统恢复使用!",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行系统登出
|
||||
*/
|
||||
private performSystemLogout(): void {
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
const [{ router }, { useUserStore }] = await Promise.all([
|
||||
import("@/router"),
|
||||
import("@stores/modules/user.store"),
|
||||
]);
|
||||
useUserStore().logout();
|
||||
await router.push({ name: "Login" });
|
||||
console.info("[Storage] 已执行系统登出");
|
||||
} catch (error) {
|
||||
console.error("[Storage] 系统登出失败:", error);
|
||||
}
|
||||
})();
|
||||
}, StorageConfig.LOGOUT_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理存储异常
|
||||
*/
|
||||
private handleStorageError(): void {
|
||||
this.showStorageError();
|
||||
this.performSystemLogout();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证存储数据完整性
|
||||
* @param requireAuth 是否需要验证登录状态(默认 false)
|
||||
*/
|
||||
validateStorageData(requireAuth: boolean = false): boolean {
|
||||
try {
|
||||
// 优先检查新版本存储结构
|
||||
if (this.hasCurrentVersionStorage()) {
|
||||
// console.debug('[Storage] 发现当前版本存储数据')
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查是否有任何版本的存储数据
|
||||
if (this.hasAnyVersionStorage()) {
|
||||
// console.debug('[Storage] 发现其他版本存储数据,可能需要迁移')
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查旧版本存储结构
|
||||
const legacyData = this.getLegacyStorageData();
|
||||
if (Object.keys(legacyData).length === 0) {
|
||||
// 只有在需要验证登录状态时才执行登出操作
|
||||
if (requireAuth) {
|
||||
console.warn("[Storage] 未发现任何存储数据,需要重新登录");
|
||||
this.performSystemLogout();
|
||||
return false;
|
||||
}
|
||||
// 首次访问或访问静态路由,不需要登出
|
||||
// console.debug('[Storage] 未发现存储数据,首次访问或访问静态路由')
|
||||
return true;
|
||||
}
|
||||
|
||||
console.debug("[Storage] 发现旧版本存储数据");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[Storage] 存储数据验证失败:", error);
|
||||
// 只有在需要验证登录状态时才处理错误
|
||||
if (requireAuth) {
|
||||
this.handleStorageError();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查存储是否为空
|
||||
*/
|
||||
isStorageEmpty(): boolean {
|
||||
// 检查新版本存储结构
|
||||
if (this.hasCurrentVersionStorage()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否有任何版本的存储数据
|
||||
if (this.hasAnyVersionStorage()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查旧版本存储结构
|
||||
const legacyData = this.getLegacyStorageData();
|
||||
return Object.keys(legacyData).length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查存储兼容性
|
||||
* @param requireAuth 是否需要验证登录状态(默认 false)
|
||||
*/
|
||||
checkCompatibility(requireAuth: boolean = false): boolean {
|
||||
try {
|
||||
const isValid = this.validateStorageData(requireAuth);
|
||||
const isEmpty = this.isStorageEmpty();
|
||||
|
||||
if (isValid || isEmpty) {
|
||||
// console.debug('[Storage] 存储兼容性检查通过')
|
||||
return true;
|
||||
}
|
||||
|
||||
console.warn("[Storage] 存储兼容性检查失败");
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("[Storage] 兼容性检查异常:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建存储兼容性管理器实例
|
||||
const storageManager = new StorageCompatibilityManager();
|
||||
|
||||
/**
|
||||
* 获取系统存储数据
|
||||
*/
|
||||
export function getSystemStorage(): any {
|
||||
return storageManager.getSystemStorage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统版本号
|
||||
*/
|
||||
export function getSysVersion(): string | null {
|
||||
return storageManager.getSystemVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证本地存储数据
|
||||
* @param requireAuth 是否需要验证登录状态(默认 false)
|
||||
*/
|
||||
export function validateStorageData(requireAuth: boolean = false): boolean {
|
||||
return storageManager.validateStorageData(requireAuth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查存储兼容性
|
||||
* @param requireAuth 是否需要验证登录状态(默认 false)
|
||||
*/
|
||||
export function checkStorageCompatibility(requireAuth: boolean = false): boolean {
|
||||
return storageManager.checkCompatibility(requireAuth);
|
||||
}
|
||||
|
||||
export class StorageKeyManager {
|
||||
private getCurrentVersionKey(storeId: string): string {
|
||||
return StorageConfig.generateStorageKey(storeId);
|
||||
}
|
||||
|
||||
private hasCurrentVersionData(key: string): boolean {
|
||||
return localStorage.getItem(key) !== null;
|
||||
}
|
||||
|
||||
private findExistingKey(storeId: string): string | null {
|
||||
const storageKeys = Object.keys(localStorage);
|
||||
const pattern = StorageConfig.createKeyPattern(storeId);
|
||||
return storageKeys.find((key) => pattern.test(key) && localStorage.getItem(key)) || null;
|
||||
}
|
||||
|
||||
private migrateData(fromKey: string, toKey: string): void {
|
||||
try {
|
||||
const existingData = localStorage.getItem(fromKey);
|
||||
if (existingData) {
|
||||
localStorage.setItem(toKey, existingData);
|
||||
console.info(`[Storage] 已迁移数据: ${fromKey} → ${toKey}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[Storage] 数据迁移失败: ${fromKey}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
getStorageKey(storeId: string): string {
|
||||
const currentKey = this.getCurrentVersionKey(storeId);
|
||||
if (this.hasCurrentVersionData(currentKey)) return currentKey;
|
||||
|
||||
const existingKey = this.findExistingKey(storeId);
|
||||
if (existingKey) this.migrateData(existingKey, currentKey);
|
||||
return currentKey;
|
||||
}
|
||||
}
|
||||
|
||||
export class Storage {
|
||||
/**
|
||||
* localStorage 存储数据
|
||||
*
|
||||
* 将数据序列化为 JSON 字符串后存储到 localStorage
|
||||
*
|
||||
* @param {string} key - 存储键名
|
||||
* @param {any} value - 要存储的数据
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Storage.set('user', { name: 'John', age: 25 });
|
||||
* ```
|
||||
*/
|
||||
static set(key: string, value: any): void {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage 读取数据
|
||||
*
|
||||
* 从 localStorage 读取数据并反序列化为指定类型
|
||||
* 如果解析失败,返回原始字符串
|
||||
*
|
||||
* @param {string} key - 存储键名
|
||||
* @param {T} [defaultValue] - 默认值,当键不存在时返回
|
||||
* @returns {T} 解析后的数据或默认值
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const user = Storage.get<User>('user', { name: '', age: 0 });
|
||||
* ```
|
||||
*/
|
||||
static get<T>(key: string, defaultValue?: T): T {
|
||||
const value = localStorage.getItem(key);
|
||||
if (!value) return defaultValue as T;
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
// 如果解析失败,返回原始字符串
|
||||
return value as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage 删除数据
|
||||
*
|
||||
* 从 localStorage 中删除指定键的数据
|
||||
*
|
||||
* @param {string} key - 要删除的键名
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Storage.remove('user');
|
||||
* ```
|
||||
*/
|
||||
static remove(key: string): void {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* localStorage 清空所有数据
|
||||
*
|
||||
* 清除 localStorage 中的所有数据
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Storage.clear();
|
||||
* ```
|
||||
*/
|
||||
static clear(): void {
|
||||
localStorage.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* sessionStorage 存储数据
|
||||
*
|
||||
* 将数据序列化为 JSON 字符串后存储到 sessionStorage
|
||||
*
|
||||
* @param {string} key - 存储键名
|
||||
* @param {any} value - 要存储的数据
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Storage.sessionSet('temp', { token: 'abc123' });
|
||||
* ```
|
||||
*/
|
||||
static sessionSet(key: string, value: any): void {
|
||||
sessionStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* sessionStorage 读取数据
|
||||
*
|
||||
* 从 sessionStorage 读取数据并反序列化为指定类型
|
||||
* 如果解析失败,返回原始字符串
|
||||
*
|
||||
* @param {string} key - 存储键名
|
||||
* @param {T} [defaultValue] - 默认值,当键不存在时返回
|
||||
* @returns {T} 解析后的数据或默认值
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const temp = Storage.sessionGet<string>('temp', '');
|
||||
* ```
|
||||
*/
|
||||
static sessionGet<T>(key: string, defaultValue?: T): T {
|
||||
const value = sessionStorage.getItem(key);
|
||||
if (!value) return defaultValue as T;
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
// 如果解析失败,返回原始字符串
|
||||
return value as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* sessionStorage 删除数据
|
||||
*
|
||||
* 从 sessionStorage 中删除指定键的数据
|
||||
*
|
||||
* @param {string} key - 要删除的键名
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Storage.sessionRemove('temp');
|
||||
* ```
|
||||
*/
|
||||
static sessionRemove(key: string): void {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* sessionStorage 清空所有数据
|
||||
*
|
||||
* 清除 sessionStorage 中的所有数据
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Storage.sessionClear();
|
||||
* ```
|
||||
*/
|
||||
static sessionClear(): void {
|
||||
sessionStorage.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/** System helpers (flattened). */
|
||||
|
||||
import type { App } from "vue";
|
||||
import mitt, { type Emitter } from "mitt";
|
||||
import { upgradeLogList } from "@/mock/upgrade/changeLog";
|
||||
import { ElNotification } from "element-plus";
|
||||
import { useUserStore } from "@stores/modules/user.store";
|
||||
import { StorageConfig } from "@utils/storage";
|
||||
|
||||
// -----------------------------
|
||||
// Console banner
|
||||
// -----------------------------
|
||||
|
||||
export function printConsoleBanner(): void {
|
||||
// ANSI escape codes via https://patorjk.com/software/taag/#p=display&f=Big&t=ABB%0A
|
||||
const asciiArt = `
|
||||
\x1b[32m欢迎使用 Fastapi Admin!
|
||||
\x1b[0m
|
||||
\x1b[36m哇!你居然在用我的项目~ 好用的话别忘了去 GitHub 点个 ★Star 呀,你的支持就是我更新的超强动力!祝使用体验满分💯
|
||||
\x1b[0m
|
||||
\x1b[33mGitHub: https://github.com/fastapiadmin/FastapiAdmin
|
||||
\x1b[0m
|
||||
\x1b[31m技术支持(社区群): https://service.fastapiadmin.com/about/,和开发者一起交流~ 群里有小伙伴实时答疑,遇到问题不用慌!
|
||||
\x1b[0m
|
||||
`;
|
||||
|
||||
console.log(asciiArt);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Mitt bus
|
||||
// -----------------------------
|
||||
|
||||
type SysEvents = {
|
||||
triggerFireworks: string | undefined;
|
||||
openSetting: void;
|
||||
openSearchDialog: void;
|
||||
openChat: void;
|
||||
openLockScreen: void;
|
||||
};
|
||||
|
||||
export const mittBus: Emitter<SysEvents> = mitt<SysEvents>();
|
||||
|
||||
// -----------------------------
|
||||
// Error handling
|
||||
// -----------------------------
|
||||
|
||||
const IGNORABLE_SCRIPT_ERRORS = [
|
||||
"ResizeObserver loop completed with undelivered notifications.",
|
||||
"ResizeObserver loop limit exceeded",
|
||||
];
|
||||
|
||||
function normalizeErrorMessage(message: Event | string): string {
|
||||
if (typeof message === "string") return message;
|
||||
if ("message" in message && typeof message.message === "string") return message.message;
|
||||
return "";
|
||||
}
|
||||
|
||||
function isIgnorableScriptError(message: Event | string, source?: string): boolean {
|
||||
const normalizedMessage = normalizeErrorMessage(message);
|
||||
if (!normalizedMessage) return false;
|
||||
|
||||
if (IGNORABLE_SCRIPT_ERRORS.some((item) => normalizedMessage.includes(item))) {
|
||||
// 浏览器/扩展在布局抖动时常见的 ResizeObserver 噪声,不作为真实异常处理
|
||||
return true;
|
||||
}
|
||||
|
||||
// 浏览器扩展注入脚本偶发的跨域 Script error 也没有排查价值
|
||||
if (normalizedMessage === "Script error." && source === "") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function vueErrorHandler(err: unknown, instance: any, info: string) {
|
||||
console.error("[VueError]", err, info, instance);
|
||||
}
|
||||
|
||||
export function scriptErrorHandler(
|
||||
message: Event | string,
|
||||
source?: string,
|
||||
lineno?: number,
|
||||
colno?: number,
|
||||
error?: Error
|
||||
): boolean {
|
||||
if (isIgnorableScriptError(message, source)) return true;
|
||||
console.error("[ScriptError]", { message, source, lineno, colno, error });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerPromiseErrorHandler() {
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
console.error("[PromiseError]", event.reason);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerResourceErrorHandler() {
|
||||
window.addEventListener(
|
||||
"error",
|
||||
(event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "IMG" || target.tagName === "SCRIPT" || target.tagName === "LINK")
|
||||
) {
|
||||
console.error("[ResourceError]", {
|
||||
tagName: target.tagName,
|
||||
src:
|
||||
(target as HTMLImageElement).src ||
|
||||
(target as HTMLScriptElement).src ||
|
||||
(target as HTMLLinkElement).href,
|
||||
});
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
export function initErrorHandle(app: App) {
|
||||
app.config.errorHandler = vueErrorHandler;
|
||||
window.onerror = scriptErrorHandler;
|
||||
registerPromiseErrorHandler();
|
||||
registerResourceErrorHandler();
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Upgrade
|
||||
// -----------------------------
|
||||
|
||||
class VersionManager {
|
||||
private normalizeVersion(version: string): string {
|
||||
return version.replace(/^v/, "");
|
||||
}
|
||||
|
||||
private getStoredVersion(): string | null {
|
||||
return localStorage.getItem(StorageConfig.VERSION_KEY);
|
||||
}
|
||||
|
||||
private setStoredVersion(version: string): void {
|
||||
localStorage.setItem(StorageConfig.VERSION_KEY, version);
|
||||
}
|
||||
|
||||
private shouldSkipUpgrade(): boolean {
|
||||
return StorageConfig.CURRENT_VERSION === StorageConfig.SKIP_UPGRADE_VERSION;
|
||||
}
|
||||
|
||||
private isFirstVisit(storedVersion: string | null): boolean {
|
||||
return !storedVersion;
|
||||
}
|
||||
|
||||
private isSameVersion(storedVersion: string): boolean {
|
||||
return storedVersion === StorageConfig.CURRENT_VERSION;
|
||||
}
|
||||
|
||||
private findLegacyStorage(): { oldSysKey: string | null; oldVersionKeys: string[] } {
|
||||
const storageKeys = Object.keys(localStorage);
|
||||
const currentVersionPrefix = StorageConfig.generateStorageKey("").slice(0, -1);
|
||||
|
||||
const oldSysKey =
|
||||
storageKeys.find(
|
||||
(key) =>
|
||||
StorageConfig.isVersionedKey(key) && key !== currentVersionPrefix && !key.includes("-")
|
||||
) || null;
|
||||
|
||||
const oldVersionKeys = storageKeys.filter(
|
||||
(key) =>
|
||||
StorageConfig.isVersionedKey(key) &&
|
||||
!StorageConfig.isCurrentVersionKey(key) &&
|
||||
key.includes("-")
|
||||
);
|
||||
|
||||
return { oldSysKey, oldVersionKeys };
|
||||
}
|
||||
|
||||
private shouldRequireReLogin(storedVersion: string): boolean {
|
||||
const normalizedCurrent = this.normalizeVersion(StorageConfig.CURRENT_VERSION);
|
||||
const normalizedStored = this.normalizeVersion(storedVersion);
|
||||
|
||||
return upgradeLogList.value.some((item) => {
|
||||
const itemVersion = this.normalizeVersion(item.version);
|
||||
return (
|
||||
item.requireReLogin && itemVersion > normalizedStored && itemVersion <= normalizedCurrent
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private buildUpgradeMessage(requireReLogin: boolean): string {
|
||||
const { title: content } = upgradeLogList.value[0];
|
||||
const messageParts = [
|
||||
`<p style="color: var(--art-gray-800) !important; padding-bottom: 5px;">`,
|
||||
`系统已升级到 ${StorageConfig.CURRENT_VERSION} 版本,此次更新带来了以下改进:`,
|
||||
`</p>`,
|
||||
content,
|
||||
];
|
||||
|
||||
if (requireReLogin) {
|
||||
messageParts.push(
|
||||
`<p style="color: var(--theme-color); padding-top: 5px;">升级完成,请重新登录后继续使用。</p>`
|
||||
);
|
||||
}
|
||||
|
||||
return messageParts.join("");
|
||||
}
|
||||
|
||||
private showUpgradeNotification(message: string): void {
|
||||
ElNotification({
|
||||
title: "系统升级公告",
|
||||
message,
|
||||
duration: 0,
|
||||
type: "success",
|
||||
dangerouslyUseHTMLString: true,
|
||||
});
|
||||
}
|
||||
|
||||
private cleanupLegacyData(oldSysKey: string | null, oldVersionKeys: string[]): void {
|
||||
if (oldSysKey) {
|
||||
localStorage.removeItem(oldSysKey);
|
||||
console.info(`[Upgrade] 已清理旧存储: ${oldSysKey}`);
|
||||
}
|
||||
|
||||
oldVersionKeys.forEach((key) => {
|
||||
localStorage.removeItem(key);
|
||||
console.info(`[Upgrade] 已清理旧存储: ${key}`);
|
||||
});
|
||||
}
|
||||
|
||||
private performLogout(): void {
|
||||
try {
|
||||
useUserStore().logout();
|
||||
console.info("[Upgrade] 已执行升级后登出");
|
||||
} catch (error) {
|
||||
console.error("[Upgrade] 升级后登出失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async executeUpgrade(
|
||||
storedVersion: string,
|
||||
legacyStorage: ReturnType<typeof this.findLegacyStorage>
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!upgradeLogList.value.length) {
|
||||
console.warn("[Upgrade] 升级日志列表为空");
|
||||
return;
|
||||
}
|
||||
|
||||
const requireReLogin = this.shouldRequireReLogin(storedVersion);
|
||||
const message = this.buildUpgradeMessage(requireReLogin);
|
||||
|
||||
this.showUpgradeNotification(message);
|
||||
this.setStoredVersion(StorageConfig.CURRENT_VERSION);
|
||||
this.cleanupLegacyData(legacyStorage.oldSysKey, legacyStorage.oldVersionKeys);
|
||||
if (requireReLogin) this.performLogout();
|
||||
|
||||
console.info(`[Upgrade] 升级完成: ${storedVersion} → ${StorageConfig.CURRENT_VERSION}`);
|
||||
} catch (error) {
|
||||
console.error("[Upgrade] 系统升级处理失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async processUpgrade(): Promise<void> {
|
||||
if (this.shouldSkipUpgrade()) {
|
||||
console.debug("[Upgrade] 跳过版本升级检查");
|
||||
return;
|
||||
}
|
||||
|
||||
const storedVersion = this.getStoredVersion();
|
||||
if (this.isFirstVisit(storedVersion)) {
|
||||
this.setStoredVersion(StorageConfig.CURRENT_VERSION);
|
||||
console.info("[Upgrade] 首次访问,已设置当前版本");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isSameVersion(storedVersion!)) {
|
||||
console.debug("[Upgrade] 版本相同,无需升级");
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyStorage = this.findLegacyStorage();
|
||||
if (!legacyStorage.oldSysKey && legacyStorage.oldVersionKeys.length === 0) {
|
||||
this.setStoredVersion(StorageConfig.CURRENT_VERSION);
|
||||
console.info("[Upgrade] 无旧数据,已更新版本号");
|
||||
return;
|
||||
}
|
||||
|
||||
await this.executeUpgrade(storedVersion!, legacyStorage);
|
||||
}
|
||||
}
|
||||
|
||||
export async function processUpgrade(): Promise<void> {
|
||||
const versionManager = new VersionManager();
|
||||
await versionManager.processUpgrade();
|
||||
}
|
||||
|
||||
export function systemUpgrade(): void {
|
||||
setTimeout(() => {
|
||||
void processUpgrade();
|
||||
}, StorageConfig.UPGRADE_DELAY);
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
/** Table helpers (flattened). */
|
||||
|
||||
import { h } from "vue";
|
||||
import type { VNode } from "vue";
|
||||
import { ElTooltip } from "element-plus";
|
||||
import { hash } from "ohash";
|
||||
import ArtButtonMore from "@/components/Core/forms/art-button-more/index.vue";
|
||||
import type { ButtonMoreItem } from "@/components/Core/forms/art-button-more/index.vue";
|
||||
import ArtButtonTable from "@/components/Core/forms/art-button-table/index.vue";
|
||||
|
||||
// -----------------------------
|
||||
// Config
|
||||
// -----------------------------
|
||||
|
||||
export const tableConfig = {
|
||||
recordFields: ["list", "data", "records", "items", "result", "rows"],
|
||||
totalFields: ["total", "count"],
|
||||
currentFields: ["current", "page", "pageNum", "page_no"],
|
||||
sizeFields: ["size", "pageSize", "limit", "page_size"],
|
||||
paginationKey: {
|
||||
current: "current",
|
||||
size: "size",
|
||||
},
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// Cache
|
||||
// -----------------------------
|
||||
|
||||
export enum CacheInvalidationStrategy {
|
||||
CLEAR_ALL = "clear_all",
|
||||
CLEAR_CURRENT = "clear_current",
|
||||
CLEAR_PAGINATION = "clear_pagination",
|
||||
KEEP_ALL = "keep_all",
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
records?: T[];
|
||||
data?: T[];
|
||||
total?: number;
|
||||
current?: number;
|
||||
size?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CacheItem<T> {
|
||||
data: T[];
|
||||
response: ApiResponse<T>;
|
||||
timestamp: number;
|
||||
params: string;
|
||||
tags: Set<string>;
|
||||
accessCount: number;
|
||||
lastAccessTime: number;
|
||||
}
|
||||
|
||||
export class TableCache<T> {
|
||||
private cache = new Map<string, CacheItem<T>>();
|
||||
private cacheTime: number;
|
||||
private maxSize: number;
|
||||
private enableLog: boolean;
|
||||
|
||||
constructor(cacheTime = 5 * 60 * 1000, maxSize = 50, enableLog = false) {
|
||||
this.cacheTime = cacheTime;
|
||||
this.maxSize = maxSize;
|
||||
this.enableLog = enableLog;
|
||||
}
|
||||
|
||||
private log(message: string, ...args: any[]) {
|
||||
if (this.enableLog) console.log(`[TableCache] ${message}`, ...args);
|
||||
}
|
||||
|
||||
private generateKey(params: unknown): string {
|
||||
return hash(params);
|
||||
}
|
||||
|
||||
private generateTags(params: Record<string, unknown>): Set<string> {
|
||||
const tags = new Set<string>();
|
||||
|
||||
const searchKeys = Object.keys(params).filter(
|
||||
(key) =>
|
||||
!["current", "size", "total"].includes(key) &&
|
||||
params[key] !== undefined &&
|
||||
params[key] !== "" &&
|
||||
params[key] !== null
|
||||
);
|
||||
|
||||
if (searchKeys.length > 0) {
|
||||
const searchTag = searchKeys.map((key) => `${key}:${String(params[key])}`).join("|");
|
||||
tags.add(`search:${searchTag}`);
|
||||
} else {
|
||||
tags.add("search:default");
|
||||
}
|
||||
|
||||
tags.add(`pagination:${params.size || 10}`);
|
||||
tags.add("pagination");
|
||||
return tags;
|
||||
}
|
||||
|
||||
private evictLRU(): void {
|
||||
if (this.cache.size <= this.maxSize) return;
|
||||
|
||||
let lruKey = "";
|
||||
let minAccessCount = Infinity;
|
||||
let oldestTime = Infinity;
|
||||
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (
|
||||
item.accessCount < minAccessCount ||
|
||||
(item.accessCount === minAccessCount && item.lastAccessTime < oldestTime)
|
||||
) {
|
||||
lruKey = key;
|
||||
minAccessCount = item.accessCount;
|
||||
oldestTime = item.lastAccessTime;
|
||||
}
|
||||
}
|
||||
|
||||
if (lruKey) {
|
||||
this.cache.delete(lruKey);
|
||||
this.log(`LRU 清理缓存: ${lruKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
set(params: unknown, data: T[], response: ApiResponse<T>): void {
|
||||
const key = this.generateKey(params);
|
||||
const tags = this.generateTags(params as Record<string, unknown>);
|
||||
const now = Date.now();
|
||||
|
||||
this.evictLRU();
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
response,
|
||||
timestamp: now,
|
||||
params: key,
|
||||
tags,
|
||||
accessCount: 1,
|
||||
lastAccessTime: now,
|
||||
});
|
||||
}
|
||||
|
||||
get(params: unknown): CacheItem<T> | null {
|
||||
const key = this.generateKey(params);
|
||||
const item = this.cache.get(key);
|
||||
if (!item) return null;
|
||||
|
||||
if (Date.now() - item.timestamp > this.cacheTime) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
item.accessCount++;
|
||||
item.lastAccessTime = Date.now();
|
||||
return item;
|
||||
}
|
||||
|
||||
clearByTags(tags: string[]): number {
|
||||
let clearedCount = 0;
|
||||
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
const hasMatchingTag = tags.some((tag) =>
|
||||
Array.from(item.tags).some((itemTag) => itemTag.includes(tag))
|
||||
);
|
||||
|
||||
if (hasMatchingTag) {
|
||||
this.cache.delete(key);
|
||||
clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return clearedCount;
|
||||
}
|
||||
|
||||
clearCurrentSearch(params: unknown): number {
|
||||
const key = this.generateKey(params);
|
||||
const deleted = this.cache.delete(key);
|
||||
return deleted ? 1 : 0;
|
||||
}
|
||||
|
||||
clearPagination(): number {
|
||||
return this.clearByTags(["pagination"]);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
getStats(): { total: number; size: string; hitRate: string } {
|
||||
const total = this.cache.size;
|
||||
let totalSize = 0;
|
||||
let totalAccess = 0;
|
||||
|
||||
for (const item of this.cache.values()) {
|
||||
totalSize += JSON.stringify(item.data).length;
|
||||
totalAccess += item.accessCount;
|
||||
}
|
||||
|
||||
const sizeInKB = (totalSize / 1024).toFixed(2);
|
||||
const avgHits = total > 0 ? (totalAccess / total).toFixed(1) : "0";
|
||||
|
||||
return { total, size: `${sizeInKB}KB`, hitRate: `${avgHits} avg hits` };
|
||||
}
|
||||
|
||||
cleanupExpired(): number {
|
||||
let cleanedCount = 0;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (now - item.timestamp > this.cacheTime) {
|
||||
this.cache.delete(key);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedCount;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Utils
|
||||
// -----------------------------
|
||||
|
||||
function unwrapAxiosResponseBody(response: unknown): unknown {
|
||||
if (response === null || typeof response !== "object") return response;
|
||||
const r = response as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
"data" in r &&
|
||||
"status" in r &&
|
||||
typeof r.status === "number" &&
|
||||
"config" in r &&
|
||||
typeof r.config === "object"
|
||||
) {
|
||||
return r.data;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export interface BaseRequestParams extends PageQuery {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TableError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
function extractRecords<T>(obj: Record<string, unknown>, fields: string[]): T[] {
|
||||
for (const field of fields) {
|
||||
if (field in obj && Array.isArray(obj[field])) return obj[field] as T[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractTotal(obj: Record<string, unknown>, records: unknown[], fields: string[]): number {
|
||||
for (const field of fields) {
|
||||
if (field in obj && typeof obj[field] === "number") return obj[field] as number;
|
||||
}
|
||||
return records.length;
|
||||
}
|
||||
|
||||
function extractPagination(
|
||||
obj: Record<string, unknown>,
|
||||
data?: Record<string, unknown>
|
||||
): Pick<ApiResponse<unknown>, "current" | "size"> | undefined {
|
||||
const result: Partial<Pick<ApiResponse<unknown>, "current" | "size">> = {};
|
||||
const sources = [obj, data ?? {}];
|
||||
|
||||
for (const src of sources) {
|
||||
for (const field of tableConfig.currentFields) {
|
||||
if (field in src && typeof src[field] === "number") {
|
||||
result.current = src[field] as number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (result.current !== undefined) break;
|
||||
}
|
||||
|
||||
for (const src of sources) {
|
||||
for (const field of tableConfig.sizeFields) {
|
||||
if (field in src && typeof src[field] === "number") {
|
||||
result.size = src[field] as number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (result.size !== undefined) break;
|
||||
}
|
||||
|
||||
if (result.current === undefined && result.size === undefined) return undefined;
|
||||
return result;
|
||||
}
|
||||
|
||||
export const defaultResponseAdapter = <T>(response: unknown): ApiResponse<T> => {
|
||||
const recordFields = tableConfig.recordFields;
|
||||
|
||||
response = unwrapAxiosResponseBody(response);
|
||||
|
||||
if (!response) return { records: [], total: 0 };
|
||||
if (Array.isArray(response)) return { records: response, total: response.length };
|
||||
|
||||
if (typeof response !== "object") {
|
||||
console.warn(
|
||||
"[tableUtils] 无法识别的响应格式,支持的格式包括: 数组、包含" +
|
||||
recordFields.join("/") +
|
||||
"字段的对象、嵌套data对象。当前格式:",
|
||||
response
|
||||
);
|
||||
return { records: [], total: 0 };
|
||||
}
|
||||
|
||||
const res = response as Record<string, unknown>;
|
||||
let records: T[] = [];
|
||||
let total = 0;
|
||||
let pagination: Pick<ApiResponse<unknown>, "current" | "size"> | undefined;
|
||||
|
||||
records = extractRecords(res, recordFields);
|
||||
total = extractTotal(res, records, tableConfig.totalFields);
|
||||
pagination = extractPagination(res);
|
||||
|
||||
if (records.length === 0 && "data" in res && typeof res.data === "object") {
|
||||
const data = res.data as Record<string, unknown>;
|
||||
records = extractRecords(data, ["list", "records", "items"]);
|
||||
total = extractTotal(data, records, tableConfig.totalFields);
|
||||
pagination = extractPagination(res, data);
|
||||
|
||||
if (Array.isArray(res.data)) {
|
||||
records = res.data as T[];
|
||||
total = records.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recordFields.some((field) => field in res) && records.length === 0) {
|
||||
console.warn("[tableUtils] 无法识别的响应格式");
|
||||
console.warn("支持的字段包括: " + recordFields.join("、"), response);
|
||||
console.warn("扩展字段请到 utils/table/tableConfig 文件配置");
|
||||
}
|
||||
|
||||
const result: ApiResponse<T> = { records, total };
|
||||
if (pagination) Object.assign(result, pagination);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const extractTableData = <T>(response: ApiResponse<T>): T[] => {
|
||||
const data = response.records || response.data || [];
|
||||
return Array.isArray(data) ? data : [];
|
||||
};
|
||||
|
||||
export const updatePaginationFromResponse = <T>(
|
||||
pagination: { total: number },
|
||||
response: ApiResponse<T>
|
||||
): void => {
|
||||
const total = response.total;
|
||||
if (typeof total === "number") (pagination as Record<string, unknown>).total = total;
|
||||
};
|
||||
|
||||
export const createSmartDebounce = <T extends (...args: any[]) => Promise<any>>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): T & { cancel: () => void; flush: () => Promise<any> } => {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let lastArgs: Parameters<T> | null = null;
|
||||
let lastResolve: ((value: any) => void) | null = null;
|
||||
let lastReject: ((reason: any) => void) | null = null;
|
||||
|
||||
const debouncedFn = (...args: Parameters<T>): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
lastArgs = args;
|
||||
lastResolve = resolve;
|
||||
lastReject = reject;
|
||||
timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
const result = await fn(...args);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
timeoutId = null;
|
||||
lastArgs = null;
|
||||
lastResolve = null;
|
||||
lastReject = null;
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
};
|
||||
|
||||
debouncedFn.cancel = () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
lastArgs = null;
|
||||
lastResolve = null;
|
||||
lastReject = null;
|
||||
};
|
||||
|
||||
debouncedFn.flush = async () => {
|
||||
if (timeoutId && lastArgs && lastResolve && lastReject) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
const args = lastArgs;
|
||||
const resolve = lastResolve;
|
||||
const reject = lastReject;
|
||||
lastArgs = null;
|
||||
lastResolve = null;
|
||||
lastReject = null;
|
||||
|
||||
try {
|
||||
const result = await fn(...args);
|
||||
resolve(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return debouncedFn as T & { cancel: () => void; flush: () => Promise<any> };
|
||||
};
|
||||
|
||||
export const createErrorHandler = (
|
||||
onError?: (error: TableError) => void,
|
||||
enableLog: boolean = false
|
||||
) => {
|
||||
return (error: any, defaultMessage: string = "操作失败"): TableError => {
|
||||
const tableError: TableError = {
|
||||
code: error?.code || "UNKNOWN_ERROR",
|
||||
message: error?.message || defaultMessage,
|
||||
details: error,
|
||||
};
|
||||
|
||||
if (enableLog) {
|
||||
console.error("[tableUtils]", tableError);
|
||||
}
|
||||
|
||||
onError?.(tableError);
|
||||
return tableError;
|
||||
};
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// Operation cell renderer
|
||||
// -----------------------------
|
||||
|
||||
export const DEFAULT_MAX_INLINE_TABLE_OPERATIONS = 3;
|
||||
|
||||
export interface TableOperationAction {
|
||||
key: string | number;
|
||||
label: string;
|
||||
artType: "add" | "edit" | "delete" | "view" | "more";
|
||||
icon?: string;
|
||||
perm?: string;
|
||||
disabled?: boolean;
|
||||
iconColor?: string;
|
||||
color?: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export interface RenderTableOperationCellOptions {
|
||||
maxInline?: number;
|
||||
wrapperClass?: string;
|
||||
emptyText?: string;
|
||||
}
|
||||
|
||||
const ART_TYPE_DEFAULT_ICONS: Record<TableOperationAction["artType"], string> = {
|
||||
add: "ri:add-fill",
|
||||
edit: "ri:pencil-line",
|
||||
delete: "ri:delete-bin-5-line",
|
||||
view: "ri:eye-line",
|
||||
more: "ri:more-2-fill",
|
||||
};
|
||||
|
||||
function iconForOperation(a: TableOperationAction): string {
|
||||
return a.icon ?? ART_TYPE_DEFAULT_ICONS[a.artType];
|
||||
}
|
||||
|
||||
const ART_TYPE_ICON_COLORS: Record<TableOperationAction["artType"], string> = {
|
||||
add: "var(--el-color-primary)",
|
||||
edit: "var(--el-color-success)",
|
||||
delete: "var(--el-color-danger)",
|
||||
view: "var(--el-color-info)",
|
||||
more: "var(--el-text-color-regular)",
|
||||
};
|
||||
|
||||
function iconColorForOperation(a: TableOperationAction): string | undefined {
|
||||
if (a.iconColor != null) return a.iconColor;
|
||||
return ART_TYPE_ICON_COLORS[a.artType];
|
||||
}
|
||||
|
||||
function defaultMoreItemColor(a: TableOperationAction): string | undefined {
|
||||
if (a.color != null) return a.color;
|
||||
return String(a.key) === "delete" ? "var(--el-color-danger)" : undefined;
|
||||
}
|
||||
|
||||
export function renderTableOperationCell(
|
||||
actions: TableOperationAction[],
|
||||
options?: RenderTableOperationCellOptions
|
||||
): VNode {
|
||||
const maxInline = options?.maxInline ?? DEFAULT_MAX_INLINE_TABLE_OPERATIONS;
|
||||
const wrapperClass =
|
||||
options?.wrapperClass ?? "inline-flex flex-wrap items-center justify-end gap-1";
|
||||
const emptyText = options?.emptyText ?? "—";
|
||||
|
||||
if (actions.length === 0) return h("span", { class: "text-g-400" }, emptyText);
|
||||
|
||||
const inline = actions.slice(0, maxInline);
|
||||
const overflow = actions.slice(maxInline);
|
||||
|
||||
const inlineNodes = inline.map((a) =>
|
||||
h(ElTooltip, { content: a.label, placement: "top" }, () =>
|
||||
h(
|
||||
"span",
|
||||
{ class: a.disabled ? "inline-flex opacity-40 pointer-events-none" : "inline-flex" },
|
||||
[
|
||||
h(ArtButtonTable, {
|
||||
type: a.artType,
|
||||
icon: iconForOperation(a),
|
||||
iconColor: iconColorForOperation(a),
|
||||
onClick: a.run,
|
||||
}),
|
||||
]
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (overflow.length === 0) return h("div", { class: wrapperClass }, inlineNodes);
|
||||
|
||||
const moreDropdown = h(ArtButtonMore, {
|
||||
list: overflow.map((a) => ({
|
||||
key: a.key,
|
||||
label: a.label,
|
||||
icon: iconForOperation(a),
|
||||
auth: a.perm,
|
||||
disabled: a.disabled,
|
||||
iconColor: iconColorForOperation(a),
|
||||
color: defaultMoreItemColor(a),
|
||||
})),
|
||||
onClick: (item: ButtonMoreItem) => {
|
||||
const act = overflow.find((x) => String(x.key) === String(item.key));
|
||||
act?.run();
|
||||
},
|
||||
});
|
||||
|
||||
return h("div", { class: wrapperClass }, [...inlineNodes, moreDropdown]);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { ThemeMode } from "@/enums";
|
||||
|
||||
// 辅助函数:将十六进制颜色转换为 RGB
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const bigint = parseInt(hex.slice(1), 16);
|
||||
return [(bigint >> 16) & 255, (bigint >> 8) & 255, bigint & 255];
|
||||
}
|
||||
|
||||
// 辅助函数:将 RGB 转换为十六进制颜色
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加深颜色值
|
||||
* @param {String} color 颜色值字符串
|
||||
* @param {Number} level 加深的程度,限0-1之间
|
||||
* @returns {String} 返回处理后的颜色值
|
||||
*/
|
||||
export function getDarkColor(color: string, level: number): string {
|
||||
const rgb = hexToRgb(color);
|
||||
for (let i = 0; i < 3; i++) rgb[i] = Math.round(20.5 * level + rgb[i] * (1 - level));
|
||||
return rgbToHex(rgb[0], rgb[1], rgb[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 变浅颜色值
|
||||
* @param {String} color 颜色值字符串
|
||||
* @param {Number} level 加深的程度,限0-1之间
|
||||
* @returns {String} 返回处理后的颜色值
|
||||
*/
|
||||
export const getLightColor = (color: string, level: number): string => {
|
||||
const rgb = hexToRgb(color);
|
||||
for (let i = 0; i < 3; i++) rgb[i] = Math.round(255 * level + rgb[i] * (1 - level));
|
||||
return rgbToHex(rgb[0], rgb[1], rgb[2]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成主题色
|
||||
* @param primary 主题色
|
||||
* @param theme 主题类型
|
||||
*/
|
||||
export function generateThemeColors(primary: string, theme: ThemeMode) {
|
||||
const colors: Record<string, string> = {
|
||||
primary,
|
||||
};
|
||||
|
||||
// 生成浅色变体
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
colors[`primary-light-${i}`] =
|
||||
theme === ThemeMode.LIGHT
|
||||
? `${getLightColor(primary, i / 10)}`
|
||||
: `${getDarkColor(primary, i / 10)}`;
|
||||
}
|
||||
|
||||
// 生成深色变体
|
||||
colors["primary-dark-2"] =
|
||||
theme === ThemeMode.LIGHT ? `${getLightColor(primary, 0.2)}` : `${getDarkColor(primary, 0.3)}`;
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
export function applyTheme(colors: Record<string, string>) {
|
||||
const el = document.documentElement;
|
||||
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
el.style.setProperty(`--el-color-${key}`, value);
|
||||
});
|
||||
|
||||
// 确保主题色立即生效,强制重新渲染
|
||||
requestAnimationFrame(() => {
|
||||
// 触发样式重新计算
|
||||
el.style.setProperty("--theme-update-trigger", Date.now().toString());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换暗黑模式
|
||||
*
|
||||
* @param isDark 是否启用暗黑模式
|
||||
*/
|
||||
export function toggleDarkMode(isDark: boolean) {
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add(ThemeMode.DARK);
|
||||
} else {
|
||||
document.documentElement.classList.remove(ThemeMode.DARK);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换浅色主题下的侧边栏颜色方案
|
||||
*
|
||||
* @param isBlue 布尔值,表示是否开启深蓝色侧边栏颜色方案
|
||||
*/
|
||||
export function toggleSidebarColor(isBuleSidebar: boolean) {
|
||||
if (isBuleSidebar) {
|
||||
document.documentElement.classList.add("sidebar-color-blue");
|
||||
} else {
|
||||
document.documentElement.classList.remove("sidebar-color-blue");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/** UI helpers (flattened). */
|
||||
|
||||
import NProgress from "nprogress";
|
||||
import "nprogress/nprogress.css";
|
||||
import { ThemeMode } from "@/enums";
|
||||
import { useSettingsStore } from "@stores/modules/setting.store";
|
||||
import { fourDotsSpinnerSvg } from "@/assets/svg/loading";
|
||||
import { useCommon } from "@/hooks/core/useCommon";
|
||||
import { useTheme } from "@/hooks/core/useTheme";
|
||||
import { SystemThemeEnum } from "@/enums/appEnum";
|
||||
|
||||
// -----------------------------
|
||||
// NProgress
|
||||
// -----------------------------
|
||||
|
||||
NProgress.configure({
|
||||
easing: "ease",
|
||||
speed: 500,
|
||||
showSpinner: false,
|
||||
trickleSpeed: 200,
|
||||
minimum: 0.3,
|
||||
});
|
||||
|
||||
export { NProgress };
|
||||
|
||||
// -----------------------------
|
||||
// Colors
|
||||
// -----------------------------
|
||||
|
||||
interface RgbaResult {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
rgba: string;
|
||||
}
|
||||
|
||||
export function getCssVar(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name);
|
||||
}
|
||||
|
||||
function isValidHexColor(hex: string): boolean {
|
||||
const cleanHex = hex.trim().replace(/^#/, "");
|
||||
return /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(cleanHex);
|
||||
}
|
||||
|
||||
function isValidRgbValue(r: number, g: number, b: number): boolean {
|
||||
const isValid = (value: number) => Number.isInteger(value) && value >= 0 && value <= 255;
|
||||
return isValid(r) && isValid(g) && isValid(b);
|
||||
}
|
||||
|
||||
export function hexToRgba(hex: string, opacity: number): RgbaResult {
|
||||
if (!isValidHexColor(hex)) throw new Error("Invalid hex color format");
|
||||
|
||||
let cleanHex = hex.trim().replace(/^#/, "").toUpperCase();
|
||||
if (cleanHex.length === 3) {
|
||||
cleanHex = cleanHex
|
||||
.split("")
|
||||
.map((char) => char.repeat(2))
|
||||
.join("");
|
||||
}
|
||||
|
||||
const [red, green, blue] = cleanHex.match(/\w\w/g)!.map((x) => parseInt(x, 16));
|
||||
const validOpacity = Math.max(0, Math.min(1, opacity));
|
||||
const rgba = `rgba(${red}, ${green}, ${blue}, ${validOpacity.toFixed(2)})`;
|
||||
return { red, green, blue, rgba };
|
||||
}
|
||||
|
||||
export function hexToRgb(hexColor: string): number[] {
|
||||
if (!isValidHexColor(hexColor)) {
|
||||
ElMessage.warning("输入错误的hex颜色值");
|
||||
throw new Error("Invalid hex color format");
|
||||
}
|
||||
|
||||
const cleanHex = hexColor.replace(/^#/, "");
|
||||
let hex = cleanHex;
|
||||
if (hex.length === 3) {
|
||||
hex = hex
|
||||
.split("")
|
||||
.map((char) => char.repeat(2))
|
||||
.join("");
|
||||
}
|
||||
|
||||
const hexPairs = hex.match(/../g);
|
||||
if (!hexPairs) throw new Error("Invalid hex color format");
|
||||
return hexPairs.map((hexPair) => parseInt(hexPair, 16));
|
||||
}
|
||||
|
||||
export function rgbToHex(r: number, g: number, b: number): string {
|
||||
if (!isValidRgbValue(r, g, b)) {
|
||||
ElMessage.warning("输入错误的RGB颜色值");
|
||||
throw new Error("Invalid RGB color values");
|
||||
}
|
||||
|
||||
const toHex = (value: number) => {
|
||||
const hex = value.toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
export function colourBlend(color1: string, color2: string, ratio: number): string {
|
||||
const validRatio = Math.max(0, Math.min(1, Number(ratio)));
|
||||
const rgb1 = hexToRgb(color1);
|
||||
const rgb2 = hexToRgb(color2);
|
||||
|
||||
const blendedRgb = rgb1.map((value1, index) => {
|
||||
const value2 = rgb2[index];
|
||||
return Math.round(value1 * (1 - validRatio) + value2 * validRatio);
|
||||
});
|
||||
|
||||
return rgbToHex(blendedRgb[0], blendedRgb[1], blendedRgb[2]);
|
||||
}
|
||||
|
||||
export function getLightColor(color: string, level: number, isDark: boolean = false): string {
|
||||
if (!isValidHexColor(color)) {
|
||||
ElMessage.warning("输入错误的hex颜色值");
|
||||
throw new Error("Invalid hex color format");
|
||||
}
|
||||
|
||||
if (isDark) return getDarkColor(color, level);
|
||||
|
||||
const rgb = hexToRgb(color);
|
||||
const lightRgb = rgb.map((value) => Math.floor((255 - value) * level + value));
|
||||
return rgbToHex(lightRgb[0], lightRgb[1], lightRgb[2]);
|
||||
}
|
||||
|
||||
export function getDarkColor(color: string, level: number): string {
|
||||
if (!isValidHexColor(color)) {
|
||||
ElMessage.warning("输入错误的hex颜色值");
|
||||
throw new Error("Invalid hex color format");
|
||||
}
|
||||
|
||||
const rgb = hexToRgb(color);
|
||||
const darkRgb = rgb.map((value) => Math.floor(value * (1 - level)));
|
||||
return rgbToHex(darkRgb[0], darkRgb[1], darkRgb[2]);
|
||||
}
|
||||
|
||||
export function handleElementThemeColor(theme: string, isDark: boolean = false): void {
|
||||
document.documentElement.style.setProperty("--el-color-primary", theme);
|
||||
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
document.documentElement.style.setProperty(
|
||||
`--el-color-primary-light-${i}`,
|
||||
getLightColor(theme, i / 10, isDark)
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
document.documentElement.style.setProperty(
|
||||
`--el-color-primary-dark-${i}`,
|
||||
getDarkColor(theme, i / 10)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function setElementThemeColor(color: string): void {
|
||||
const mixColor = "#ffffff";
|
||||
const elStyle = document.documentElement.style;
|
||||
|
||||
elStyle.setProperty("--el-color-primary", color);
|
||||
handleElementThemeColor(color, useSettingsStore().isDark);
|
||||
|
||||
for (let i = 1; i < 16; i++) {
|
||||
const itemColor = colourBlend(color, mixColor, i / 16);
|
||||
elStyle.setProperty(`--el-color-primary-custom-${i}`, itemColor);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Theme utils
|
||||
// -----------------------------
|
||||
|
||||
export function generateThemeColors(primary: string, theme: ThemeMode): Record<string, string> {
|
||||
const colors: Record<string, string> = { primary };
|
||||
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
colors[`primary-light-${i}`] =
|
||||
theme === ThemeMode.LIGHT
|
||||
? `${getLightColor(primary, i / 10)}`
|
||||
: `${getDarkColor(primary, i / 10)}`;
|
||||
}
|
||||
|
||||
colors["primary-dark-2"] =
|
||||
theme === ThemeMode.LIGHT ? `${getLightColor(primary, 0.2)}` : `${getDarkColor(primary, 0.3)}`;
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
export function applyTheme(colors: Record<string, string>): void {
|
||||
const el = document.documentElement;
|
||||
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
el.style.setProperty(`--el-color-${key}`, value);
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
el.style.setProperty("--theme-update-trigger", Date.now().toString());
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleDarkMode(isDark: boolean): void {
|
||||
if (isDark) document.documentElement.classList.add(ThemeMode.DARK);
|
||||
else document.documentElement.classList.remove(ThemeMode.DARK);
|
||||
}
|
||||
|
||||
export function toggleSidebarColor(isBlueSidebar: boolean): void {
|
||||
if (isBlueSidebar) document.documentElement.classList.add("sidebar-color-blue");
|
||||
else document.documentElement.classList.remove("sidebar-color-blue");
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Loading
|
||||
// -----------------------------
|
||||
|
||||
const getLoadingBackground = (): string => {
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
return isDark ? "rgba(7, 7, 7, 0.85)" : "#fff";
|
||||
};
|
||||
|
||||
const DEFAULT_LOADING_CONFIG = {
|
||||
lock: true,
|
||||
get background() {
|
||||
return getLoadingBackground();
|
||||
},
|
||||
svg: fourDotsSpinnerSvg,
|
||||
svgViewBox: "0 0 40 40",
|
||||
customClass: "art-loading-fix",
|
||||
} as const;
|
||||
|
||||
interface LoadingInstance {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
let loadingInstance: LoadingInstance | null = null;
|
||||
|
||||
export const loadingService = {
|
||||
showLoading(): () => void {
|
||||
if (!loadingInstance) {
|
||||
const config = { ...DEFAULT_LOADING_CONFIG, background: getLoadingBackground() };
|
||||
loadingInstance = ElLoading.service(config);
|
||||
}
|
||||
return () => loadingService.hideLoading();
|
||||
},
|
||||
|
||||
hideLoading(): void {
|
||||
if (!loadingInstance) return;
|
||||
loadingInstance.close();
|
||||
loadingInstance = null;
|
||||
},
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// Tabs config
|
||||
// -----------------------------
|
||||
|
||||
export const TAB_CONFIG = {
|
||||
"tab-default": { openTop: 106, closeTop: 60, openHeight: 121, closeHeight: 75 },
|
||||
"tab-card": { openTop: 122, closeTop: 78, openHeight: 139, closeHeight: 95 },
|
||||
"tab-google": { openTop: 122, closeTop: 78, openHeight: 139, closeHeight: 95 },
|
||||
};
|
||||
|
||||
export const getTabConfig = (style: string) =>
|
||||
TAB_CONFIG[style as keyof typeof TAB_CONFIG] || TAB_CONFIG["tab-card"];
|
||||
|
||||
// -----------------------------
|
||||
// EmojiText (default export-compatible)
|
||||
// -----------------------------
|
||||
|
||||
export const EmojiText: { [key: string]: string } = {
|
||||
"0": "O_O",
|
||||
"200": "^_^",
|
||||
"400": "T_T",
|
||||
"500": "X_X",
|
||||
};
|
||||
|
||||
// -----------------------------
|
||||
// Theme animation
|
||||
// -----------------------------
|
||||
|
||||
const { LIGHT, DARK } = SystemThemeEnum;
|
||||
|
||||
export const themeAnimation = (e: any) => {
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
|
||||
|
||||
document.documentElement.style.setProperty("--x", x + "px");
|
||||
document.documentElement.style.setProperty("--y", y + "px");
|
||||
document.documentElement.style.setProperty("--r", endRadius + "px");
|
||||
|
||||
if (document.startViewTransition) document.startViewTransition(() => toggleTheme());
|
||||
else toggleTheme();
|
||||
};
|
||||
|
||||
const toggleTheme = () => {
|
||||
useTheme().switchThemeStyles(useSettingsStore().systemThemeType === LIGHT ? DARK : LIGHT);
|
||||
useCommon().refresh();
|
||||
};
|
||||
|
||||
export const toggleTransition = (enable: boolean) => {
|
||||
const body = document.body;
|
||||
|
||||
if (enable) body.classList.add("theme-change");
|
||||
else {
|
||||
setTimeout(() => {
|
||||
body.classList.remove("theme-change");
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* WebSocket 服务管理
|
||||
*
|
||||
* @description
|
||||
* 统一管理应用中的所有 WebSocket 连接
|
||||
* - 字典同步 WebSocket
|
||||
* - 在线用户计数 WebSocket
|
||||
* - 其他业务 WebSocket
|
||||
*
|
||||
* @author fastapiadmin
|
||||
*/
|
||||
|
||||
import { Auth } from "@/utils/auth";
|
||||
|
||||
/**
|
||||
* WebSocket 服务实例约定接口
|
||||
*/
|
||||
type WebSocketService = {
|
||||
disconnect?: () => void;
|
||||
closeWebSocket?: () => void;
|
||||
cleanup?: () => void;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* 全局 WebSocket 实例管理
|
||||
*/
|
||||
const websocketInstances = new Map<string, WebSocketService>();
|
||||
|
||||
/**
|
||||
* 防止重复初始化的状态标记
|
||||
*/
|
||||
let isInitialized = false;
|
||||
|
||||
/**
|
||||
* 注册 WebSocket 实例
|
||||
*/
|
||||
export function registerWebSocketInstance(key: string, instance: WebSocketService) {
|
||||
websocketInstances.set(key, instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 实例
|
||||
*/
|
||||
export function getWebSocketInstance(key: string) {
|
||||
return websocketInstances.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 WebSocket 服务
|
||||
*/
|
||||
export function setupWebSocket() {
|
||||
if (isInitialized) {
|
||||
console.warn("[WebSocket] 已初始化,跳过重复初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Auth.getAccessToken()) {
|
||||
console.warn("[WebSocket] 未登录,跳过 WebSocket 初始化");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isInitialized = true;
|
||||
console.log("[WebSocket] 初始化成功");
|
||||
} catch (error) {
|
||||
console.error("[WebSocket] 初始化失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有 WebSocket 连接
|
||||
*/
|
||||
export function cleanupWebSocket() {
|
||||
console.log("[WebSocket] 开始清理连接...");
|
||||
|
||||
websocketInstances.forEach((instance, key) => {
|
||||
try {
|
||||
if (instance.disconnect) {
|
||||
instance.disconnect();
|
||||
} else if (instance.closeWebSocket) {
|
||||
instance.closeWebSocket();
|
||||
} else if (instance.cleanup) {
|
||||
instance.cleanup();
|
||||
}
|
||||
console.log(`[WebSocket] ${key} 已断开`);
|
||||
} catch (error) {
|
||||
console.error(`[WebSocket] ${key} 清理失败:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
websocketInstances.clear();
|
||||
isInitialized = false;
|
||||
console.log("[WebSocket] 清理完成");
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新初始化 WebSocket
|
||||
*/
|
||||
export function reinitializeWebSocket() {
|
||||
cleanupWebSocket();
|
||||
setupWebSocket();
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
cleanupWebSocket();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user