feat: 迁移前端资源文件并重构项目结构

refactor: 优化前端代码结构和资源管理

style: 调整前端代码格式和样式

chore: 更新.gitignore和构建配置

fix: 修复前端资源路径和引用问题

docs: 更新前端文档和注释

perf: 优化前端性能和加载速度

test: 更新前端测试用例

build: 调整前端构建配置

ci: 更新CI/CD配置
This commit is contained in:
zhangtao
2026-05-09 00:46:43 +08:00
parent 1bd125d99b
commit bf81480ada
967 changed files with 21018 additions and 83019 deletions
-86
View File
@@ -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);
}
}
+91
View File
@@ -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;
}
-36
View File
@@ -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;
}
-148
View File
@@ -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";
}
+800
View File
@@ -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",
};
@@ -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";
-77
View File
@@ -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,
};
};
+101
View File
@@ -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;
-30
View File
@@ -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;
}
+21
View File
@@ -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 });
}
+186
View File
@@ -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;
}
+140
View File
@@ -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;
+24
View File
@@ -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;
}
+134
View File
@@ -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;
};
+10
View File
@@ -0,0 +1,10 @@
/**
* HTTP 客户端(配置 / 错误类型 / Axios 实例)
*
* @module utils/http
*/
export * from "./config";
export * from "./error";
export { default } from "./client";
export type { AxiosInstance } from "axios";
-12
View File
@@ -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;
}
+12
View File
@@ -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;
}
+7
View File
@@ -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
View File
@@ -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";
+272
View File
@@ -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";
+4
View File
@@ -0,0 +1,4 @@
/**
* Remix / Iconify 解析(与 `menuIcon/index` 同源,便于按需引用)。
*/
export { resolveIconForArtSvgIcon } from "./index";
+120
View File
@@ -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,
});
}
};
-21
View File
@@ -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;
+11
View File
@@ -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;
}
-138
View File
@@ -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();
-158
View File
@@ -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;
+541
View File
@@ -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;
}
}
}
-64
View File
@@ -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();
}
}
+510
View File
@@ -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();
}
}
+296
View File
@@ -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);
}
+545
View File
@@ -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]);
}
-101
View File
@@ -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");
}
}
+310
View File
@@ -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);
}
};
-109
View File
@@ -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();
});
}