feat: 重构前端项目结构并优化代码

refactor: 迁移前端资源文件至web目录
feat: 新增多种图标资源
style: 统一代码风格和格式化配置
docs: 更新README和文档说明
chore: 更新依赖和配置文件
fix: 修复部分类型定义和枚举
perf: 优化路由和组件加载逻辑
This commit is contained in:
zhangtao
2026-05-01 01:01:17 +08:00
parent 1355a09441
commit 80263e98e1
887 changed files with 116349 additions and 33825 deletions
+86
View File
@@ -0,0 +1,86 @@
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);
}
}
+35
View File
@@ -0,0 +1,35 @@
import router from '@/router';
import { useUserStoreHook } from '@/store/modules/user.store';
/** 登录页跳转进行中,合并并发调用,避免重复通知与重复路由 */
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
@@ -0,0 +1,148 @@
// 问候语:根据当前小时返回不同问候语
export function greetings() {
// 当前时间(用于计算问候语)
const currentDate = new Date();
const hours = currentDate.getHours();
if (hours >= 6 && hours < 8) {
return '晨起披衣出草堂,轩窗已自喜微凉🌅!';
} else if (hours >= 8 && hours < 12) {
return `上午好!`;
} else if (hours >= 12 && hours < 14) {
return `中午好!`;
} else if (hours >= 14 && hours < 18) {
return `下午好!`;
} else if (hours >= 18 && hours < 24) {
return `晚上好!`;
} else {
return '偷偷向银河要了一把碎星,只等你闭上眼睛撒入你的梦中,晚安🌛!';
}
}
export function getRangeDate(startDate: string | number | Date, endDate: string | number | Date) {
const targetArr = [];
const start = new Date(startDate);
const end = new Date(endDate);
const startDateInfo = {
year: start.getFullYear(),
month: start.getMonth() + 1,
day: start.getDate(),
};
const endDateInfo = {
year: end.getFullYear(),
month: end.getMonth() + 1,
day: end.getDate(),
};
if (startDateInfo.year === endDateInfo.year) {
//同年
if (startDateInfo.month !== endDateInfo.month) {
//同年,不同月份
//获取开始时间所在月的月底日期
const startMax = new Date(startDateInfo.year, startDateInfo.month, 0).getDate();
const endNum = startMax - startDateInfo.day + endDateInfo.day;
for (let i = startDateInfo.day; i <= startDateInfo.day + endNum; i++) {
if (i > startMax) {
targetArr.push(
`${endDateInfo.year}-${
endDateInfo.month < 10 ? '0' + endDateInfo.month : endDateInfo.month
}-${i - startMax < 10 ? '0' + (i - startMax) : i - startMax}`
);
} else {
targetArr.push(
`${startDateInfo.year}-${
startDateInfo.month < 10 ? '0' + startDateInfo.month : startDateInfo.month
}-${i < 10 ? '0' + i : i}`
);
}
}
} else {
//同年同月
for (let i = startDateInfo.day; i <= endDateInfo.day; i++) {
targetArr.push(
`${startDateInfo.year}-${
startDateInfo.month < 10 ? '0' + startDateInfo.month : startDateInfo.month
}-${i < 10 ? '0' + i : i}`
);
}
}
} else {
//不同年 【既然不同年那肯定也不同月】
const startMax = new Date(startDateInfo.year, startDateInfo.month, 0).getDate();
const endNum = startMax - startDateInfo.day + endDateInfo.day;
for (let i = startDateInfo.day; i <= startDateInfo.day + endNum; i++) {
if (i > startMax) {
targetArr.push(
`${endDateInfo.year}-${
endDateInfo.month < 10 ? '0' + endDateInfo.month : endDateInfo.month
}-${i - startMax < 10 ? '0' + (i - startMax) : i - startMax}`
);
} else {
targetArr.push(
`${startDateInfo.year}-${
startDateInfo.month < 10 ? '0' + startDateInfo.month : startDateInfo.month
}-${i < 10 ? '0' + i : i}`
);
}
}
}
return targetArr;
}
export function listToTree(list: any[]) {
const map: { [key: string | number]: any } = {};
// 创建映射表,保留每个节点的 parent_id 等原始字段
list.forEach((item) => {
map[item.id] = { ...item };
});
const tree: any[] = [];
list.forEach((item) => {
const parentId = item.parent_id;
if (parentId && map[parentId]) {
// 将当前节点加入其父节点的 children 数组中
if (!map[parentId].children) {
map[parentId].children = [];
}
map[parentId].children.push(map[item.id]);
} else if (parentId === null || parentId === undefined) {
// 根节点
tree.push(map[item.id]);
}
});
return tree;
}
// 加载部门选项
export function formatTree(nodes: any[]): any[] {
return nodes.map((node) => {
const formattedNode = {
value: node.id,
label: node.name,
disabled: node.status === false || String(node.status) === 'false',
};
if (node.children && node.children.length > 0) {
Object.assign(formattedNode, { children: formatTree(node.children) });
}
return formattedNode;
});
}
export function cloneDeep(obj: any) {
return JSON.parse(JSON.stringify(obj));
}
export function isEmpty(obj: string | null | undefined) {
if (obj === undefined || obj === null || obj === '') {
return true;
} else {
return false;
}
}
// 验证是否为blob格式
export function blobValidate(data: Blob): boolean {
return data.type !== 'application/json';
}
@@ -0,0 +1,8 @@
/**
* 常量定义相关工具函数统一导出
*
* @module utils/constants/index
* @author Fastapi Admin Team
*/
export * from './links';
+35
View File
@@ -0,0 +1,35 @@
/**
* 网站链接常量配置
* 集中管理便于维护和更新链接地址
*
* @module utils/constants/links
* @author Fastapi Admin Team
*/
export const WEB_LINKS = {
// Github 主页
GITHUB_HOME: 'https://github.com/Daymychen/art-design-pro',
// 项目 Github 主页
GITHUB: 'https://github.com/Daymychen/art-design-pro',
// 个人博客
BLOG: 'https://www.artd.pro',
// 项目文档
DOCS: 'https://www.artd.pro/docs/zh/',
// 精简版本
LiteVersion: 'https://www.artd.pro/docs/zh/guide/lite-version.html',
// v2.6.1版本
OldVersion: 'https://www.artd.pro/v2/',
// 项目社区
COMMUNITY: 'https://www.artd.pro/docs/zh/community/communicate.html',
// 个人 Bilibili 主页
BILIBILI: 'https://space.bilibili.com/425500936?spm_id_from=333.1007.0.0',
// 项目介绍
INTRODUCE: 'https://www.artd.pro/docs/zh/guide/introduce.html',
};
+77
View File
@@ -0,0 +1,77 @@
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,
};
};
+12
View File
@@ -0,0 +1,12 @@
interface ErrorCodeMap {
[key: string]: string;
}
const errorCode: ErrorCodeMap = {
'401': '认证失败,无法访问系统资源',
'403': '当前操作没有权限',
'404': '访问资源不存在',
default: '系统未知错误,请反馈给管理员',
};
export default errorCode;
+30
View File
@@ -0,0 +1,30 @@
/**
* 按分页拉取全量列表(用于 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;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* 表单工具函数统一导出
*
* @module utils/form
* @author Fastapi Admin Team
*/
// 表单验证器
export * from './validator';
// 响应式布局
export * from './responsive';
+122
View File
@@ -0,0 +1,122 @@
/**
* 表单响应式布局工具模块
*
* 提供表单项在不同屏幕尺寸下的智能布局计算
*
* ## 主要功能
*
* - 响应式断点管理(xs/sm/md/lg/xl)
* - 表单列宽自动降级(避免小屏幕压缩)
* - 基于阈值的智能 span 计算
* - 响应式计算器工厂函数
* - 可配置的断点规则
*
* ## 使用场景
*
* - 表单组件响应式布局
* - 搜索表单自适应
* - 移动端表单优化
* - 多列表单布局
*
* ## 断点说明(基于 Element Plus Grid 24 栅格系统):
* - xs (手机): < 768px,小于 12 时降级为 24(满宽)
* - sm (平板): ≥ 768px,小于 12 时降级为 12(半宽)
* - md (中等屏幕): ≥ 992px,小于 8 时降级为 8(三分之一宽)
* - lg (大屏幕): ≥ 1200px,直接使用设置的 span
* - xl (超大屏幕): ≥ 1920px,直接使用设置的 span
*
* ## 核心功能
*
* - calculateResponsiveSpan: 计算响应式列宽
* - createResponsiveSpanCalculator: 创建 span 计算器(柯里化)
*
* @module utils/form/responsive
* @author Fastapi Admin Team
*/
/**
* 响应式断点类型
*/
export type ResponsiveBreakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/**
* 断点配置映射
*/
interface BreakpointConfig {
/** 最小 span 阈值 */
threshold: number;
/** 降级后的 span 值 */
fallback: number;
}
/**
* 响应式断点配置
*/
const BREAKPOINT_CONFIG: Record<ResponsiveBreakpoint, BreakpointConfig | null> = {
xs: { threshold: 12, fallback: 24 }, // 手机:小于 12 时使用满宽
sm: { threshold: 12, fallback: 12 }, // 平板:小于 12 时使用半宽
md: { threshold: 8, fallback: 8 }, // 中等屏幕:小于 8 时使用三分之一宽
lg: null, // 大屏幕:直接使用设置的 span
xl: null, // 超大屏幕:直接使用设置的 span
};
/**
* 计算响应式列宽
*
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
*
* @param itemSpan 表单项自定义的 span 值
* @param defaultSpan 默认的 span 值
* @param breakpoint 当前断点
* @returns 计算后的 span 值
*
* @example
* ```ts
* // 在 xs 断点下,span 为 6 会降级为 24(满宽)
* calculateResponsiveSpan(6, 6, 'xs') // 24
*
* // 在 md 断点下,span 为 6 会降级为 8(三分之一宽)
* calculateResponsiveSpan(6, 6, 'md') // 8
*
* // 在 lg 断点下,直接使用原始 span
* calculateResponsiveSpan(6, 6, 'lg') // 6
* ```
*/
export function calculateResponsiveSpan(
itemSpan: number | undefined,
defaultSpan: number,
breakpoint: ResponsiveBreakpoint
): number {
const finalSpan = itemSpan ?? defaultSpan;
const config = BREAKPOINT_CONFIG[breakpoint];
// 如果没有配置(lg/xl),直接返回原始 span
if (!config) {
return finalSpan;
}
// 如果 span 小于阈值,使用降级值
return finalSpan >= config.threshold ? finalSpan : config.fallback;
}
/**
* 创建响应式 span 计算器
*
* 返回一个函数,用于计算指定断点下的 span 值
*
* @param defaultSpan 默认的 span 值
* @returns span 计算函数
*
* @example
* ```ts
* const getColSpan = createResponsiveSpanCalculator(6)
* getColSpan(undefined, 'xs') // 24
* getColSpan(8, 'md') // 8
* getColSpan(12, 'lg') // 12
* ```
*/
export function createResponsiveSpanCalculator(defaultSpan: number) {
return (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
return calculateResponsiveSpan(itemSpan, defaultSpan, breakpoint);
};
}
+316
View File
@@ -0,0 +1,316 @@
/**
* 表单验证工具模块
*
* 提供全面的表单字段验证功能
*
* ## 主要功能
*
* - 手机号码验证(中国大陆格式)
* - 固定电话验证(支持区号格式)
* - 用户账号验证(字母开头,支持数字和下划线)
* - 密码强度验证(普通密码、强密码)
* - 密码强度评估(弱、中、强)
* - IPv4 地址验证
* - 邮箱地址验证(RFC 5322 标准)
* - URL 地址验证
* - 身份证号码验证(18位,含校验码验证)
* - 银行卡号验证(Luhn 算法)
* - 字符串空格处理
*
* ## 验证规则
*
* - 手机号:1开头,第二位3-9,共11位
* - 账号:字母开头,5-20位,支持字母数字下划线
* - 普通密码:6-20位,必须包含字母和数字
* - 强密码:8-20位,必须包含大小写字母、数字和特殊字符
* - 身份证:18位,含出生日期和校验码验证
* - 银行卡:13-19位,通过 Luhn 算法验证
*
* @module utils/validation/formValidator
* @author Fastapi Admin Team
*/
/**
* 密码强度级别枚举
*/
export enum PasswordStrength {
WEAK = '弱',
MEDIUM = '中',
STRONG = '强',
}
/**
* 去除字符串首尾空格
* @param value 待处理的字符串
* @returns 返回去除首尾空格后的字符串
*/
export function trimSpaces(value: string): string {
if (typeof value !== 'string') {
return '';
}
return value.trim();
}
/**
* 验证手机号码(中国大陆)
* @param value 手机号码字符串
* @returns 返回验证结果,true表示格式正确
*/
export function validatePhone(value: string): boolean {
if (!value || typeof value !== 'string') {
return false;
}
// 中国大陆手机号码:1开头,第二位为3-9,共11位数字
const phoneRegex = /^1[3-9]\d{9}$/;
return phoneRegex.test(value.trim());
}
/**
* 验证固定电话号码(中国大陆)
* @param value 电话号码字符串
* @returns 返回验证结果,true表示格式正确
*/
export function validateTelPhone(value: string): boolean {
if (!value || typeof value !== 'string') {
return false;
}
// 支持格式:区号-号码,如:010-12345678、0755-1234567
const telRegex = /^0\d{2,3}-?\d{7,8}$/;
return telRegex.test(value.trim().replace(/\s+/g, ''));
}
/**
* 验证用户账号
* @param value 账号字符串
* @returns 返回验证结果,true表示格式正确
* @description 规则:字母开头,5-20位,支持字母、数字、下划线
*/
export function validateAccount(value: string): boolean {
if (!value || typeof value !== 'string') {
return false;
}
// 字母开头,5-20位,支持字母、数字、下划线
const accountRegex = /^[a-zA-Z][a-zA-Z0-9_]{4,19}$/;
return accountRegex.test(value.trim());
}
/**
* 验证密码
* @param value 密码字符串
* @returns 返回验证结果,true表示格式正确
* @description 规则:6-20位,必须包含字母和数字
*/
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;
}
/**
* 验证强密码
* @param value 密码字符串
* @returns 返回验证结果,true表示格式正确
* @description 规则:8-20位,必须包含大写字母、小写字母、数字和特殊字符
*/
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;
}
/**
* 获取密码强度
* @param value 密码字符串
* @returns 返回密码强度:弱、中、强
* @description 弱:纯数字/纯字母/纯特殊字符;中:两种组合;强:三种或以上组合
*/
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;
} else if (typeCount >= 2) {
return PasswordStrength.MEDIUM;
} else {
return PasswordStrength.WEAK;
}
}
/**
* 验证IPv4地址
* @param value IP地址字符串
* @returns 返回验证结果,true表示格式正确
*/
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;
});
}
/**
* 验证邮箱地址
* @param value 邮箱地址字符串
* @returns 返回验证结果,true表示格式正确
*/
export function validateEmail(value: string): boolean {
if (!value || typeof value !== 'string') {
return false;
}
const trimmedValue = value.trim();
// RFC 5322 标准的简化版邮箱正则
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;
}
/**
* 验证URL地址
* @param value URL字符串
* @returns 返回验证结果,true表示格式正确
*/
export function validateURL(value: string): boolean {
if (!value || typeof value !== 'string') {
return false;
}
try {
new URL(value.trim());
return true;
} catch {
return false;
}
}
/**
* 验证身份证号码(中国大陆)
* @param value 身份证号码字符串
* @returns 返回验证结果,true表示格式正确
*/
export function validateChineseIDCard(value: string): boolean {
if (!value || typeof value !== 'string') {
return false;
}
const trimmedValue = value.trim();
// 18位身份证号码正则
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;
}
/**
* 验证银行卡号
* @param value 银行卡号字符串
* @returns 返回验证结果,true表示格式正确
*/
export function validateBankCard(value: string): boolean {
if (!value || typeof value !== 'string') {
return false;
}
const trimmedValue = value.trim().replace(/\s+/g, '');
// 银行卡号通常为13-19位数字
if (!/^\d{13,19}$/.test(trimmedValue)) {
return false;
}
// Luhn算法验证
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;
}
+222
View File
@@ -0,0 +1,222 @@
/**
* HTTP 错误处理模块
*
* 提供统一的 HTTP 请求错误处理机制,包括:
* - 自定义 HttpError 错误类,封装错误信息、状态码、时间戳等
* - 错误拦截和转换,将 Axios 错误转换为标准的 HttpError
* - 错误消息国际化处理,根据状态码返回对应的多语言错误提示
* - 统一的成功/错误消息展示
*
* @module utils/http/error
*/
import { AxiosError } from 'axios';
import { ApiStatus, ResultEnum } from './status';
import { $t } from '@/locales';
/** 错误响应数据结构 */
export interface ErrorResponse {
/** 错误状态码 */
code: number;
/** 错误消息 */
msg: string;
/** 错误附加数据 */
data?: unknown;
}
/** 错误日志数据结构 */
export interface ErrorLogData {
/** 错误状态码 */
code: number;
/** 错误消息 */
message: string;
/** 错误附加数据 */
data?: unknown;
/** 错误发生时间戳 */
timestamp: string;
/** 请求 URL */
url?: string;
/** 请求方法 */
method?: string;
/** 错误堆栈信息 */
stack?: string;
}
/**
* HTTP 错误类
*
* 封装 HTTP 请求过程中产生的错误信息,包含错误码、错误消息、请求上下文等
* 继承自 Error 类,支持错误堆栈追踪
*/
export class HttpError extends Error {
/** 错误状态码 */
public readonly code: number;
/** 错误附加数据 */
public readonly data?: unknown;
/** 错误发生时间戳 */
public readonly timestamp: string;
/** 请求 URL */
public readonly url?: string;
/** 请求方法 */
public readonly method?: string;
/**
* 创建 HttpError 实例
* @param message 错误消息
* @param code 错误状态码
* @param options 可选配置,包含 data、url、method 等附加信息
*/
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;
}
/**
* 将错误转换为日志数据结构
* @returns 错误日志数据
*/
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,
};
}
}
/**
* 根据 HTTP 状态码获取国际化错误消息
* @param status HTTP 状态码
* @returns 国际化后的错误消息
*/
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');
};
/**
* 处理 Axios 错误并转换为 HttpError
*
* @param error Axios 错误对象
* @returns 转换后的 HttpError
* @throws HttpError 始终抛出 HttpError 实例
*/
export function handleError(error: AxiosError<ErrorResponse>): 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) {
let errorMsg = $t('httpMsg.networkError');
if (error.message?.includes('ECONNREFUSED')) {
errorMsg = '服务器连接失败,请检查后端服务是否正常运行';
} else if (error.message?.includes('timeout')) {
errorMsg = '请求超时,请稍后重试';
} else if (error.message?.includes('Network Error')) {
errorMsg = '网络连接错误,请检查您的网络设置';
}
throw new HttpError(errorMsg, ApiStatus.error, {
url: requestConfig?.url,
method: requestConfig?.method?.toUpperCase(),
});
}
// 根据 HTTP 状态码获取错误消息
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(),
});
}
/**
* 显示错误消息
*
* @param error HttpError 实例
* @param showMessage 是否显示错误消息弹窗,默认 true
*/
export function showError(error: HttpError, showMessage: boolean = true): void {
if (showMessage) {
ElMessage.error(error.message);
}
// 记录错误日志
console.error('[HTTP Error]', error.toLogData());
}
/**
* 显示成功消息
*
* @param message 成功消息文本
* @param showMessage 是否显示消息弹窗,默认 true
*/
export function showSuccess(message: string, showMessage: boolean = true): void {
if (showMessage) {
ElMessage.success(message);
}
}
/**
* 类型守卫:判断是否为 HttpError 类型
*
* @param error 任意错误对象
* @returns 是否为 HttpError 类型
*/
export const isHttpError = (error: unknown): error is HttpError => {
return error instanceof HttpError;
};
/**
* 判断业务响应码是否为成功状态
*
* @param code 业务响应码
* @returns 是否为成功状态
*/
export const isApiSuccess = (code: number): boolean => {
return code === ResultEnum.SUCCESS;
};
/**
* 判断业务响应码是否为 Token 过期状态
*
* @param code 业务响应码
* @returns 是否为 Token 过期状态
*/
export const isTokenExpired = (code: number): boolean => {
return code === ResultEnum.TOKEN_EXPIRED;
};
+230
View File
@@ -0,0 +1,230 @@
/**
* HTTP 请求封装模块
*
* 基于 Axios 封装的 HTTP 请求工具,提供统一的请求/响应处理
*
* @module utils/http
*/
import axios, { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import qs from 'qs';
import { useUserStore } from '@/store/modules/user';
import { ApiStatus, ResultEnum } from './status';
import { HttpError, showError } from './error';
import { $t } from '@/locales';
const REQUEST_TIMEOUT = 15000;
const MAX_RETRIES = 0;
const RETRY_DELAY = 1000;
export interface HttpRequestConfig extends AxiosRequestConfig {
showErrorMessage?: boolean;
showSuccessMessage?: boolean;
}
const { VITE_APP_BASE_API, VITE_TIMEOUT } = import.meta.env;
const httpRequest = axios.create({
baseURL: VITE_APP_BASE_API,
timeout: VITE_TIMEOUT || REQUEST_TIMEOUT,
headers: { 'Content-Type': 'application/json;charset=utf-8' },
paramsSerializer: (params) => qs.stringify(params, { indices: false }),
});
httpRequest.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken = useUserStore().accessToken;
const auth = config.headers.Authorization;
if (auth === 'no-auth') {
delete config.headers.Authorization;
return config;
}
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>) => {
if (response.config.responseType === 'blob') {
return response;
}
const { code, msg } = response.data;
if (code !== ResultEnum.SUCCESS) {
ElMessage.error(msg);
return Promise.reject(response);
}
const method = response.config.method?.toUpperCase();
const url = response.config.url;
if (method !== 'GET' && !url?.includes('login') && !url?.includes('logout')) {
if (msg) {
ElMessage.success(msg);
}
}
return response;
},
async (error: any) => {
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));
}
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 || '请求错误'));
} else 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 data = error.response?.data;
const status = error.response?.status;
const hasApiCode =
data !== undefined &&
data !== null &&
typeof data === 'object' &&
'code' in data &&
typeof data.code === 'number';
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('请求处理失败'));
}
}
);
function redirectToLogin(message: string) {
useUserStore().logOut();
window.location.href = `/login?redirect=${encodeURIComponent(window.location.hash.slice(1))}`;
ElMessage.error(message);
}
function shouldRetry(statusCode: number) {
return [
ApiStatus.requestTimeout,
ApiStatus.internalServerError,
ApiStatus.badGateway,
ApiStatus.serviceUnavailable,
ApiStatus.gatewayTimeout,
].includes(statusCode);
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function retryRequest<T>(
config: HttpRequestConfig,
retries: number = MAX_RETRIES
): Promise<AxiosResponse<T>> {
try {
return await request<T>(config);
} catch (error) {
if (retries > 0 && error instanceof HttpError && shouldRetry(error.code)) {
await delay(RETRY_DELAY);
return retryRequest<T>(config, retries - 1);
}
throw error;
}
}
async function request<T = any>(config: HttpRequestConfig): Promise<AxiosResponse<T>> {
if (
['POST', 'PUT'].includes(config.method?.toUpperCase() || '') &&
config.params &&
!config.data
) {
config.data = config.params;
config.params = undefined;
}
try {
const res = await httpRequest.request<T>(config);
return res;
} catch (error) {
if (error instanceof HttpError && error.code !== ApiStatus.unauthorized) {
const showMsg = config.showErrorMessage !== false;
showError(error, showMsg);
}
return Promise.reject(error);
}
}
const api = Object.assign(
async function <T = any>(config: HttpRequestConfig): Promise<AxiosResponse<T>> {
return retryRequest<T>(config);
},
{
get<T>(config: HttpRequestConfig) {
return retryRequest<T>({ ...config, method: 'GET' });
},
post<T>(config: HttpRequestConfig) {
return retryRequest<T>({ ...config, method: 'POST' });
},
put<T>(config: HttpRequestConfig) {
return retryRequest<T>({ ...config, method: 'PUT' });
},
del<T>(config: HttpRequestConfig) {
return retryRequest<T>({ ...config, method: 'DELETE' });
},
request<T>(config: HttpRequestConfig) {
return retryRequest<T>(config);
},
}
);
export default api;
export { httpRequest };
+43
View File
@@ -0,0 +1,43 @@
/**
* HTTP 状态码枚举
*
* 定义标准的 HTTP 状态码,用于区分 HTTP 协议层面的响应状态
*
* @module utils/http/status
*/
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,
/** HTTP 版本不支持 */
httpVersionNotSupported = 505,
}
/**
* 业务响应码枚举
*
* 定义后端接口返回的业务状态码,用于区分业务处理结果
* 与 ApiStatus(HTTP 状态码)不同,这是接口 data 字段中的 code 值
*/
export { ResultEnum } from '@/enums/api/result.enum';
+13
View File
@@ -0,0 +1,13 @@
// translate router.meta.title, be used in breadcrumb sidebar tagsview
import i18n from '@/locales';
import type { Composer } from 'vue-i18n';
export function translateRouteTitle(title: any) {
// 判断是否存在国际化配置,如果没有原生返回
const hasKey = (i18n as Composer).global.te('route.' + title);
if (hasKey) {
const translatedTitle = (i18n as Composer).global.t('route.' + title);
return translatedTitle;
}
return title;
}
+155
View File
@@ -0,0 +1,155 @@
/**
* 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 取绝对值
*
* @param growthRate
* @returns
*/
export function formatGrowthRate(growthRate: number) {
if (growthRate === 0) {
return '-';
}
const formattedRate = Math.abs(growthRate * 100)
.toFixed(2)
.replace(/\.?0+$/, '');
return formattedRate + '%';
}
//表单生成相关
export function isNumberStr(str: string): boolean {
return /^[+-]?(0|([1-9]\d*))(\.\d+)?$/g.test(str);
}
export function titleCase(str: string): string {
return str.replace(/([A-Z])/g, ' $1').replace(/^./, function (str) {
return str.toUpperCase();
});
}
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 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,
},
};
/**
* Utils 工具函数统一导出
* 提供向后兼容性和便捷导入
*
* @module utils/index
* @author Fastapi Admin Team
*/
// UI 相关
export * from './ui';
// 路由相关
export * from './router';
// 路由导航相关
export * from './navigation';
// 系统管理相关
export * from './sys';
// 常量定义相关
export * from './constants';
// 存储相关
export * from './storage';
// HTTP 相关
export * from './http';
// 表单相关
export * from './form';
// socket 相关
export * from './socket';
@@ -0,0 +1,10 @@
/**
* 路由和导航相关工具函数统一导出
*
* @module utils/navigation/index
* @author Fastapi Admin Team
*/
export * from './jump';
export * from './worktab';
export * from './route';
+68
View File
@@ -0,0 +1,68 @@
/**
* 导航跳转工具模块
*
* 提供统一的页面跳转和导航功能
*
* ## 主要功能
*
* - 外部链接打开(新窗口)
* - 菜单项跳转处理(支持内部路由和外部链接)
* - iframe 页面跳转支持
* - 递归查找并跳转到第一个可见的子菜单
* - 智能判断跳转目标类型(外部链接/内部路由)
*
* @module utils/navigation/jump
* @author Fastapi Admin Team
*/
import { AppRouteRecord } from '@/types/router';
import router from '@/router';
import { isNavigableMenuItem } from './route';
// 打开外部链接
export const openExternalLink = (link: string) => {
window.open(link, '_blank');
};
/**
* 菜单跳转
* @param item 菜单项
* @param jumpToFirst 是否跳转到第一个子菜单
* @returns
*/
export const handleMenuJump = (item: AppRouteRecord, jumpToFirst: boolean = false) => {
// 处理外部链接
const { link, isIframe } = item.meta;
if (link && !isIframe) {
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);
}
// 跳转到子菜单路径
router.push(firstChild.path);
};
@@ -0,0 +1,85 @@
/**
* 路由工具模块
*
* 提供路由处理和菜单路径相关的工具函数
*
* ## 主要功能
*
* - iframe 路由检测,判断是否为外部嵌入页面
* - 菜单项有效性验证,过滤隐藏和无效菜单
* - 路径标准化处理,统一路径格式
* - 递归查找菜单树中第一个有效路径
* - 支持多级嵌套菜单的路径解析
*
* ## 使用场景
*
* - 系统初始化时获取默认跳转路径
* - 菜单权限过滤后获取首个可访问页面
* - 路由重定向逻辑处理
* - iframe 页面特殊处理
*
* @module utils/navigation/route
* @author Fastapi Admin Team
*/
import { AppRouteRecord } from '@/types';
// 检查是否为 iframe 路由
export function isIframe(url: string): boolean {
return url.startsWith('/outside/iframe/');
}
/**
* 判断菜单项是否可作为默认导航落点
* 隐藏的全屏页面虽然不展示在菜单中,但仍然可能是合法首页。
*/
export const isNavigableMenuItem = (menuItem: AppRouteRecord): boolean => {
if (!menuItem.path || !menuItem.path.trim()) {
return false;
}
if (!menuItem.meta?.isHide) {
return true;
}
return menuItem.meta?.isFullPage === true;
};
/**
* 标准化路径格式
* @param path 路径
* @returns 标准化后的路径
*/
const normalizePath = (path: string): string => {
return path.startsWith('/') ? path : `/${path}`;
};
/**
* 递归获取菜单的第一个有效路径
* @param menuList 菜单列表
* @returns 第一个有效路径,如果没有找到则返回空字符串
*/
export const getFirstMenuPath = (menuList: AppRouteRecord[]): 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 '';
};
@@ -0,0 +1,67 @@
/**
* 工作标签页管理模块
*
* 提供工作标签页(Worktab)的自动管理功能
*
* ## 主要功能
*
* - 根据路由导航自动创建和更新工作标签页
* - iframe 页面标签页特殊处理
* - 标签页信息提取(标题、路径、缓存状态等)
* - 固定标签页支持
* - 根据系统设置控制标签页显示
* - 首页标签页特殊处理
*
* ## 使用场景
*
* - 路由守卫中自动创建标签页
* - 页面切换时更新标签页状态
* - 多标签页导航系统
*
* @module utils/navigation/worktab
* @author Fastapi Admin Team
*/
import { useWorktabStore } from '@/store/modules/worktab';
import { RouteLocationNormalized } from 'vue-router';
import { isIframe } from './route';
import { useSettingStore } from '@/store/modules/setting';
import { IframeRouteManager } from '@/router/core';
import { useCommon } from '@/hooks/core/useCommon';
/**
* 根据当前路由信息设置工作标签页(worktab)
* @param to 当前路由对象
*/
export const setWorktab = (to: RouteLocationNormalized): void => {
const worktabStore = useWorktabStore();
const { meta, path, name, params, query } = to;
if (!meta.isHideTab) {
// 如果是 iframe 页面,则特殊处理工作标签页
if (isIframe(path)) {
const iframeRoute = IframeRouteManager.getInstance().findByPath(to.path);
if (iframeRoute?.meta) {
worktabStore.openTab({
title: iframeRoute.meta.title,
icon: meta.icon as string,
path,
name: name as string,
keepAlive: meta.keepAlive as boolean,
params,
query,
});
}
} else if (useSettingStore().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
@@ -0,0 +1,21 @@
/**
* 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;
+138
View File
@@ -0,0 +1,138 @@
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();
+61
View File
@@ -0,0 +1,61 @@
/**
* 路由工具函数
*
* 提供路由相关的工具函数
*
* @module utils/router
*/
import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
import AppConfig from '@/config';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import i18n, { $t } from '@/locales';
/** 扩展的路由配置类型 */
export type AppRouteRecordRaw = RouteRecordRaw & {
hidden?: boolean;
};
/** 顶部进度条配置 */
export const configureNProgress = () => {
NProgress.configure({
easing: 'ease',
speed: 600,
showSpinner: false,
parent: 'body',
});
};
/**
* 设置页面标题,根据路由元信息和系统信息拼接标题
* @param to 当前路由对象
*/
export const setPageTitle = (to: RouteLocationNormalized): void => {
const { title } = to.meta;
if (title) {
setTimeout(() => {
document.title = `${formatMenuTitle(String(title))} - ${AppConfig.systemInfo.name}`;
}, 150);
}
};
/**
* 格式化菜单标题
* @param title 菜单标题,可以是 i18n 的 key,也可以是字符串
* @returns 格式化后的菜单标题
*/
export const formatMenuTitle = (title: string): string => {
if (title) {
if (title.startsWith('menus.')) {
// 使用 te() 方法检查翻译键值是否存在,避免控制台警告
if (i18n.global.te(title)) {
return $t(title);
} else {
// 如果翻译不存在,返回键值的最后部分作为fallback
return title.split('.').pop() || title;
}
}
return title;
}
return '';
};
+423
View File
@@ -0,0 +1,423 @@
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
@@ -0,0 +1,64 @@
/**
* 存储工具类
* 提供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();
}
}
+7
View File
@@ -0,0 +1,7 @@
/**
* 存储相关工具函数统一导出
*/
export * from './storage';
export * from './storage-config';
export * from './storage-key-manager';
@@ -0,0 +1,125 @@
/**
* 存储配置管理模块
*
* 提供统一的本地存储配置和工具方法
*
* ## 主要功能
*
* - 版本化存储键管理,支持多版本数据隔离
* - 存储键名生成和解析(带版本前缀)
* - 版本号提取和验证
* - 存储键匹配的正则表达式生成
* - 旧版本存储键兼容处理
* - 升级和登出延迟配置
* - 主题存储键配置
*
* ## 使用场景
*
* - Pinia Store 持久化存储
* - 应用版本升级时的数据迁移
* - 多版本数据清理
* - 存储键的统一管理和规范
*
* 存储键格式:sys-v{version}-{storeId}
* 例如:sys-v1.0.0-user, sys-v1.0.0-setting
*
* @module utils/storage/storage-config
* @author Fastapi Admin Team
*/
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;
}
}
@@ -0,0 +1,97 @@
/**
* 存储键名管理器模块
*
* 提供智能的版本化存储键管理和数据迁移功能
*
* ## 主要功能
*
* - 自动生成当前版本的存储键名
* - 检测当前版本数据是否存在
* - 查找其他版本的同名存储数据
* - 自动将旧版本数据迁移到当前版本
* - 数据迁移日志记录
* - 迁移失败的错误处理
*
* ## 使用场景
*
* - Pinia Store 持久化插件中获取存储键
* - 应用版本升级时自动迁移用户数据
* - 避免版本升级导致的数据丢失
* - 实现平滑的版本过渡
*
* ## 工作流程
*
* 1. 优先使用当前版本的存储键
* 2. 如果当前版本无数据,查找其他版本的同名数据
* 3. 找到旧版本数据后自动迁移到当前版本
* 4. 返回当前版本的存储键供使用
*
* @module utils/storage/storage-key-manager
* @author Fastapi Admin Team
*/
import { StorageConfig } from '@/utils/storage/storage-config';
/**
* 存储键名管理器
* 负责处理版本化的存储键名生成和数据迁移
*/
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;
}
}
+252
View File
@@ -0,0 +1,252 @@
/**
* 存储兼容性管理模块
*
* 提供完整的本地存储兼容性检查和数据验证功能
*
* 主要功能
*
* - 多版本存储数据检测和验证
* - 新旧存储格式兼容处理
* - 存储数据完整性校验
* - 存储异常自动恢复(清理+登出)
* - 登录状态验证
* - 存储为空检测
* - 版本号管理
*
* ## 使用场景
*
* - 应用启动时检查存储数据有效性
* - 路由守卫中验证登录状态
* - 版本升级时的数据兼容性检查
* - 存储异常时的自动恢复
* - 防止因存储数据损坏导致的系统异常
*
* ## 工作流程
*
* 1. 优先检查当前版本的存储数据
* 2. 检查其他版本的存储数据
* 3. 兼容旧格式的存储数据
* 4. 验证数据完整性
* 5. 异常时提示用户并执行登出
*
* @module utils/storage/storage
* @author Fastapi Admin Team
*/
import router from '@/router';
import { useUserStore } from '@/store/modules/user';
import { StorageConfig } from '@/utils/storage/storage-config';
/**
* 存储兼容性管理器
* 负责处理不同版本间的存储兼容性检查和数据验证
*/
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(() => {
try {
localStorage.clear();
useUserStore().logOut();
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);
}
+13
View File
@@ -0,0 +1,13 @@
// ANSI 转义码生成网站 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技术支持(联系作者): 948080782,和开发者一起交流~ 群里有小伙伴实时答疑,遇到问题不用慌!
\x1b[0m
`;
console.log(asciiArt);
+143
View File
@@ -0,0 +1,143 @@
/**
* 全局错误处理模块
*
* 提供统一的错误捕获和处理机制
*
* ## 主要功能
*
* - Vue 运行时错误捕获(组件错误、生命周期错误等)
* - 全局脚本错误捕获(语法错误、运行时错误等)
* - Promise 未捕获错误处理(unhandledrejection)
* - 静态资源加载错误监控(图片、脚本、样式等)
* - 错误日志记录和上报
* - 统一的错误处理入口
*
* ## 使用场景
* - 应用启动时安装全局错误处理器
* - 捕获和记录所有类型的错误
* - 错误上报到监控平台
* - 提升应用稳定性和可维护性
* - 问题排查和调试
*
* ## 错误类型
*
* - VueError: Vue 组件相关错误
* - ScriptError: JavaScript 脚本错误
* - PromiseError: Promise 未捕获的 rejection
* - ResourceError: 静态资源加载失败
*
* @module utils/sys/error-handle
* @author Fastapi Admin Team
*/
import type { App } from 'vue';
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;
}
/**
* Vue 运行时错误处理
*/
export function vueErrorHandler(err: unknown, instance: any, info: string) {
console.error('[VueError]', err, info, instance);
// 这里可以上报到服务端,比如:
// reportError({ type: 'vue', err, info })
}
/**
* 全局脚本错误处理
*/
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 });
// reportError({ type: 'script', message, source, lineno, colno, error })
return true; // 阻止默认控制台报错,可根据需求改
}
/**
* Promise 未捕获错误处理
*/
export function registerPromiseErrorHandler() {
window.addEventListener('unhandledrejection', (event) => {
console.error('[PromiseError]', event.reason);
// reportError({ type: 'promise', reason: event.reason })
});
}
/**
* 资源加载错误处理 (img, script, css...)
*/
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,
});
// reportError({ type: 'resource', target })
}
},
true // 捕获阶段才能监听到资源错误
);
}
/**
* 安装统一错误处理
*/
export function setupErrorHandle(app: App) {
app.config.errorHandler = vueErrorHandler;
window.onerror = scriptErrorHandler;
registerPromiseErrorHandler();
registerResourceErrorHandler();
}
+6
View File
@@ -0,0 +1,6 @@
/**
* 系统管理相关工具函数统一导出
*/
export * from './upgrade';
export { default as mittBus } from './mittBus';
+63
View File
@@ -0,0 +1,63 @@
/**
* 全局事件总线模块
*
* 基于 mitt 库实现的类型安全的事件总线
*
* ## 主要功能
*
* - 跨组件通信(发布/订阅模式)
* - 类型安全的事件定义和调用
* - 全局事件管理(烟花效果、设置面板、搜索对话框等)
* - 解耦组件间的直接依赖
*
* ## 使用场景
*
* - 跨层级组件通信
* - 全局功能触发(设置、搜索、聊天、锁屏等)
* - 特效触发(烟花效果)
* - 避免 props 层层传递
*
* ## 用法示例
*
* ```typescript
* // 订阅事件
* mittBus.on('openSetting', () => { ... })
*
* // 发布事件
* mittBus.emit('openSetting')
*
* // 带参数的事件
* mittBus.emit('triggerFireworks', 'image-url')
* ```
*
* ## 已定义的事件
*
* - triggerFireworks: 触发烟花效果(可选图片URL)
* - openSetting: 打开设置面板
* - openSearchDialog: 打开搜索对话框
* - openChat: 打开聊天窗口
* - openLockScreen: 打开锁屏
*
* @module utils/sys/mittBus
* @author Fastapi Admin Team
*/
import mitt, { type Emitter } from 'mitt';
// 定义事件类型映射
type Events = {
// 烟花效果事件 - 可选的图片URL参数
triggerFireworks: string | undefined;
// 打开设置面板事件 - 无参数
openSetting: void;
// 打开搜索对话框事件 - 无参数
openSearchDialog: void;
// 打开聊天窗口事件 - 无参数
openChat: void;
// 打开锁屏事件 - 无参数
openLockScreen: void;
};
// 创建类型安全的事件总线实例
const mittBus: Emitter<Events> = mitt<Events>();
export default mittBus;
+277
View File
@@ -0,0 +1,277 @@
/**
* 系统版本升级管理模块
*
* 提供完整的应用版本升级检测和处理功能
*
* ## 主要功能
*
* - 版本号比较和升级检测
* - 首次访问识别和处理
* - 旧版本数据自动清理
* - 升级日志展示和通知
* - 强制重新登录控制(根据升级日志配置)
* - 版本号规范化处理
* - 旧存储结构迁移和清理
* - 升级流程延迟执行(确保应用完全加载)
*
* ## 使用场景
*
* - 应用启动时自动检测版本升级
* - 版本更新后清理旧数据
* - 向用户展示版本更新内容
* - 重大更新时要求用户重新登录
* - 防止旧版本数据污染新版本
*
* ## 工作流程
*
* 1. 检查本地存储的版本号
* 2. 与当前应用版本对比
* 3. 查找并清理旧版本数据
* 4. 展示升级通知(包含更新日志)
* 5. 根据配置决定是否强制重新登录
* 6. 更新本地版本号
*
* @module utils/sys/upgrade
* @author Fastapi Admin Team
*/
import { upgradeLogList } from '@/mock/upgrade/changeLog';
import { ElNotification } from 'element-plus';
import { useUserStore } from '@/store/modules/user';
import { StorageConfig } from '@/utils/storage/storage-config';
/**
* 版本管理器
* 负责处理版本比较、升级检测和数据清理
*/
class VersionManager {
/**
* 规范化版本号字符串,移除前缀 'v'
*/
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;
}
// 延迟执行升级流程,确保应用已完全加载
setTimeout(() => {
this.executeUpgrade(storedVersion!, legacyStorage);
}, StorageConfig.UPGRADE_DELAY);
}
}
// 创建版本管理器实例
const versionManager = new VersionManager();
/**
* 系统升级处理入口函数
*/
export async function systemUpgrade(): Promise<void> {
await versionManager.processUpgrade();
}
+266
View File
@@ -0,0 +1,266 @@
/**
* 表格缓存管理模块
*
* 提供高性能的表格数据缓存机制
*
* ## 主要功能
*
* - 基于参数的智能缓存键生成(使用 ohash)
* - LRU(最近最少使用)缓存淘汰策略
* - 缓存过期时间管理
* - 缓存大小限制和自动清理
* - 基于标签的缓存分组管理
* - 多种缓存失效策略(清空所有、清空当前、清空分页等)
* - 缓存访问统计和命中率分析
* - 缓存大小估算
*
* ## 使用场景
*
* - 表格数据的分页缓存
* - 减少重复的 API 请求
* - 提升表格切换和返回的响应速度
* - 搜索条件变化时的智能缓存管理
* - 数据更新后的缓存失效处理
*
* ## 缓存策略
*
* - CLEAR_ALL: 清空所有缓存(适用于全局数据更新)
* - CLEAR_CURRENT: 仅清空当前查询条件的缓存(适用于单条数据更新)
* - CLEAR_PAGINATION: 清空所有分页缓存但保留不同搜索条件(适用于批量操作)
* - KEEP_ALL: 不清除缓存(适用于只读操作)
*
* @module utils/table/tableCache
* @author Fastapi Admin Team
*/
import { hash } from 'ohash';
// 缓存失效策略枚举
export enum CacheInvalidationStrategy {
/** 清空所有缓存 */
CLEAR_ALL = 'clear_all',
/** 仅清空当前查询条件的缓存 */
CLEAR_CURRENT = 'clear_current',
/** 清空所有分页缓存(保留不同搜索条件的缓存) */
CLEAR_PAGINATION = 'clear_pagination',
/** 不清除缓存 */
KEEP_ALL = 'keep_all',
}
// 通用 API 响应接口(兼容不同的后端响应格式)
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>;
// 访问次数(用于 LRU 算法)
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) {
// 默认5分钟,最多50条缓存
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;
}
// 🔧 优化:LRU 缓存清理
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()) {
// 粗略估算大小(JSON字符串长度)
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;
}
}
@@ -0,0 +1,55 @@
/**
* 表格全局配置模块
*
* 提供表格与后端接口的字段映射配置
*
* ## 主要功能
*
* - 响应数据字段自动识别和映射
* - 支持多种常见的后端响应格式
* - 请求参数字段映射配置
* - 可扩展的字段配置机制
*
* ## 使用场景
*
* - 适配不同后端的分页接口格式
* - 统一前端表格组件的数据处理
* - 减少重复的数据转换代码
* - 支持多个后端服务的接口对接
*
* ## 配置说明
*
* - recordFields: 列表数据字段名(按优先级顺序查找)
* - totalFields: 总条数字段名
* - currentFields: 当前页码字段名
* - sizeFields: 每页大小字段名
* - paginationKey: 前端发送请求时使用的分页参数名
*
* ## 扩展方式
*
* 如果后端使用其他字段名,可以在对应数组中添加新的字段名
* 例如:recordFields: ['list', 'data', 'records', 'items', 'yourCustomField']
*
* @module utils/table/tableConfig
* @author Fastapi Admin Team
*/
export const tableConfig = {
// 响应数据字段映射配置,系统会从接口返回数据中按顺序查找这些字段
// 列表数据
recordFields: ['list', 'data', 'records', 'items', 'result', 'rows'],
// 总条数
totalFields: ['total', 'count'],
// 当前页码
currentFields: ['current', 'page', 'pageNum'],
// 每页大小
sizeFields: ['size', 'pageSize', 'limit'],
// 请求参数映射配置,前端发送请求时使用的分页参数名
// useTable 组合式函数传递分页参数的时候 用 current 跟 size
paginationKey: {
// 当前页码
current: 'current',
// 每页大小
size: 'size',
},
};
+297
View File
@@ -0,0 +1,297 @@
/**
* 表格工具函数模块
*
* 提供表格数据处理和请求管理的核心工具函数
*
* ## 主要功能
*
* - 多格式 API 响应自动适配和标准化
* - 表格数据提取和转换
* - 分页信息自动更新和校验
* - 智能防抖函数(支持取消和立即执行)
* - 统一的错误处理机制
* - 嵌套数据结构解析
*
* ## 使用场景
*
* - useTable 组合式函数的底层工具
* - 适配各种后端接口响应格式
* - 表格数据的标准化处理
* - 请求防抖和性能优化
* - 错误统一处理和日志记录
*
* ## 支持的响应格式
*
* 1. 直接数组: [item1, item2, ...]
* 2. 标准对象: { records: [], total: 100 }
* 3. 嵌套data: { data: { list: [], total: 100 } }
* 4. 多种字段名: list/data/records/items/result/rows
*
* ## 核心功能
*
* - defaultResponseAdapter: 智能识别和转换响应格式
* - extractTableData: 提取表格数据数组
* - updatePaginationFromResponse: 更新分页信息
* - createSmartDebounce: 创建可控的防抖函数
* - createErrorHandler: 生成错误处理器
*
* @module utils/table/tableUtils
* @author Fastapi Admin Team
*/
import type { ApiResponse } from './tableCache';
import { tableConfig } from './tableConfig';
// 请求参数基础接口,扩展分页参数
export interface BaseRequestParams extends Api.Common.PaginationParams {
[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 ?? {}];
const currentFields = tableConfig.currentFields;
for (const src of sources) {
for (const field of currentFields) {
if (field in src && typeof src[field] === 'number') {
result.current = src[field] as number;
break;
}
}
if (result.current !== undefined) break;
}
const sizeFields = tableConfig.sizeFields;
for (const src of sources) {
for (const field of 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;
}
/**
* 默认响应适配器 - 支持多种常见的API响应格式
*/
export const defaultResponseAdapter = <T>(response: unknown): ApiResponse<T> => {
// 定义支持的字段
const recordFields = tableConfig.recordFields;
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);
// 如果没有找到,检查嵌套data
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;
};
/**
* 从标准化的API响应中提取表格数据
*/
export const extractTableData = <T>(response: ApiResponse<T>): T[] => {
const data = response.records || response.data || [];
return Array.isArray(data) ? data : [];
};
/**
* 根据API响应更新分页信息
*/
export const updatePaginationFromResponse = <T>(
pagination: Api.Common.PaginationParams,
response: ApiResponse<T>
): void => {
pagination.total = response.total ?? pagination.total ?? 0;
if (response.current !== undefined) {
pagination.current = response.current;
}
const maxPage = Math.max(1, Math.ceil(pagination.total / (pagination.size || 1)));
if (pagination.current > maxPage) {
pagination.current = maxPage;
}
};
/**
* 创建智能防抖函数 - 支持取消和立即执行
*/
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 Promise.resolve();
};
return debouncedFn as any;
};
/**
* 生成错误处理函数
*/
export const createErrorHandler = (
onError?: (error: TableError) => void,
enableLog: boolean = false
) => {
const logger = {
error: (message: string, ...args: any[]) => {
if (enableLog) console.error(`[useTable] ${message}`, ...args);
},
};
return (err: unknown, context: string): TableError => {
const tableError: TableError = {
code: 'UNKNOWN_ERROR',
message: '未知错误',
details: err,
};
if (err instanceof Error) {
tableError.message = err.message;
tableError.code = err.name;
} else if (typeof err === 'string') {
tableError.message = err;
}
logger.error(`${context}:`, err);
onError?.(tableError);
return tableError;
};
};
+101
View File
@@ -0,0 +1,101 @@
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');
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* 主题动画工具模块
*
* 提供主题切换的视觉动画效果
*
* ## 主要功能
*
* - 基于鼠标点击位置的圆形扩散动画
* - View Transition API 支持(现代浏览器)
* - 降级处理(不支持动画的浏览器)
* - 暗黑主题切换过渡效果
* - 页面刷新时的主题过渡优化
*
* ## 使用场景
*
* - 明暗主题切换
* - 提升用户体验的视觉反馈
* - 页面刷新时的平滑过渡
*
* ## 技术实现
*
* - 使用 CSS 变量存储点击位置和半径
* - 利用 View Transition API 实现流畅动画
* - 通过 CSS class 控制过渡效果
* - 自动计算最大扩散半径
*
* @module utils/theme/animation
* @author Fastapi Admin Team
*/
import { useCommon } from '@/hooks/core/useCommon';
import { useTheme } from '@/hooks/core/useTheme';
import { SystemThemeEnum } from '@/enums/appEnum';
import { useSettingStore } from '@/store/modules/setting';
const { LIGHT, DARK } = SystemThemeEnum;
/**
* 主题切换动画
* @param e 鼠标点击事件
*/
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));
// 设置CSS变量
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(useSettingStore().systemThemeType === LIGHT ? DARK : LIGHT);
useCommon().refresh();
};
/**
* 切换主题过渡效果
* @param enable 是否启用过渡效果
*/
export const toggleTransition = (enable: boolean) => {
const body = document.body;
if (enable) {
body.classList.add('theme-change');
} else {
setTimeout(() => {
body.classList.remove('theme-change');
}, 300);
}
};
+273
View File
@@ -0,0 +1,273 @@
/**
* 颜色处理工具模块
*
* 提供完整的颜色格式转换和处理功能
*
* ## 主要功能
*
* - Hex 与 RGB/RGBA 格式互转
* - 颜色混合计算
* - 颜色变浅/变深处理
* - Element Plus 主题色自动生成
* - 颜色格式验证
* - CSS 变量读取
* - 暗黑模式颜色适配
*
* ## 使用场景
*
* - 主题色动态切换
* - Element Plus 组件主题定制
* - 颜色渐变生成
* - 明暗主题颜色计算
* - 颜色格式标准化
*
* ## 核心功能
*
* - hexToRgba: Hex 转 RGBA(支持透明度)
* - hexToRgb: Hex 转 RGB 数组
* - rgbToHex: RGB 转 Hex
* - colourBlend: 两种颜色混合
* - getLightColor: 生成变浅的颜色
* - getDarkColor: 生成变深的颜色
* - handleElementThemeColor: 处理 Element Plus 主题色
* - setElementThemeColor: 设置完整的主题色系统
*
* ## 支持格式
*
* - Hex: #FFF, #FFFFFF
* - RGB: rgb(255, 255, 255)
* - RGBA: rgba(255, 255, 255, 0.5)
*
* @module utils/ui/colors
* @author Fastapi Admin Team
*/
import { useSettingStore } from '@/store/modules/setting';
/**
* 颜色转换结果接口
*/
interface RgbaResult {
red: number;
green: number;
blue: number;
rgba: string;
}
/**
* 获取CSS变量值(别名函数)
* @param name CSS变量名
* @returns CSS变量值
*/
export function getCssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name);
}
/**
* 验证hex颜色格式
* @param hex hex颜色值
* @returns 是否为有效的hex颜色
*/
function isValidHexColor(hex: string): boolean {
const cleanHex = hex.trim().replace(/^#/, '');
return /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(cleanHex);
}
/**
* 验证RGB颜色值
* @param r 红色值
* @param g 绿色值
* @param b 蓝色值
* @returns 是否为有效的RGB值
*/
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);
}
/**
* 将hex颜色转换为RGBA
* @param hex hex颜色值 (支持 #FFF 或 #FFFFFF 格式)
* @param opacity 透明度 (0-1)
* @returns 包含RGB值和RGBA字符串的对象
*/
export function hexToRgba(hex: string, opacity: number): RgbaResult {
if (!isValidHexColor(hex)) {
throw new Error('Invalid hex color format');
}
// 移除可能存在的 # 前缀并转换为大写
let cleanHex = hex.trim().replace(/^#/, '').toUpperCase();
// 如果是缩写形式(如 FFF),转换为完整形式
if (cleanHex.length === 3) {
cleanHex = cleanHex
.split('')
.map((char) => char.repeat(2))
.join('');
}
// 解析 RGB 值
const [red, green, blue] = cleanHex.match(/\w\w/g)!.map((x) => parseInt(x, 16));
// 确保 opacity 在有效范围内
const validOpacity = Math.max(0, Math.min(1, opacity));
// 构建 RGBA 字符串
const rgba = `rgba(${red}, ${green}, ${blue}, ${validOpacity.toFixed(2)})`;
return { red, green, blue, rgba };
}
/**
* 将hex颜色转换为RGB数组
* @param hexColor hex颜色值
* @returns RGB数组 [r, g, b]
*/
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));
}
/**
* 将RGB颜色转换为hex
* @param r 红色值 (0-255)
* @param g 绿色值 (0-255)
* @param b 蓝色值 (0-255)
* @returns hex颜色值
*/
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)}`;
}
/**
* 颜色混合
* @param color1 第一个颜色
* @param color2 第二个颜色
* @param ratio 混合比例 (0-1)
* @returns 混合后的颜色
*/
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]);
}
/**
* 获取变浅的颜色
* @param color 原始颜色
* @param level 变浅程度 (0-1)
* @param isDark 是否为暗色主题
* @returns 变浅后的颜色
*/
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]);
}
/**
* 获取变深的颜色
* @param color 原始颜色
* @param level 变深程度 (0-1)
* @returns 变深后的颜色
*/
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]);
}
/**
* 处理 Element Plus 主题颜色
* @param theme 主题颜色
* @param isDark 是否为暗色主题
*/
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)
);
}
}
/**
* 设置 Element Plus 主题颜色
* @param color 主题颜色
*/
export function setElementThemeColor(color: string): void {
const mixColor = '#ffffff';
const elStyle = document.documentElement.style;
elStyle.setProperty('--el-color-primary', color);
handleElementThemeColor(color, useSettingStore().isDark);
// 生成更淡一点的颜色
for (let i = 1; i < 16; i++) {
const itemColor = colourBlend(color, mixColor, i / 16);
elStyle.setProperty(`--el-color-primary-custom-${i}`, itemColor);
}
}
+24
View File
@@ -0,0 +1,24 @@
/**
* 表情
* 用于在消息提示的时候显示对应的表情
*
* 用法
* ElMessage.success(`${EmojiText[200]} 图片上传成功`)
* ElMessage.error(`${EmojiText[400]} 图片上传失败`)
* ElMessage.error(`${EmojiText[500]} 图片上传失败`)
*
* @module utils/ui/emojo
* @author Fastapi Admin Team
*/
// macos 用户 按 shift + 6 可以唤出更多表情……
const EmojiText: { [key: string]: string } = {
'0': 'O_O', // 空
'200': '^_^', // 成功
'400': 'T_T', // 错误请求
'500': 'X_X', // 服务器内部错误,无法完成请求
};
// const EmojiIcon = ['🟢', '🔴', '🟡 ', '🚀', '✨', '💡', '🛠️', '🔥', '🎉', '🌟', '🌈']
export default EmojiText;
@@ -0,0 +1,31 @@
/**
* 离线图标加载器
*
* 用于在内网环境下支持 Iconify 图标的离线加载。
* 通过预加载图标集数据,避免运行时从 CDN 获取图标。
*
* 使用方式:
* 1. 安装所需图标集:pnpm add -D @iconify-json/[icon-set-name]
* 2. 在此文件中导入并注册图标集
* 3. 在组件中使用:<ArtSvgIcon icon="ri:home-line" />
*
* @module utils/ui/iconify-loader
* @author Fastapi Admin Team
*/
// import { addCollection } from '@iconify/vue'
// // 导入离线图标数据
// // 系统必要图标库
// import riIcons from '@iconify-json/ri/icons.json'
// // 演示图标库(可选,生产环境可移除)
// import svgSpinners from '@iconify-json/svg-spinners/icons.json'
// import lineMd from '@iconify-json/line-md/icons.json'
// // 注册离线图标集
// addCollection(riIcons)
// addCollection(svgSpinners)
// addCollection(lineMd)
+11
View File
@@ -0,0 +1,11 @@
/**
* UI 相关工具函数统一导出
*
* @module utils/ui/index
* @author Fastapi Admin Team
*/
export * from './colors';
export * from './loading';
export * from './tabs';
export * from './emojo';
+85
View File
@@ -0,0 +1,85 @@
/**
* 全局 Loading 加载管理模块
*
* 提供统一的全屏加载动画管理
*
* ## 主要功能
*
* - 全屏 Loading 显示和隐藏
* - 自动适配明暗主题背景色
* - 自定义 SVG 加载动画
* - 单例模式防止重复创建
* - 锁定页面交互
*
* ## 使用场景
*
* - 页面初始化加载
* - 大量数据请求
* - 路由切换过渡
* - 异步操作等待
*
* ## 特性
*
* - 自动检测当前主题并应用对应背景色
* - 使用自定义 SVG 动画(四点旋转)
* - 单例模式确保同时只有一个 Loading
* - 提供便捷的显示/隐藏方法
*
* @module utils/ui/loading
* @author Fastapi Admin Team
*/
import { ElLoading } from 'element-plus';
import { fourDotsSpinnerSvg } from '@/assets/svg/loading';
/**
* 获取当前主题对应的loading背景色
* @returns 背景色字符串
*/
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 = {
/**
* 显示 loading
* @returns 关闭 loading 的函数
*/
showLoading(): () => void {
if (!loadingInstance) {
// 每次显示时获取最新的配置,确保背景色与当前主题同步
const config = {
...DEFAULT_LOADING_CONFIG,
background: getLoadingBackground(),
};
loadingInstance = ElLoading.service(config);
}
return () => this.hideLoading();
},
/**
* 隐藏 loading
*/
hideLoading(): void {
if (loadingInstance) {
loadingInstance.close();
loadingInstance = null;
}
},
};
+60
View File
@@ -0,0 +1,60 @@
/**
* 标签页布局配置模块
*
* 提供不同标签页样式的高度和间距配置
*
* ## 主要功能
*
* - 多种标签页样式配置(默认、卡片、谷歌风格)
* - 标签页打开/关闭状态的高度管理
* - 顶部间距自动计算
* - 配置获取和默认值处理
*
* ## 使用场景
*
* - 工作标签页(Worktab)布局计算
* - 页面内容区域高度调整
* - 标签页显示/隐藏时的动画
* - 响应式布局适配
*
* ## 配置项说明
*
* - openTop: 标签页显示时,内容区域距离顶部的距离
* - closeTop: 标签页隐藏时,内容区域距离顶部的距离
* - openHeight: 标签页显示时的总高度(包含标签栏)
* - closeHeight: 标签页隐藏时的总高度(仅头部)
*
* ## 支持的样式
*
* - tab-default: 默认标签页样式
* - tab-card: 卡片式标签页
* - tab-google: 谷歌浏览器风格标签页
*
* @module utils/ui/tabs
* @author Fastapi Admin Team
*/
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,
},
};
// 获取当前 tab 样式配置,设置默认值
export const getTabConfig = (style: string) => {
return TAB_CONFIG[style as keyof typeof TAB_CONFIG] || TAB_CONFIG['tab-card']; // 默认使用 tab-card 配置
};
+109
View File
@@ -0,0 +1,109 @@
/**
* 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();
});
}