初始化本地仓库
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 常量定义相关工具函数统一导出
|
||||
*
|
||||
* @module utils/constants/index
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
export * from './links'
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 网站链接常量配置
|
||||
* 集中管理便于维护和更新链接地址
|
||||
*
|
||||
* @module utils/constants/links
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
export const WEB_LINKS = {
|
||||
// Github 主页
|
||||
GITHUB_HOME: 'https://gitee.com/liu_guan_qing',
|
||||
|
||||
// 项目 Github 主页
|
||||
GITHUB: 'https://gitee.com/motion-code/madong-vue',
|
||||
|
||||
// 个人博客
|
||||
BLOG: 'https://madong.tech/',
|
||||
|
||||
// 项目文档
|
||||
DOCS: 'https://madong.tech/docs/framework',
|
||||
|
||||
|
||||
// 项目社区
|
||||
COMMUNITY: 'https://madong.tech/',
|
||||
|
||||
// 个人 Bilibili 主页
|
||||
BILIBILI: '',
|
||||
|
||||
// 项目介绍
|
||||
INTRODUCE: 'https://madong.tech/docs/framework/297061518217330688'
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 表单工具函数统一导出
|
||||
*
|
||||
* @module utils/form
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
// 表单验证器
|
||||
export * from './validator'
|
||||
|
||||
// 响应式布局
|
||||
export * from './responsive'
|
||||
@@ -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 Art Design Pro 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)
|
||||
}
|
||||
}
|
||||
@@ -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 Art Design Pro 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
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
export interface PublicKeyResponse {
|
||||
enabled: boolean
|
||||
scope: string
|
||||
algorithm: string
|
||||
key_version: string
|
||||
public_key: string
|
||||
encrypted_paths?: string[]
|
||||
}
|
||||
|
||||
export interface EncryptSession {
|
||||
aesKey: Uint8Array
|
||||
iv: Uint8Array
|
||||
keyVersion: string
|
||||
encryptedKey: string
|
||||
encryptedIv: string
|
||||
}
|
||||
|
||||
interface EncryptRequestResult {
|
||||
body: string
|
||||
headers: Record<string, string>
|
||||
session: EncryptSession | null
|
||||
}
|
||||
|
||||
const PUBLIC_KEY_PATH = '/system/security/api-encrypt/public-key'
|
||||
let publicKeyCache: PublicKeyResponse | null = null
|
||||
let publicKeyFailureAt = 0
|
||||
const PUBLIC_KEY_RETRY_INTERVAL = 30000
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
const textDecoder = new TextDecoder()
|
||||
|
||||
function normalizeBaseUrl(baseURL: string): string {
|
||||
return baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
function resolveRequestPath(baseURL: string, path: string): string {
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseURL)
|
||||
const normalizedPath = normalizePath(path)
|
||||
|
||||
if (/^https?:\/\//i.test(normalizedPath)) {
|
||||
const url = new URL(normalizedPath)
|
||||
return url.pathname
|
||||
}
|
||||
|
||||
if (normalizedPath.startsWith('/api/') || normalizedPath.startsWith('/adminapi/')) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`${normalizedBaseUrl}${normalizedPath}`, window.location.origin)
|
||||
return url.pathname
|
||||
} catch {
|
||||
return normalizedPath
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipEncrypt(path: string): boolean {
|
||||
return path === '/api/auth/refresh'
|
||||
|| path === '/adminapi/auth/refresh'
|
||||
|| path === '/api/system/security/api-encrypt/public-key'
|
||||
|| path === '/adminapi/system/security/api-encrypt/public-key'
|
||||
}
|
||||
|
||||
function matchesEncryptedPath(path: string, encryptedPaths: string[] = []): boolean {
|
||||
const normalizedPath = normalizePath(path)
|
||||
return encryptedPaths.some((encryptedPath) => {
|
||||
const normalizedEncryptedPath = normalizePath(encryptedPath)
|
||||
return normalizedEncryptedPath === normalizedPath || normalizedPath.startsWith(`${normalizedEncryptedPath}/`)
|
||||
})
|
||||
}
|
||||
|
||||
function toBase64(buffer: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte)
|
||||
})
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
function fromPem(pem: string): ArrayBuffer {
|
||||
const clean = pem.replace(/-----BEGIN PUBLIC KEY-----/g, '')
|
||||
.replace(/-----END PUBLIC KEY-----/g, '')
|
||||
.replace(/\s+/g, '')
|
||||
const binary = atob(clean)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes.buffer
|
||||
}
|
||||
|
||||
async function importPublicKey(publicKeyPem: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'spki',
|
||||
fromPem(publicKeyPem),
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
true,
|
||||
['encrypt']
|
||||
)
|
||||
}
|
||||
|
||||
async function rsaEncrypt(publicKeyPem: string, data: Uint8Array): Promise<string> {
|
||||
const publicKey = await importPublicKey(publicKeyPem)
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, publicKey, data)
|
||||
return toBase64(encrypted)
|
||||
}
|
||||
|
||||
async function aesEncrypt(plainText: string, aesKey: Uint8Array, iv: Uint8Array): Promise<string> {
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(aesKey),
|
||||
{ name: 'AES-CBC' },
|
||||
false,
|
||||
['encrypt']
|
||||
)
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, textEncoder.encode(plainText))
|
||||
return toBase64(encrypted)
|
||||
}
|
||||
|
||||
async function aesDecrypt(cipherText: string, aesKey: Uint8Array, iv: Uint8Array): Promise<string> {
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
toArrayBuffer(aesKey),
|
||||
{ name: 'AES-CBC' },
|
||||
false,
|
||||
['decrypt']
|
||||
)
|
||||
const binary = atob(cipherText)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: toArrayBuffer(iv) }, cryptoKey, toArrayBuffer(bytes))
|
||||
return textDecoder.decode(decrypted)
|
||||
}
|
||||
|
||||
async function fetchPublicKey(baseURL: string): Promise<PublicKeyResponse | null> {
|
||||
const requestUrl = `${normalizeBaseUrl(baseURL)}${PUBLIC_KEY_PATH}`
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
if (!result || result.code !== 0 || !result.data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.data as PublicKeyResponse
|
||||
}
|
||||
|
||||
async function ensurePublicKey(baseURL: string): Promise<PublicKeyResponse | null> {
|
||||
if (publicKeyCache) {
|
||||
return publicKeyCache
|
||||
}
|
||||
|
||||
if (publicKeyFailureAt > 0 && Date.now() - publicKeyFailureAt < PUBLIC_KEY_RETRY_INTERVAL) {
|
||||
return null
|
||||
}
|
||||
|
||||
let data: PublicKeyResponse | null = null
|
||||
try {
|
||||
data = await fetchPublicKey(baseURL)
|
||||
} catch {
|
||||
publicKeyFailureAt = Date.now()
|
||||
return null
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
publicKeyFailureAt = Date.now()
|
||||
return null
|
||||
}
|
||||
|
||||
publicKeyFailureAt = 0
|
||||
publicKeyCache = data
|
||||
return publicKeyCache
|
||||
}
|
||||
|
||||
export function clearApiEncryptPublicKeyCache() {
|
||||
publicKeyCache = null
|
||||
publicKeyFailureAt = 0
|
||||
}
|
||||
|
||||
export async function prepareEncryptedRequest(baseURL: string, path: string, method: string, body?: unknown): Promise<EncryptRequestResult> {
|
||||
const resolvedPath = resolveRequestPath(baseURL, path)
|
||||
|
||||
if (!resolvedPath.startsWith('/api/') && !resolvedPath.startsWith('/adminapi/')) {
|
||||
return {
|
||||
body: body ? JSON.stringify(body) : '',
|
||||
headers: {},
|
||||
session: null
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSkipEncrypt(resolvedPath)) {
|
||||
return {
|
||||
body: body ? JSON.stringify(body) : '',
|
||||
headers: {},
|
||||
session: null
|
||||
}
|
||||
}
|
||||
|
||||
const publicKeyData = await ensurePublicKey(baseURL)
|
||||
if (!publicKeyData || !publicKeyData.enabled || publicKeyData.scope === 'off') {
|
||||
return {
|
||||
body: body ? JSON.stringify(body) : '',
|
||||
headers: {},
|
||||
session: null
|
||||
}
|
||||
}
|
||||
|
||||
if (publicKeyData.scope === 'partial' && !matchesEncryptedPath(resolvedPath, publicKeyData.encrypted_paths || [])) {
|
||||
return {
|
||||
body: body ? JSON.stringify(body) : '',
|
||||
headers: {},
|
||||
session: null
|
||||
}
|
||||
}
|
||||
|
||||
const aesKey = crypto.getRandomValues(new Uint8Array(32))
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16))
|
||||
const encryptedKey = await rsaEncrypt(publicKeyData.public_key, aesKey)
|
||||
const encryptedIv = await rsaEncrypt(publicKeyData.public_key, iv)
|
||||
|
||||
let encryptedBody = ''
|
||||
if (body !== undefined && !['GET', 'HEAD'].includes(method.toUpperCase())) {
|
||||
const payload = JSON.stringify(body)
|
||||
encryptedBody = await aesEncrypt(payload, aesKey, iv)
|
||||
}
|
||||
|
||||
return {
|
||||
body: encryptedBody === '' ? '' : JSON.stringify({
|
||||
encrypted: true,
|
||||
algorithm: publicKeyData.algorithm,
|
||||
key_version: publicKeyData.key_version,
|
||||
data: encryptedBody,
|
||||
method
|
||||
}),
|
||||
headers: {
|
||||
'X-Api-Encrypt': '1',
|
||||
'X-Api-Encrypt-Version': publicKeyData.key_version,
|
||||
'X-Api-Encrypt-Key': encryptedKey,
|
||||
'X-Api-Encrypt-Iv': encryptedIv
|
||||
},
|
||||
session: {
|
||||
aesKey,
|
||||
iv,
|
||||
keyVersion: publicKeyData.key_version,
|
||||
encryptedKey,
|
||||
encryptedIv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptEncryptedResponse(data: any, session: EncryptSession | null): Promise<any> {
|
||||
if (!data || data.encrypted !== true || !session) {
|
||||
return data
|
||||
}
|
||||
|
||||
const plainText = await aesDecrypt(String(data.data || ''), session.aesKey, session.iv)
|
||||
return JSON.parse(plainText)
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* HTTP 错误处理模块
|
||||
*
|
||||
* 提供统一的 HTTP 请求错误处理机制
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 自定义 HttpError 错误类,封装错误信息、状态码、时间戳等
|
||||
* - 错误拦截和转换,将 Axios 错误转换为标准的 HttpError
|
||||
* - 错误消息国际化处理,根据状态码返回对应的多语言错误提示
|
||||
* - 错误日志记录,便于问题追踪和调试
|
||||
* - 错误和成功消息的统一展示
|
||||
* - 类型守卫函数,用于判断错误类型
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - HTTP 请求拦截器中统一处理错误
|
||||
* - 业务代码中捕获和处理特定错误
|
||||
* - 错误日志收集和上报
|
||||
*
|
||||
* @module utils/http/error
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
import { AxiosError } from 'axios'
|
||||
import { ApiStatus } 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
|
||||
}
|
||||
|
||||
// 自定义 HttpError 类
|
||||
export class HttpError extends Error {
|
||||
public readonly code: number
|
||||
public readonly data?: unknown
|
||||
public readonly timestamp: string
|
||||
public readonly url?: string
|
||||
public readonly method?: string
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: number,
|
||||
options?: {
|
||||
data?: unknown
|
||||
url?: string
|
||||
method?: string
|
||||
}
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'HttpError'
|
||||
this.code = code
|
||||
this.data = options?.data
|
||||
this.timestamp = new Date().toISOString()
|
||||
this.url = options?.url
|
||||
this.method = options?.method
|
||||
}
|
||||
|
||||
public toLogData(): ErrorLogData {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
data: this.data,
|
||||
timestamp: this.timestamp,
|
||||
url: this.url,
|
||||
method: this.method,
|
||||
stack: this.stack
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误消息
|
||||
* @param status 错误状态码
|
||||
* @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')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理错误
|
||||
* @param error 错误对象
|
||||
* @returns 错误对象
|
||||
*/
|
||||
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) {
|
||||
throw new HttpError($t('httpMsg.networkError'), 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 错误对象
|
||||
* @param showMessage 是否显示错误消息
|
||||
*/
|
||||
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 是否显示消息
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* HTTP 请求封装模块
|
||||
* 基于 Axios 封装的 HTTP 请求工具,提供统一的请求/响应处理
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 请求/响应拦截器(自动添加 Token、统一错误处理)
|
||||
* - 401 未授权自动登出(带防抖机制)
|
||||
* - 请求失败自动重试(可配置)
|
||||
* - 统一的成功/错误消息提示
|
||||
* - 支持 GET/POST/PUT/DELETE 等常用方法
|
||||
*
|
||||
* @module utils/http
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
import axios, { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { ApiStatus } from './status'
|
||||
import { HttpError, handleError, showError, showSuccess } from './error'
|
||||
import { $t } from '@/locales'
|
||||
import { BaseResponse } from '@/types'
|
||||
import { AuthService } from '@/api/system/auth/auth'
|
||||
import { decryptEncryptedResponse, EncryptSession, prepareEncryptedRequest } from './api-encrypt'
|
||||
|
||||
|
||||
/** 请求配置常量 */
|
||||
const REQUEST_TIMEOUT = 15000
|
||||
const LOGOUT_DELAY = 500
|
||||
const MAX_RETRIES = 0
|
||||
const RETRY_DELAY = 1000
|
||||
|
||||
/** Token刷新配置(从环境变量读取,可通过 setTokenRefreshEnabled 动态修改) */
|
||||
let tokenRefreshEnabled = true // 初始化为 true,后面会被环境变量覆盖
|
||||
const REFRESH_TIMEOUT = 10000 // 刷新超时 10 秒
|
||||
|
||||
/** Token刷新相关状态 */
|
||||
let isRefreshing = false
|
||||
let refreshSubscribers: ((token: string) => void)[] = []
|
||||
let refreshFailed = false
|
||||
let refreshStartTime = 0
|
||||
|
||||
/**
|
||||
* 设置 Token 刷新开关
|
||||
* @param enabled 是否启用 token 刷新
|
||||
*/
|
||||
export function setTokenRefreshEnabled(enabled: boolean) {
|
||||
tokenRefreshEnabled = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Token 刷新开关状态
|
||||
*/
|
||||
export function isTokenRefreshEnabled() {
|
||||
return tokenRefreshEnabled
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置 Token 刷新状态(登录成功后调用)
|
||||
*/
|
||||
export function resetTokenRefreshState() {
|
||||
refreshFailed = false
|
||||
resetRefreshState()
|
||||
}
|
||||
|
||||
/** 扩展 AxiosRequestConfig */
|
||||
interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
|
||||
showErrorMessage?: boolean
|
||||
showSuccessMessage?: boolean
|
||||
_encryptSession?: EncryptSession | null
|
||||
}
|
||||
|
||||
const { VITE_API_URL, VITE_WITH_CREDENTIALS, VITE_TOKEN_REFRESH } = import.meta.env
|
||||
|
||||
// 初始化 Token 刷新配置
|
||||
tokenRefreshEnabled = VITE_TOKEN_REFRESH !== 'false'
|
||||
|
||||
/** Axios实例 */
|
||||
const axiosInstance = axios.create({
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
baseURL: VITE_API_URL,
|
||||
withCredentials: VITE_WITH_CREDENTIALS === 'true',
|
||||
validateStatus: (status) => status >= 200 && status < 300,
|
||||
transformResponse: [
|
||||
(data, headers) => {
|
||||
const contentType = headers['content-type']
|
||||
if (contentType?.includes('application/json')) {
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
/** 请求拦截器 */
|
||||
axiosInstance.interceptors.request.use(
|
||||
async (request: InternalAxiosRequestConfig) => {
|
||||
const { accessToken } = useUserStore()
|
||||
if (accessToken) request.headers.set('Authorization', accessToken)
|
||||
|
||||
const requestUrl = String(request.url || '')
|
||||
const requestMethod = String(request.method || 'GET').toUpperCase()
|
||||
const isFormData = request.data instanceof FormData
|
||||
const isBlobResponse = request.responseType === 'blob'
|
||||
|
||||
if (!isFormData && !isBlobResponse) {
|
||||
if (!request.headers['Content-Type']) {
|
||||
request.headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
|
||||
const requestData = typeof request.data === 'string' ? undefined : request.data
|
||||
|
||||
const encryptedRequest = await prepareEncryptedRequest(
|
||||
String(request.baseURL || VITE_API_URL || ''),
|
||||
requestUrl,
|
||||
requestMethod,
|
||||
requestData
|
||||
)
|
||||
|
||||
Object.entries(encryptedRequest.headers).forEach(([key, value]) => {
|
||||
request.headers.set(key, value)
|
||||
})
|
||||
|
||||
;(request as ExtendedAxiosRequestConfig)._encryptSession = encryptedRequest.session
|
||||
|
||||
if (encryptedRequest.body) {
|
||||
request.data = encryptedRequest.body
|
||||
} else if (requestData && typeof request.data !== 'string') {
|
||||
request.data = JSON.stringify(request.data)
|
||||
}
|
||||
} else if (request.data && !isFormData && !request.headers['Content-Type'] && typeof request.data !== 'string') {
|
||||
request.headers.set('Content-Type', 'application/json')
|
||||
request.data = JSON.stringify(request.data)
|
||||
}
|
||||
|
||||
return request
|
||||
},
|
||||
(error) => {
|
||||
showError(createHttpError($t('httpMsg.requestConfigError'), ApiStatus.error))
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
/** 响应拦截器 */
|
||||
axiosInstance.interceptors.response.use(
|
||||
async (response: AxiosResponse<BaseResponse>) => {
|
||||
if (response.config.responseType === 'blob') return response
|
||||
response.data = await decryptEncryptedResponse(
|
||||
response.data,
|
||||
(response.config as ExtendedAxiosRequestConfig)._encryptSession || null
|
||||
)
|
||||
const { code, msg } = response.data
|
||||
if (code === ApiStatus.success) return response
|
||||
// 业务层 401/402 由下方的 error 处理
|
||||
throw createHttpError(msg || $t('httpMsg.requestFailed'), code)
|
||||
},
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
const status = error.response?.status
|
||||
let responseData = error.response?.data
|
||||
|
||||
if (error.response?.config?.responseType !== 'blob') {
|
||||
responseData = await decryptEncryptedResponse(
|
||||
responseData,
|
||||
(error.response?.config as ExtendedAxiosRequestConfig)?._encryptSession || null
|
||||
)
|
||||
if (error.response) {
|
||||
error.response.data = responseData
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否是 401/402:HTTP状态码 或 业务code
|
||||
const isAuthError = status === 401 || status === 402 ||
|
||||
responseData?.code === 401 || responseData?.code === 402
|
||||
|
||||
// 处理 401/402 错误
|
||||
if (isAuthError && originalRequest) {
|
||||
const userStore = useUserStore()
|
||||
const hasToken = userStore.hasRefreshToken()
|
||||
|
||||
// 如果未启用 token 刷新,直接退出
|
||||
if (!tokenRefreshEnabled) {
|
||||
logOut()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 如果已有刷新失败标记,直接退出
|
||||
if (refreshFailed) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 正在刷新中,检查是否超时
|
||||
if (isRefreshing) {
|
||||
const elapsed = Date.now() - refreshStartTime
|
||||
if (elapsed > REFRESH_TIMEOUT) {
|
||||
// 刷新超时,重置状态,允许新的刷新
|
||||
resetRefreshState()
|
||||
} else {
|
||||
// 还在刷新中,加入队列等待
|
||||
return new Promise((resolve, reject) => {
|
||||
addRefreshSubscriber((token: string) => {
|
||||
if (token) {
|
||||
originalRequest.headers['Authorization'] = `Bearer ${token}`
|
||||
resolve(axiosInstance(originalRequest))
|
||||
} else {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有 refresh token,尝试刷新
|
||||
if (hasToken) {
|
||||
isRefreshing = true
|
||||
refreshStartTime = Date.now()
|
||||
|
||||
try {
|
||||
const newAccessToken = await refreshAccessToken()
|
||||
|
||||
// 更新请求头并重试
|
||||
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`
|
||||
|
||||
// 通知队列中的请求并重试
|
||||
executeRefreshSubscribers(newAccessToken)
|
||||
|
||||
// 重置刷新状态
|
||||
resetRefreshState()
|
||||
|
||||
return axiosInstance(originalRequest)
|
||||
} catch (e) {
|
||||
// 刷新失败,通知队列
|
||||
executeRefreshSubscribers('')
|
||||
resetRefreshState()
|
||||
refreshFailed = true
|
||||
logOut()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有 refresh token,直接退出
|
||||
logOut()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
return Promise.reject(handleError(error))
|
||||
}
|
||||
)
|
||||
|
||||
/** 统一创建HttpError */
|
||||
function createHttpError(message: string, code: number) {
|
||||
return new HttpError(message, code)
|
||||
}
|
||||
|
||||
/** 添加刷新订阅者 */
|
||||
function addRefreshSubscriber(callback: (token: string) => void) {
|
||||
refreshSubscribers.push(callback)
|
||||
}
|
||||
|
||||
/** 执行刷新订阅者 */
|
||||
function executeRefreshSubscribers(token: string) {
|
||||
refreshSubscribers.forEach(callback => callback(token))
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
/** 重置刷新状态 */
|
||||
function resetRefreshState() {
|
||||
isRefreshing = false
|
||||
refreshSubscribers = []
|
||||
refreshFailed = false
|
||||
}
|
||||
|
||||
/** 刷新token */
|
||||
async function refreshAccessToken(): Promise<string> {
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (!userStore.hasRefreshToken()) {
|
||||
throw new HttpError('没有可用的刷新令牌', ApiStatus.unauthorized)
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用刷新token接口
|
||||
const response = await AuthService.refreshAccessToken(userStore.refreshToken) as AxiosResponse
|
||||
const data = response.data
|
||||
|
||||
// 检查响应结构
|
||||
if (!data || typeof data.code === 'undefined') {
|
||||
throw new HttpError('刷新响应格式错误', ApiStatus.error)
|
||||
}
|
||||
|
||||
if (data.code !== ApiStatus.success) {
|
||||
if (data.code === 402) {
|
||||
throw new HttpError('登录已过期,请重新登录', 402)
|
||||
}
|
||||
throw new HttpError(data.msg || '刷新令牌失败', data.code || ApiStatus.unauthorized)
|
||||
}
|
||||
|
||||
const tokenData = data.data
|
||||
if (!tokenData || !tokenData.access_token) {
|
||||
throw new HttpError('刷新响应数据错误', ApiStatus.error)
|
||||
}
|
||||
|
||||
const newAccessToken = tokenData.access_token
|
||||
const newRefreshToken = tokenData.refresh_token
|
||||
const newExpiresAt = tokenData.expires_at || (Math.floor(Date.now() / 1000) + (tokenData.expires_in || 7200))
|
||||
|
||||
// 更新store中的token
|
||||
userStore.updateAccessToken(newAccessToken)
|
||||
|
||||
// 如果有新的refresh token,也更新
|
||||
if (newRefreshToken) {
|
||||
userStore.updateRefreshToken(newRefreshToken)
|
||||
}
|
||||
|
||||
// 更新过期时间
|
||||
userStore.expiresAt = newExpiresAt
|
||||
|
||||
return newAccessToken
|
||||
} catch (e) {
|
||||
// 如果是 HttpError,直接抛出
|
||||
if (e instanceof HttpError) {
|
||||
throw e
|
||||
}
|
||||
// 其他错误(如网络错误)
|
||||
throw new HttpError('刷新令牌失败,请检查网络', ApiStatus.error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出登录函数 */
|
||||
function logOut() {
|
||||
resetRefreshState()
|
||||
setTimeout(() => {
|
||||
useUserStore().logOut()
|
||||
}, LOGOUT_DELAY)
|
||||
}
|
||||
|
||||
/** 是否需要重试 */
|
||||
function shouldRetry(statusCode: number) {
|
||||
return [
|
||||
ApiStatus.requestTimeout,
|
||||
ApiStatus.internalServerError,
|
||||
ApiStatus.badGateway,
|
||||
ApiStatus.serviceUnavailable,
|
||||
ApiStatus.gatewayTimeout
|
||||
].includes(statusCode)
|
||||
}
|
||||
|
||||
/** 请求重试逻辑 */
|
||||
async function retryRequest<T>(
|
||||
config: ExtendedAxiosRequestConfig,
|
||||
retries: number = MAX_RETRIES
|
||||
): Promise<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
|
||||
}
|
||||
}
|
||||
|
||||
/** 延迟函数 */
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/** 请求函数 */
|
||||
async function request<T = any>(config: ExtendedAxiosRequestConfig): Promise<T> {
|
||||
// POST | PUT 参数自动填充
|
||||
if (
|
||||
['POST', 'PUT'].includes(config.method?.toUpperCase() || '') &&
|
||||
config.params &&
|
||||
!config.data
|
||||
) {
|
||||
config.data = config.params
|
||||
config.params = undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axiosInstance.request<BaseResponse<T>>(config)
|
||||
|
||||
// 显示成功消息
|
||||
if (config.showSuccessMessage && res.data.msg) {
|
||||
showSuccess(res.data.msg)
|
||||
}
|
||||
|
||||
if (config.responseType === 'blob') return res.data as T
|
||||
|
||||
return res.data.data as T
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.code !== ApiStatus.unauthorized) {
|
||||
const showMsg = config.showErrorMessage !== false
|
||||
showError(error, showMsg)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** API方法集合 */
|
||||
const api = {
|
||||
get<T>(config: ExtendedAxiosRequestConfig) {
|
||||
return retryRequest<T>({ ...config, method: 'GET' })
|
||||
},
|
||||
post<T>(config: ExtendedAxiosRequestConfig) {
|
||||
return retryRequest<T>({ ...config, method: 'POST' })
|
||||
},
|
||||
put<T>(config: ExtendedAxiosRequestConfig) {
|
||||
return retryRequest<T>({ ...config, method: 'PUT' })
|
||||
},
|
||||
patch<T>(config: ExtendedAxiosRequestConfig) {
|
||||
return retryRequest<T>({ ...config, method: 'PATCH' })
|
||||
},
|
||||
del<T>(config: ExtendedAxiosRequestConfig) {
|
||||
return retryRequest<T>({ ...config, method: 'DELETE' })
|
||||
},
|
||||
request<T>(config: ExtendedAxiosRequestConfig): Promise<T> {
|
||||
return retryRequest<T>({ ...config })
|
||||
},
|
||||
upload<T = any>(
|
||||
url: string,
|
||||
data: Record<string, any> & { file: Blob | File },
|
||||
config?: ExtendedAxiosRequestConfig
|
||||
): Promise<T> {
|
||||
const formData = new FormData()
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
formData.append(`${key}[${index}]`, item)
|
||||
})
|
||||
} else {
|
||||
formData.append(key, value)
|
||||
}
|
||||
})
|
||||
|
||||
const finalConfig: ExtendedAxiosRequestConfig = {
|
||||
...config,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
...config?.headers
|
||||
}
|
||||
}
|
||||
|
||||
return retryRequest({
|
||||
url: url,
|
||||
data: formData,
|
||||
method: 'POST',
|
||||
...finalConfig
|
||||
})
|
||||
},
|
||||
/**
|
||||
* SSE (Server-Sent Events) 请求方法
|
||||
* @param config 请求配置
|
||||
* @param eventHandlers 事件处理函数
|
||||
* @returns SSE连接对象,包含关闭方法
|
||||
*/
|
||||
sse(
|
||||
config: ExtendedAxiosRequestConfig,
|
||||
eventHandlers: {
|
||||
message?: (data: any) => void;
|
||||
error?: (error: Event) => void;
|
||||
open?: () => void;
|
||||
[eventName: string]: ((data: any) => void) | undefined;
|
||||
}
|
||||
) {
|
||||
const { url, params } = config
|
||||
if (!url) {
|
||||
throw new Error('SSE request requires a URL')
|
||||
}
|
||||
|
||||
// 构建完整URL,包括baseURL
|
||||
let fullUrl = url
|
||||
// 检查是否为绝对路径
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
// 使用VITE_API_URL作为基础URL
|
||||
fullUrl = `${VITE_API_URL}${url.startsWith('/') ? '' : '/'}${url}`
|
||||
}
|
||||
|
||||
// 添加查询参数
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams()
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value))
|
||||
}
|
||||
})
|
||||
const queryString = searchParams.toString()
|
||||
if (queryString) {
|
||||
fullUrl += (fullUrl.includes('?') ? '&' : '?') + queryString
|
||||
}
|
||||
}
|
||||
|
||||
// 添加认证token(注意:EventSource不支持自定义headers,这里使用URL参数传递token作为备选方案)
|
||||
// 如果需要在headers中传递token,可能需要使用Fetch API模拟SSE
|
||||
const { accessToken } = useUserStore()
|
||||
if (accessToken && !fullUrl.includes('token=')) {
|
||||
// 检查accessToken是否已经包含Bearer前缀
|
||||
let bearerToken = accessToken
|
||||
if (!bearerToken.startsWith('Bearer ')) {
|
||||
// 后端期望 Bearer xxx 格式,需要添加 Bearer 前缀
|
||||
bearerToken = `Bearer ${bearerToken}`
|
||||
}
|
||||
fullUrl += (fullUrl.includes('?') ? '&' : '?') + `token=${encodeURIComponent(bearerToken)}`
|
||||
}
|
||||
|
||||
// 创建EventSource
|
||||
const eventSource = new EventSource(fullUrl, {
|
||||
withCredentials: VITE_WITH_CREDENTIALS === 'true'
|
||||
})
|
||||
|
||||
// 注册事件处理器
|
||||
if (eventHandlers.message) {
|
||||
eventSource.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
eventHandlers.message?.(data)
|
||||
} catch (error) {
|
||||
eventHandlers.message?.(event.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 打开事件处理
|
||||
eventSource.addEventListener('open', () => {
|
||||
console.log('SSE连接已打开')
|
||||
// 调用用户提供的打开处理函数
|
||||
if (eventHandlers.open) {
|
||||
eventHandlers.open()
|
||||
}
|
||||
})
|
||||
|
||||
// 注册自定义事件处理器(包括服务器发送的 error 事件)
|
||||
Object.entries(eventHandlers).forEach(([eventName, handler]) => {
|
||||
if (eventName !== 'message' && eventName !== 'connectionError' && eventName !== 'open' && typeof handler === 'function') {
|
||||
eventSource.addEventListener(eventName, (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
handler(data)
|
||||
} catch (error) {
|
||||
handler(event.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 处理 EventSource 连接错误
|
||||
eventSource.addEventListener('error', (event) => {
|
||||
console.log('SSE connection error:', event)
|
||||
console.log('SSE readyState:', eventSource.readyState)
|
||||
|
||||
// 如果 readyState 为 2 (CLOSED),说明连接已关闭
|
||||
if (eventSource.readyState === 2) {
|
||||
// 调用用户提供的连接错误处理函数
|
||||
if (eventHandlers.connectionError) {
|
||||
eventHandlers.connectionError(event)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 返回SSE连接对象
|
||||
return {
|
||||
eventSource,
|
||||
close: () => {
|
||||
console.log('SSE连接手动关闭')
|
||||
eventSource.close()
|
||||
},
|
||||
readyState: () => eventSource.readyState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 接口状态码
|
||||
*/
|
||||
export enum ApiStatus {
|
||||
success = 0, // 成功
|
||||
error = 400, // 错误
|
||||
unauthorized = 401, // 未授权
|
||||
forbidden = 403, // 禁止访问
|
||||
notFound = 404, // 未找到
|
||||
methodNotAllowed = 405, // 方法不允许
|
||||
requestTimeout = 408, // 请求超时
|
||||
internalServerError = 500, // 服务器错误
|
||||
notImplemented = 501, // 未实现
|
||||
badGateway = 502, // 网关错误
|
||||
serviceUnavailable = 503, // 服务不可用
|
||||
gatewayTimeout = 504, // 网关超时
|
||||
httpVersionNotSupported = 505 // HTTP版本不支持
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Utils 工具函数统一导出
|
||||
* 提供向后兼容性和便捷导入
|
||||
*
|
||||
* @module utils/index
|
||||
* @author Art Design Pro 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'
|
||||
|
||||
// URL 相关
|
||||
export * from './url'
|
||||
|
||||
// 工具函数相关
|
||||
export * from './tools'
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import AntDesignIcons from '@iconify/json/json/ant-design.json'
|
||||
import CarbonIcons from '@iconify/json/json/carbon.json'
|
||||
import IcIcons from '@iconify/json/json/ic.json'
|
||||
import LogosIcons from '@iconify/json/json/logos.json'
|
||||
import LucideIcons from '@iconify/json/json/lucide.json'
|
||||
import EP from '@iconify/json/json/ep.json'
|
||||
import Ri from '@iconify/json/json/ri.json'
|
||||
addCollection(AntDesignIcons)
|
||||
addCollection(LucideIcons)
|
||||
addCollection(CarbonIcons)
|
||||
addCollection(IcIcons as any)
|
||||
addCollection(LogosIcons as any)
|
||||
addCollection(EP)
|
||||
addCollection(Ri)
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 路由和导航相关工具函数统一导出
|
||||
*
|
||||
* @module utils/navigation/index
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
export * from './jump'
|
||||
export * from './worktab'
|
||||
export * from './route'
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 导航跳转工具模块
|
||||
*
|
||||
* 提供统一的页面跳转和导航功能
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 外部链接打开(新窗口)
|
||||
* - 菜单项跳转处理(支持内部路由和外部链接)
|
||||
* - iframe 页面跳转支持
|
||||
* - 递归查找并跳转到第一个可见的子菜单
|
||||
* - 智能判断跳转目标类型(外部链接/内部路由)
|
||||
*
|
||||
* @module utils/navigation/jump
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { router } from '@/router'
|
||||
|
||||
// 打开外部链接
|
||||
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 => {
|
||||
for (const child of items) {
|
||||
if (!child.meta.isHide) {
|
||||
return child.children?.length ? findFirstLeafMenu(child.children) : child
|
||||
}
|
||||
}
|
||||
return items[0]
|
||||
}
|
||||
|
||||
const firstChild = findFirstLeafMenu(item.children)
|
||||
|
||||
// 如果第一个子菜单是外部链接则打开新窗口
|
||||
if (firstChild.meta?.link) {
|
||||
return openExternalLink(firstChild.meta.link)
|
||||
}
|
||||
|
||||
// 跳转到子菜单路径
|
||||
router.push(firstChild.path)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 路由工具模块
|
||||
*
|
||||
* 提供路由处理和菜单路径相关的工具函数
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - iframe 路由检测,判断是否为外部嵌入页面
|
||||
* - 菜单项有效性验证,过滤隐藏和无效菜单
|
||||
* - 路径标准化处理,统一路径格式
|
||||
* - 递归查找菜单树中第一个有效路径
|
||||
* - 支持多级嵌套菜单的路径解析
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 系统初始化时获取默认跳转路径
|
||||
* - 菜单权限过滤后获取首个可访问页面
|
||||
* - 路由重定向逻辑处理
|
||||
* - iframe 页面特殊处理
|
||||
*
|
||||
* @module utils/navigation/route
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
import { AppRouteRecord } from '@/types'
|
||||
|
||||
// 检查是否为 iframe 路由
|
||||
export function isIframe(url: string): boolean {
|
||||
return url.startsWith('/outside/iframe/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证菜单项是否有效
|
||||
* @param menuItem 菜单项
|
||||
* @returns 是否为有效菜单项
|
||||
*/
|
||||
const isValidMenuItem = (menuItem: AppRouteRecord): boolean => {
|
||||
return !!(menuItem.path && menuItem.path.trim() && !menuItem.meta?.isHide)
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化路径格式
|
||||
* @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 (!isValidMenuItem(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 Art Design Pro 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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { globSync } from 'glob'
|
||||
import fs from 'fs'
|
||||
|
||||
export function extractDependenciesFromContent(content: string): string[] {
|
||||
const deps: string[] = []
|
||||
const match = content.match(/dependencies:\s*\[([^\]]+)\]/)
|
||||
if (match) {
|
||||
const items = match[1].match(/'([^']+)'|"([^"]+)"/g) || []
|
||||
items.forEach(item => {
|
||||
const dep = item.replace(/['"]/g, '').trim()
|
||||
if (dep) deps.push(dep)
|
||||
})
|
||||
}
|
||||
return deps
|
||||
}
|
||||
|
||||
export function collectPluginDependencies(): string[] {
|
||||
const deps: string[] = []
|
||||
try {
|
||||
const files = globSync('./src/apps/*/index.ts')
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, 'utf-8')
|
||||
deps.push(...extractDependenciesFromContent(content))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PluginDeps] Failed to collect plugin dependencies:', e)
|
||||
}
|
||||
return [...new Set(deps)]
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
|
||||
export const Push = function (options) {
|
||||
this.doNotConnect = 0;
|
||||
options = options || {};
|
||||
options.heartbeat = options.heartbeat || 25000;
|
||||
options.pingTimeout = options.pingTimeout || 10000;
|
||||
this.config = options;
|
||||
this.uid = 0;
|
||||
this.channels = {};
|
||||
this.connection = null;
|
||||
this.pingTimeoutTimer = 0;
|
||||
Push.instances.push(this);
|
||||
this.createConnection();
|
||||
}
|
||||
|
||||
Push.prototype.checkoutPing = function () {
|
||||
var _this = this;
|
||||
setTimeout(function () {
|
||||
if (_this.connection.state === 'connected') {
|
||||
_this.connection.send('{"event":"pusher:ping","data":{}}');
|
||||
if (_this.pingTimeoutTimer) {
|
||||
clearTimeout(_this.pingTimeoutTimer);
|
||||
_this.pingTimeoutTimer = 0;
|
||||
}
|
||||
_this.pingTimeoutTimer = setTimeout(function () {
|
||||
_this.connection.closeAndClean();
|
||||
if (!_this.connection.doNotConnect) {
|
||||
_this.connection.waitReconnect();
|
||||
}
|
||||
}, _this.config.pingTimeout);
|
||||
}
|
||||
}, this.config.heartbeat);
|
||||
};
|
||||
|
||||
Push.prototype.channel = function (name) {
|
||||
return this.channels.find(name);
|
||||
};
|
||||
|
||||
Push.prototype.allChannels = function () {
|
||||
return this.channels.all();
|
||||
};
|
||||
|
||||
Push.prototype.createConnection = function () {
|
||||
if (this.connection) {
|
||||
throw Error('Connection already exist');
|
||||
}
|
||||
var _this = this;
|
||||
var url = this.config.url;
|
||||
function updateSubscribed() {
|
||||
for (var i in _this.channels) {
|
||||
_this.channels[i].subscribed = false;
|
||||
}
|
||||
}
|
||||
|
||||
this.connection = new Connection({
|
||||
url: url,
|
||||
app_key: this.config.app_key,
|
||||
onOpen: function () {
|
||||
_this.connection.state = 'connecting';
|
||||
_this.checkoutPing();
|
||||
},
|
||||
onMessage: function (params) {
|
||||
if (_this.pingTimeoutTimer) {
|
||||
clearTimeout(_this.pingTimeoutTimer);
|
||||
_this.pingTimeoutTimer = 0;
|
||||
}
|
||||
|
||||
params = JSON.parse(params.data);
|
||||
var event = params.event;
|
||||
var channel_name = params.channel;
|
||||
|
||||
if (event === 'pusher:pong') {
|
||||
_this.checkoutPing();
|
||||
return;
|
||||
}
|
||||
if (event === 'pusher:error') {
|
||||
throw Error(params.data.message);
|
||||
}
|
||||
var data = JSON.parse(params.data), channel;
|
||||
if (event === 'pusher_internal:subscription_succeeded') {
|
||||
channel = _this.channels[channel_name];
|
||||
channel.subscribed = true;
|
||||
channel.processQueue();
|
||||
channel.emit('pusher:subscription_succeeded');
|
||||
return;
|
||||
}
|
||||
if (event === 'pusher:connection_established') {
|
||||
_this.connection.socket_id = data.socket_id;
|
||||
_this.connection.state = 'connected';
|
||||
_this.subscribeAll();
|
||||
}
|
||||
if (event.indexOf('pusher_internal') !== -1) {
|
||||
console.log("Event '" + event + "' not implement");
|
||||
return;
|
||||
}
|
||||
channel = _this.channels[channel_name];
|
||||
if (channel) {
|
||||
channel.emit(event, data);
|
||||
}
|
||||
},
|
||||
onClose: function () {
|
||||
updateSubscribed();
|
||||
},
|
||||
onError: function () {
|
||||
updateSubscribed();
|
||||
}
|
||||
});
|
||||
};
|
||||
Push.prototype.disconnect = function () {
|
||||
this.connection.doNotConnect = 1;
|
||||
this.connection.close();
|
||||
};
|
||||
|
||||
Push.prototype.subscribeAll = function () {
|
||||
if (this.connection.state !== 'connected') {
|
||||
return;
|
||||
}
|
||||
for (var channel_name in this.channels) {
|
||||
//this.connection.send(JSON.stringify({event:"pusher:subscribe", data:{channel:channel_name}}));
|
||||
this.channels[channel_name].processSubscribe();
|
||||
}
|
||||
};
|
||||
|
||||
Push.prototype.unsubscribe = function (channel_name) {
|
||||
if (this.channels[channel_name]) {
|
||||
delete this.channels[channel_name];
|
||||
if (this.connection.state === 'connected') {
|
||||
this.connection.send(JSON.stringify({ event: "pusher:unsubscribe", data: { channel: channel_name } }));
|
||||
}
|
||||
}
|
||||
};
|
||||
Push.prototype.unsubscribeAll = function () {
|
||||
var channels = Object.keys(this.channels);
|
||||
if (channels.length) {
|
||||
if (this.connection.state === 'connected') {
|
||||
for (var channel_name in this.channels) {
|
||||
this.unsubscribe(channel_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.channels = {};
|
||||
};
|
||||
Push.prototype.subscribe = function (channel_name) {
|
||||
if (this.channels[channel_name]) {
|
||||
return this.channels[channel_name];
|
||||
}
|
||||
if (channel_name.indexOf('private-') === 0) {
|
||||
return createPrivateChannel(channel_name, this);
|
||||
}
|
||||
if (channel_name.indexOf('presence-') === 0) {
|
||||
return createPresenceChannel(channel_name, this);
|
||||
}
|
||||
return createChannel(channel_name, this);
|
||||
};
|
||||
Push.instances = [];
|
||||
|
||||
function createChannel(channel_name, push) {
|
||||
var channel = new Channel(push.connection, channel_name);
|
||||
push.channels[channel_name] = channel;
|
||||
channel.subscribeCb = function () {
|
||||
push.connection.send(JSON.stringify({ event: "pusher:subscribe", data: { channel: channel_name } }));
|
||||
}
|
||||
channel.processSubscribe();
|
||||
return channel;
|
||||
}
|
||||
|
||||
function createPrivateChannel(channel_name, push) {
|
||||
var channel = new Channel(push.connection, channel_name);
|
||||
push.channels[channel_name] = channel;
|
||||
channel.subscribeCb = function () {
|
||||
__ajax({
|
||||
url: push.config.auth,
|
||||
type: 'POST',
|
||||
data: { channel_name: channel_name, socket_id: push.connection.socket_id },
|
||||
success: function (data) {
|
||||
data = JSON.parse(data);
|
||||
data.channel = channel_name;
|
||||
push.connection.send(JSON.stringify({ event: "pusher:subscribe", data: data }));
|
||||
},
|
||||
error: function (e) {
|
||||
throw Error(e);
|
||||
}
|
||||
});
|
||||
};
|
||||
channel.processSubscribe();
|
||||
return channel;
|
||||
}
|
||||
|
||||
function createPresenceChannel(channel_name, push) {
|
||||
return createPrivateChannel(channel_name, push);
|
||||
}
|
||||
|
||||
function Connection(options) {
|
||||
this.dispatcher = new Dispatcher();
|
||||
__extends(this, this.dispatcher);
|
||||
var properies = ['on', 'off', 'emit'];
|
||||
for (var i in properies) {
|
||||
this[properies[i]] = this.dispatcher[properies[i]];
|
||||
}
|
||||
this.options = options;
|
||||
this.state = 'initialized'; //initialized connecting connected disconnected
|
||||
this.doNotConnect = 0;
|
||||
this.reconnectInterval = 1;
|
||||
this.connection = null;
|
||||
this.reconnectTimer = 0;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
Connection.prototype.updateNetworkState = function (state) {
|
||||
var old_state = this.state;
|
||||
this.state = state;
|
||||
if (old_state !== state) {
|
||||
this.emit('state_change', { previous: old_state, current: state });
|
||||
}
|
||||
};
|
||||
|
||||
Connection.prototype.connect = function () {
|
||||
this.doNotConnect = 0;
|
||||
if (this.state === 'connected') {
|
||||
console.log('networkState is "' + this.state + '" and do not need connect');
|
||||
return;
|
||||
}
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = 0;
|
||||
}
|
||||
|
||||
this.closeAndClean();
|
||||
|
||||
var options = this.options;
|
||||
var websocket = new WebSocket(options.url + '/app/' + options.app_key);
|
||||
|
||||
this.updateNetworkState('connecting');
|
||||
|
||||
var _this = this;
|
||||
websocket.onopen = function (res) {
|
||||
_this.reconnectInterval = 1;
|
||||
if (_this.doNotConnect) {
|
||||
_this.updateNetworkState('disconnected');
|
||||
websocket.close();
|
||||
return;
|
||||
}
|
||||
if (options.onOpen) {
|
||||
options.onOpen(res);
|
||||
}
|
||||
};
|
||||
|
||||
if (options.onMessage) {
|
||||
websocket.onmessage = options.onMessage;
|
||||
}
|
||||
|
||||
websocket.onclose = function (res) {
|
||||
websocket.onmessage = websocket.onopen = websocket.onclose = websocket.onerror = null;
|
||||
_this.updateNetworkState('disconnected');
|
||||
if (!_this.doNotConnect) {
|
||||
_this.waitReconnect();
|
||||
}
|
||||
if (options.onClose) {
|
||||
options.onClose(res);
|
||||
}
|
||||
};
|
||||
|
||||
websocket.onerror = function (res) {
|
||||
_this.close();
|
||||
if (!_this.doNotConnect) {
|
||||
_this.waitReconnect();
|
||||
}
|
||||
if (options.onError) {
|
||||
options.onError(res);
|
||||
}
|
||||
};
|
||||
this.connection = websocket;
|
||||
}
|
||||
|
||||
Connection.prototype.closeAndClean = function () {
|
||||
if (this.connection) {
|
||||
var websocket = this.connection;
|
||||
websocket.onmessage = websocket.onopen = websocket.onclose = websocket.onerror = null;
|
||||
try {
|
||||
websocket.close();
|
||||
} catch (e) { }
|
||||
this.updateNetworkState('disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
Connection.prototype.waitReconnect = function () {
|
||||
if (this.state === 'connected' || this.state === 'connecting') {
|
||||
return;
|
||||
}
|
||||
if (!this.doNotConnect) {
|
||||
this.updateNetworkState('connecting');
|
||||
var _this = this;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
this.reconnectTimer = setTimeout(function () {
|
||||
_this.connect();
|
||||
}, this.reconnectInterval);
|
||||
if (this.reconnectInterval < 1000) {
|
||||
this.reconnectInterval = 1000;
|
||||
} else {
|
||||
// 每次重连间隔增大一倍
|
||||
this.reconnectInterval = this.reconnectInterval * 2;
|
||||
}
|
||||
// 有网络的状态下,重连间隔最大2秒
|
||||
if (this.reconnectInterval > 2000 && navigator.onLine) {
|
||||
_this.reconnectInterval = 2000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Connection.prototype.send = function (data) {
|
||||
if (this.state !== 'connected') {
|
||||
console.trace('networkState is "' + this.state + '", can not send ' + data);
|
||||
return;
|
||||
}
|
||||
this.connection.send(data);
|
||||
}
|
||||
|
||||
Connection.prototype.close = function () {
|
||||
this.updateNetworkState('disconnected');
|
||||
this.connection.close();
|
||||
}
|
||||
|
||||
var __extends = (this && this.__extends) || function (d, b) {
|
||||
for (var p in b) if (b.hasOwnProperty(p)) { d[p] = b[p]; }
|
||||
function __() { this.constructor = d; }
|
||||
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
|
||||
};
|
||||
|
||||
function Channel(connection, channel_name) {
|
||||
this.subscribed = false;
|
||||
this.dispatcher = new Dispatcher();
|
||||
this.connection = connection;
|
||||
this.channelName = channel_name;
|
||||
this.subscribeCb = null;
|
||||
this.queue = [];
|
||||
__extends(this, this.dispatcher);
|
||||
var properies = ['on', 'off', 'emit'];
|
||||
for (var i in properies) {
|
||||
this[properies[i]] = this.dispatcher[properies[i]];
|
||||
}
|
||||
}
|
||||
|
||||
Channel.prototype.processSubscribe = function () {
|
||||
if (this.connection.state !== 'connected') {
|
||||
return;
|
||||
}
|
||||
this.subscribeCb();
|
||||
};
|
||||
|
||||
Channel.prototype.processQueue = function () {
|
||||
if (this.connection.state !== 'connected' || !this.subscribed) {
|
||||
return;
|
||||
}
|
||||
for (var i in this.queue) {
|
||||
this.queue[i]();
|
||||
}
|
||||
this.queue = [];
|
||||
};
|
||||
|
||||
Channel.prototype.trigger = function (event, data) {
|
||||
if (event.indexOf('client-') !== 0) {
|
||||
throw new Error("Event '" + event + "' should start with 'client-'");
|
||||
}
|
||||
var _this = this;
|
||||
this.queue.push(function () {
|
||||
_this.connection.send(JSON.stringify({ event: event, data: data, channel: _this.channelName }));
|
||||
});
|
||||
this.processQueue();
|
||||
};
|
||||
|
||||
////////////////
|
||||
var Collections = (function () {
|
||||
var exports = {};
|
||||
function extend(target) {
|
||||
var sources = [];
|
||||
for (var _i = 1; _i < arguments.length; _i++) {
|
||||
sources[_i - 1] = arguments[_i];
|
||||
}
|
||||
for (var i = 0; i < sources.length; i++) {
|
||||
var extensions = sources[i];
|
||||
for (var property in extensions) {
|
||||
if (extensions[property] && extensions[property].constructor &&
|
||||
extensions[property].constructor === Object) {
|
||||
target[property] = extend(target[property] || {}, extensions[property]);
|
||||
}
|
||||
else {
|
||||
target[property] = extensions[property];
|
||||
}
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
exports.extend = extend;
|
||||
function stringify() {
|
||||
var m = ["Push"];
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
if (typeof arguments[i] === "string") {
|
||||
m.push(arguments[i]);
|
||||
}
|
||||
else {
|
||||
m.push(safeJSONStringify(arguments[i]));
|
||||
}
|
||||
}
|
||||
return m.join(" : ");
|
||||
}
|
||||
|
||||
exports.stringify = stringify;
|
||||
function arrayIndexOf(array, item) {
|
||||
var nativeIndexOf = Array.prototype.indexOf;
|
||||
if (array === null) {
|
||||
return -1;
|
||||
}
|
||||
if (nativeIndexOf && array.indexOf === nativeIndexOf) {
|
||||
return array.indexOf(item);
|
||||
}
|
||||
for (var i = 0, l = array.length; i < l; i++) {
|
||||
if (array[i] === item) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
exports.arrayIndexOf = arrayIndexOf;
|
||||
function objectApply(object, f) {
|
||||
for (var key in object) {
|
||||
if (Object.prototype.hasOwnProperty.call(object, key)) {
|
||||
f(object[key], key, object);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.objectApply = objectApply;
|
||||
function keys(object) {
|
||||
var keys = [];
|
||||
objectApply(object, function (_, key) {
|
||||
keys.push(key);
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
exports.keys = keys;
|
||||
function values(object) {
|
||||
var values = [];
|
||||
objectApply(object, function (value) {
|
||||
values.push(value);
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
exports.values = values;
|
||||
function apply(array, f, context) {
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
f.call(context || (window), array[i], i, array);
|
||||
}
|
||||
}
|
||||
|
||||
exports.apply = apply;
|
||||
function map(array, f) {
|
||||
var result = [];
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
result.push(f(array[i], i, array, result));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
exports.map = map;
|
||||
function mapObject(object, f) {
|
||||
var result = {};
|
||||
objectApply(object, function (value, key) {
|
||||
result[key] = f(value);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
exports.mapObject = mapObject;
|
||||
function filter(array, test) {
|
||||
test = test || function (value) {
|
||||
return !!value;
|
||||
};
|
||||
var result = [];
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
if (test(array[i], i, array, result)) {
|
||||
result.push(array[i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
exports.filter = filter;
|
||||
function filterObject(object, test) {
|
||||
var result = {};
|
||||
objectApply(object, function (value, key) {
|
||||
if ((test && test(value, key, object, result)) || Boolean(value)) {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
exports.filterObject = filterObject;
|
||||
function flatten(object) {
|
||||
var result = [];
|
||||
objectApply(object, function (value, key) {
|
||||
result.push([key, value]);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
exports.flatten = flatten;
|
||||
function any(array, test) {
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
if (test(array[i], i, array)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
exports.any = any;
|
||||
function all(array, test) {
|
||||
for (var i = 0; i < array.length; i++) {
|
||||
if (!test(array[i], i, array)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
exports.all = all;
|
||||
function encodeParamsObject(data) {
|
||||
return mapObject(data, function (value) {
|
||||
if (typeof value === "object") {
|
||||
value = safeJSONStringify(value);
|
||||
}
|
||||
return encodeURIComponent(base64_1["default"](value.toString()));
|
||||
});
|
||||
}
|
||||
|
||||
exports.encodeParamsObject = encodeParamsObject;
|
||||
function buildQueryString(data) {
|
||||
var params = filterObject(data, function (value) {
|
||||
return value !== undefined;
|
||||
});
|
||||
return map(flatten(encodeParamsObject(params)), util_1["default"].method("join", "=")).join("&");
|
||||
}
|
||||
|
||||
exports.buildQueryString = buildQueryString;
|
||||
function decycleObject(object) {
|
||||
var objects = [], paths = [];
|
||||
return (function derez(value, path) {
|
||||
var i, name, nu;
|
||||
switch (typeof value) {
|
||||
case 'object':
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
for (i = 0; i < objects.length; i += 1) {
|
||||
if (objects[i] === value) {
|
||||
return { $ref: paths[i] };
|
||||
}
|
||||
}
|
||||
objects.push(value);
|
||||
paths.push(path);
|
||||
if (Object.prototype.toString.apply(value) === '[object Array]') {
|
||||
nu = [];
|
||||
for (i = 0; i < value.length; i += 1) {
|
||||
nu[i] = derez(value[i], path + '[' + i + ']');
|
||||
}
|
||||
}
|
||||
else {
|
||||
nu = {};
|
||||
for (name in value) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, name)) {
|
||||
nu[name] = derez(value[name], path + '[' + JSON.stringify(name) + ']');
|
||||
}
|
||||
}
|
||||
}
|
||||
return nu;
|
||||
case 'number':
|
||||
case 'string':
|
||||
case 'boolean':
|
||||
return value;
|
||||
}
|
||||
}(object, '$'));
|
||||
}
|
||||
|
||||
exports.decycleObject = decycleObject;
|
||||
function safeJSONStringify(source) {
|
||||
try {
|
||||
return JSON.stringify(source);
|
||||
}
|
||||
catch (e) {
|
||||
return JSON.stringify(decycleObject(source));
|
||||
}
|
||||
}
|
||||
|
||||
exports.safeJSONStringify = safeJSONStringify;
|
||||
return exports;
|
||||
})();
|
||||
|
||||
var Dispatcher = (function () {
|
||||
function Dispatcher(failThrough) {
|
||||
this.callbacks = new CallbackRegistry();
|
||||
this.global_callbacks = [];
|
||||
this.failThrough = failThrough;
|
||||
}
|
||||
Dispatcher.prototype.on = function (eventName, callback, context) {
|
||||
this.callbacks.add(eventName, callback, context);
|
||||
return this;
|
||||
};
|
||||
Dispatcher.prototype.on_global = function (callback) {
|
||||
this.global_callbacks.push(callback);
|
||||
return this;
|
||||
};
|
||||
Dispatcher.prototype.off = function (eventName, callback, context) {
|
||||
this.callbacks.remove(eventName, callback, context);
|
||||
return this;
|
||||
};
|
||||
Dispatcher.prototype.emit = function (eventName, data) {
|
||||
var i;
|
||||
for (i = 0; i < this.global_callbacks.length; i++) {
|
||||
this.global_callbacks[i](eventName, data);
|
||||
}
|
||||
var callbacks = this.callbacks.get(eventName);
|
||||
if (callbacks && callbacks.length > 0) {
|
||||
for (i = 0; i < callbacks.length; i++) {
|
||||
callbacks[i].fn.call(callbacks[i].context || (window), data);
|
||||
}
|
||||
}
|
||||
else if (this.failThrough) {
|
||||
this.failThrough(eventName, data);
|
||||
}
|
||||
return this;
|
||||
};
|
||||
return Dispatcher;
|
||||
}());
|
||||
|
||||
var CallbackRegistry = (function () {
|
||||
function CallbackRegistry() {
|
||||
this._callbacks = {};
|
||||
}
|
||||
CallbackRegistry.prototype.get = function (name) {
|
||||
return this._callbacks[prefix(name)];
|
||||
};
|
||||
CallbackRegistry.prototype.add = function (name, callback, context) {
|
||||
var prefixedEventName = prefix(name);
|
||||
this._callbacks[prefixedEventName] = this._callbacks[prefixedEventName] || [];
|
||||
this._callbacks[prefixedEventName].push({
|
||||
fn: callback,
|
||||
context: context
|
||||
});
|
||||
};
|
||||
CallbackRegistry.prototype.remove = function (name, callback, context) {
|
||||
if (!name && !callback && !context) {
|
||||
this._callbacks = {};
|
||||
return;
|
||||
}
|
||||
var names = name ? [prefix(name)] : Collections.keys(this._callbacks);
|
||||
if (callback || context) {
|
||||
this.removeCallback(names, callback, context);
|
||||
}
|
||||
else {
|
||||
this.removeAllCallbacks(names);
|
||||
}
|
||||
};
|
||||
CallbackRegistry.prototype.removeCallback = function (names, callback, context) {
|
||||
Collections.apply(names, function (name) {
|
||||
this._callbacks[name] = Collections.filter(this._callbacks[name] || [], function (oning) {
|
||||
return (callback && callback !== oning.fn) ||
|
||||
(context && context !== oning.context);
|
||||
});
|
||||
if (this._callbacks[name].length === 0) {
|
||||
delete this._callbacks[name];
|
||||
}
|
||||
}, this);
|
||||
};
|
||||
CallbackRegistry.prototype.removeAllCallbacks = function (names) {
|
||||
Collections.apply(names, function (name) {
|
||||
delete this._callbacks[name];
|
||||
}, this);
|
||||
};
|
||||
return CallbackRegistry;
|
||||
}());
|
||||
|
||||
function prefix(name) {
|
||||
return "_" + name;
|
||||
}
|
||||
|
||||
function __ajax(options) {
|
||||
options = options || {};
|
||||
options.type = (options.type || 'GET').toUpperCase();
|
||||
options.dataType = options.dataType || 'json';
|
||||
var params = formatParams(options.data);
|
||||
|
||||
var xhr;
|
||||
if (window.XMLHttpRequest) {
|
||||
xhr = new XMLHttpRequest();
|
||||
} else {
|
||||
xhr = ActiveXObject('Microsoft.XMLHTTP');
|
||||
}
|
||||
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState === 4) {
|
||||
var status = xhr.status;
|
||||
if (status >= 200 && status < 300) {
|
||||
options.success && options.success(xhr.responseText, xhr.responseXML);
|
||||
} else {
|
||||
options.error && options.error(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.type === 'GET') {
|
||||
xhr.open('GET', options.url + '?' + params, true);
|
||||
xhr.send(null);
|
||||
} else if (options.type === 'POST') {
|
||||
xhr.open('POST', options.url, true);
|
||||
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
|
||||
xhr.send(params);
|
||||
}
|
||||
}
|
||||
|
||||
function formatParams(data) {
|
||||
var arr = [];
|
||||
for (var name in data) {
|
||||
arr.push(encodeURIComponent(name) + '=' + encodeURIComponent(data[name]));
|
||||
}
|
||||
return arr.join('&');
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 路由工具函数
|
||||
*
|
||||
* 提供路由相关的工具函数
|
||||
*
|
||||
* @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'
|
||||
import { useSystemConfigStore } from '@/store/modules/site-config'
|
||||
/** 扩展的路由配置类型 */
|
||||
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(() => {
|
||||
const systemConfigStore = useSystemConfigStore()
|
||||
|
||||
document.title = `${formatMenuTitle(String(title))} - ${systemConfigStore.config.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 ''
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
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 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 {
|
||||
// 如果正在连接中,不重复连接
|
||||
if (this.isConnecting) {
|
||||
console.log('正在建立WebSocket连接中...')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果已连接,不重复连接
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
console.warn('WebSocket连接已存在')
|
||||
this.flushMessageQueue() // 确保队列中的消息被发送
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.isConnecting = true
|
||||
this.reconnectAttempts = 0 // 重置重连次数
|
||||
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.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.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.clearAllTimers()
|
||||
|
||||
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 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) {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否超过最大重连次数
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error(`已达到最大重连次数(${this.maxReconnectAttempts}),停止重连`)
|
||||
this.close(true)
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
this.stopReconnect = true
|
||||
this.close(true)
|
||||
|
||||
const delay = this.calculateReconnectDelay()
|
||||
console.log(
|
||||
`将在${delay / 1000}秒后尝试重新连接(第${this.reconnectAttempts}/${this.maxReconnectAttempts}次)`
|
||||
)
|
||||
|
||||
this.clearTimer('reconnectTimer')
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
console.log(`尝试重新连接WebSocket(第${this.reconnectAttempts}次)`)
|
||||
this.init()
|
||||
this.stopReconnect = 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.clearTimer('detectionTimer')
|
||||
this.clearTimer('timeoutTimer')
|
||||
this.clearTimer('reconnectTimer')
|
||||
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.reconnectAttempts > 0 && !this.stopReconnect)
|
||||
return `重连中(${this.reconnectAttempts}/${this.maxReconnectAttempts})`
|
||||
return '已断开'
|
||||
}
|
||||
|
||||
// 销毁实例
|
||||
static destroyInstance(): void {
|
||||
if (WebSocketClient.instance) {
|
||||
WebSocketClient.instance.close()
|
||||
WebSocketClient.instance = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 存储相关工具函数统一导出
|
||||
*/
|
||||
|
||||
export * from './storage'
|
||||
export * from './storage-config'
|
||||
export * from './storage-key-manager'
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 存储配置管理模块
|
||||
*
|
||||
* 提供统一的本地存储配置和工具方法
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 版本化存储键管理,支持多版本数据隔离
|
||||
* - 存储键名生成和解析(带版本前缀)
|
||||
* - 版本号提取和验证
|
||||
* - 存储键匹配的正则表达式生成
|
||||
* - 旧版本存储键兼容处理
|
||||
* - 升级和登出延迟配置
|
||||
* - 主题存储键配置
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - Pinia Store 持久化存储
|
||||
* - 应用版本升级时的数据迁移
|
||||
* - 多版本数据清理
|
||||
* - 存储键的统一管理和规范
|
||||
*
|
||||
* 存储键格式:sys-v{version}-{storeId}
|
||||
* 例如:sys-v1.0.0-user, sys-v1.0.0-setting
|
||||
*
|
||||
* @module utils/storage/storage-config
|
||||
* @author Art Design Pro 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 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 Art Design Pro Team
|
||||
*/
|
||||
import { StorageConfig } from '@/utils/storage'
|
||||
|
||||
/**
|
||||
* 存储键名管理器
|
||||
* 负责处理版本化的存储键名生成和数据迁移
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 存储兼容性管理模块
|
||||
*
|
||||
* 提供完整的本地存储兼容性检查和数据验证功能
|
||||
*
|
||||
* 主要功能
|
||||
*
|
||||
* - 多版本存储数据检测和验证
|
||||
* - 新旧存储格式兼容处理
|
||||
* - 存储数据完整性校验
|
||||
* - 存储异常自动恢复(清理+登出)
|
||||
* - 登录状态验证
|
||||
* - 存储为空检测
|
||||
* - 版本号管理
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 应用启动时检查存储数据有效性
|
||||
* - 路由守卫中验证登录状态
|
||||
* - 版本升级时的数据兼容性检查
|
||||
* - 存储异常时的自动恢复
|
||||
* - 防止因存储数据损坏导致的系统异常
|
||||
*
|
||||
* ## 工作流程
|
||||
*
|
||||
* 1. 优先检查当前版本的存储数据
|
||||
* 2. 检查其他版本的存储数据
|
||||
* 3. 兼容旧格式的存储数据
|
||||
* 4. 验证数据完整性
|
||||
* 5. 异常时提示用户并执行登出
|
||||
*
|
||||
* @module utils/storage/storage
|
||||
* @author Art Design Pro 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)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// ANSI 转义码生成网站 https://patorjk.com/software/taag/#p=display&f=Big&t=ABB%0A
|
||||
const asciiArt = `
|
||||
\x1b[32m欢迎使用 MadongAdmin!
|
||||
\x1b[0m
|
||||
\x1b[36m哇!好用的话别忘了去 Gitee|GitCode 点个 ★Star 呀,你的支持就是我更新的超强动力!祝使用体验满分💯
|
||||
\x1b[0m
|
||||
\x1b[33mGitHub: https://gitee.com/motion-code/madong-admin
|
||||
\x1b[33mGitHub: https://gitcode.com/motion-code/madong-admin
|
||||
\x1b[0m
|
||||
\x1b[31m技术社区(QQ频道): pd52261144,和开发者一起交流~ 群里有小伙伴实时答疑,遇到问题不用慌!
|
||||
\x1b[0m
|
||||
`
|
||||
|
||||
console.log(asciiArt)
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 全局错误处理模块
|
||||
*
|
||||
* 提供统一的错误捕获和处理机制
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - Vue 运行时错误捕获(组件错误、生命周期错误等)
|
||||
* - 全局脚本错误捕获(语法错误、运行时错误等)
|
||||
* - Promise 未捕获错误处理(unhandledrejection)
|
||||
* - 静态资源加载错误监控(图片、脚本、样式等)
|
||||
* - 错误日志记录和上报
|
||||
* - 统一的错误处理入口
|
||||
*
|
||||
* ## 使用场景
|
||||
* - 应用启动时安装全局错误处理器
|
||||
* - 捕获和记录所有类型的错误
|
||||
* - 错误上报到监控平台
|
||||
* - 提升应用稳定性和可维护性
|
||||
* - 问题排查和调试
|
||||
*
|
||||
* ## 错误类型
|
||||
*
|
||||
* - VueError: Vue 组件相关错误
|
||||
* - ScriptError: JavaScript 脚本错误
|
||||
* - PromiseError: Promise 未捕获的 rejection
|
||||
* - ResourceError: 静态资源加载失败
|
||||
*
|
||||
* @module utils/sys/error-handle
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
import type { App } from 'vue'
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 系统管理相关工具函数统一导出
|
||||
*/
|
||||
|
||||
export * from './upgrade'
|
||||
export { default as mittBus } from './mittBus'
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 全局事件总线模块
|
||||
*
|
||||
* 基于 mitt 库实现的类型安全的事件总线
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 跨组件通信(发布/订阅模式)
|
||||
* - 类型安全的事件定义和调用
|
||||
* - 全局事件管理(烟花效果、设置面板、搜索对话框等)
|
||||
* - 解耦组件间的直接依赖
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 跨层级组件通信
|
||||
* - 全局功能触发(设置、搜索、聊天、锁屏等)
|
||||
* - 特效触发(烟花效果)
|
||||
* - 避免 props 层层传递
|
||||
*
|
||||
* ## 用法示例
|
||||
*
|
||||
* ```typescript
|
||||
* // 订阅事件
|
||||
* mittBus.on('openSetting', () => { ... })
|
||||
*
|
||||
* // 发布事件
|
||||
* mittBus.emit('openSetting')
|
||||
*
|
||||
* // 带参数的事件
|
||||
* mittBus.emit('triggerFireworks', 'image-url')
|
||||
* ```
|
||||
*
|
||||
* ## 已定义的事件
|
||||
*
|
||||
* - triggerFireworks: 触发烟花效果(可选图片URL)
|
||||
* - openSetting: 打开设置面板
|
||||
* - openSearchDialog: 打开搜索对话框
|
||||
* - openChat: 打开聊天窗口
|
||||
* - openLockScreen: 打开锁屏
|
||||
* - openTerminalCommand: 打开终端命令对话框-扩展
|
||||
*
|
||||
* @module utils/sys/mittBus
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
import mitt, { type Emitter } from 'mitt'
|
||||
|
||||
// 定义事件类型映射
|
||||
type Events = {
|
||||
// 烟花效果事件 - 可选的图片URL参数
|
||||
triggerFireworks: string | undefined
|
||||
// 打开设置面板事件 - 无参数
|
||||
openSetting: void
|
||||
// 打开搜索对话框事件 - 无参数
|
||||
openSearchDialog: void
|
||||
// 打开聊天窗口事件 - 无参数
|
||||
openChat: void
|
||||
// 打开锁屏事件 - 无参数
|
||||
openLockScreen: void
|
||||
// 打开终端命令对话框事件 - 无参数
|
||||
openTerminalCommand: void
|
||||
}
|
||||
|
||||
// 创建类型安全的事件总线实例
|
||||
const mittBus: Emitter<Events> = mitt<Events>()
|
||||
|
||||
export default mittBus
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 系统版本升级管理模块
|
||||
*
|
||||
* 提供完整的应用版本升级检测和处理功能
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 版本号比较和升级检测
|
||||
* - 首次访问识别和处理
|
||||
* - 旧版本数据自动清理
|
||||
* - 升级日志展示和通知
|
||||
* - 强制重新登录控制(根据升级日志配置)
|
||||
* - 版本号规范化处理
|
||||
* - 旧存储结构迁移和清理
|
||||
* - 升级流程延迟执行(确保应用完全加载)
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 应用启动时自动检测版本升级
|
||||
* - 版本更新后清理旧数据
|
||||
* - 向用户展示版本更新内容
|
||||
* - 重大更新时要求用户重新登录
|
||||
* - 防止旧版本数据污染新版本
|
||||
*
|
||||
* ## 工作流程
|
||||
*
|
||||
* 1. 检查本地存储的版本号
|
||||
* 2. 与当前应用版本对比
|
||||
* 3. 查找并清理旧版本数据
|
||||
* 4. 展示升级通知(包含更新日志)
|
||||
* 5. 根据配置决定是否强制重新登录
|
||||
* 6. 更新本地版本号
|
||||
*
|
||||
* @module utils/sys/upgrade
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
// import { upgradeLogList } from '@/apps/demo/mock/upgrade/changeLog'
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { StorageConfig } from '@/utils/storage/storage-config'
|
||||
|
||||
|
||||
const upgradeLogList = ref([])
|
||||
|
||||
/**
|
||||
* 版本管理器
|
||||
* 负责处理版本比较、升级检测和数据清理
|
||||
*/
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 表格缓存管理模块
|
||||
*
|
||||
* 提供高性能的表格数据缓存机制
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 基于参数的智能缓存键生成(使用 ohash)
|
||||
* - LRU(最近最少使用)缓存淘汰策略
|
||||
* - 缓存过期时间管理
|
||||
* - 缓存大小限制和自动清理
|
||||
* - 基于标签的缓存分组管理
|
||||
* - 多种缓存失效策略(清空所有、清空当前、清空分页等)
|
||||
* - 缓存访问统计和命中率分析
|
||||
* - 缓存大小估算
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 表格数据的分页缓存
|
||||
* - 减少重复的 API 请求
|
||||
* - 提升表格切换和返回的响应速度
|
||||
* - 搜索条件变化时的智能缓存管理
|
||||
* - 数据更新后的缓存失效处理
|
||||
*
|
||||
* ## 缓存策略
|
||||
*
|
||||
* - CLEAR_ALL: 清空所有缓存(适用于全局数据更新)
|
||||
* - CLEAR_CURRENT: 仅清空当前查询条件的缓存(适用于单条数据更新)
|
||||
* - CLEAR_PAGINATION: 清空所有分页缓存但保留不同搜索条件(适用于批量操作)
|
||||
* - KEEP_ALL: 不清除缓存(适用于只读操作)
|
||||
*
|
||||
* @module utils/table/tableCache
|
||||
* @author Art Design Pro 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 Art Design Pro Team
|
||||
*/
|
||||
export const tableConfig = {
|
||||
// 响应数据字段映射配置,系统会从接口返回数据中按顺序查找这些字段
|
||||
// 列表数据
|
||||
recordFields: ['rows', 'list', 'data', 'records', 'items', 'result'],
|
||||
// 总数字段
|
||||
totalFields: ['recordCount', 'total', 'count'],
|
||||
// 当前页码字段
|
||||
currentFields: ['pageNum', 'current', 'page'],
|
||||
// 每页大小
|
||||
sizeFields: ['pageSize', 'size', 'limit'],
|
||||
|
||||
// 请求参数映射配置,前端发送请求时使用的分页参数名
|
||||
// useTable 组合式函数传递分页参数的时候 用 current 跟 size
|
||||
paginationKey: {
|
||||
// 当前页码
|
||||
current: 'page',
|
||||
// 每页大小
|
||||
size: 'limit'
|
||||
}
|
||||
}
|
||||
@@ -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 Art Design Pro 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// 通用工具
|
||||
/**
|
||||
* 获取当前时间与给定时间的相对时间描述
|
||||
* @param dateString yyyy-MM-dd HH:mm:ss 格式的时间字符串
|
||||
* @returns 相对时间字符串,如"3天前"、"2小时前"、"1分钟前"等
|
||||
*/
|
||||
export const getRelativeTime = (dateString: string): string => {
|
||||
const targetDate = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - targetDate.getTime()
|
||||
const absDiffMs = Math.abs(diffMs)
|
||||
|
||||
// 计算各时间单位差值
|
||||
const diffSeconds = Math.floor(absDiffMs / 1000)
|
||||
const diffMinutes = Math.floor(diffSeconds / 60)
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
const diffWeeks = Math.floor(diffDays / 7)
|
||||
const diffMonths = Math.floor(diffDays / 30)
|
||||
|
||||
const suffix = diffMs < 0 ? '后' : '前'
|
||||
|
||||
if (diffMonths > 0) {
|
||||
return `${diffMonths}个月${suffix}`
|
||||
}
|
||||
if (diffWeeks > 0) {
|
||||
return `${diffWeeks}周${suffix}`
|
||||
}
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}天${suffix}`
|
||||
}
|
||||
if (diffHours > 0) {
|
||||
return `${diffHours}小时${suffix}`
|
||||
}
|
||||
if (diffMinutes > 0) {
|
||||
return `${diffMinutes}分钟${suffix}`
|
||||
}
|
||||
return `${diffSeconds}秒${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据指定ID获取树形结构中该节点及其所有子节点的ID
|
||||
* @param data 树形数据数组
|
||||
* @param id 当前节点ID
|
||||
* @param idKey ID字段名,默认为'id'
|
||||
* @param childrenKey 子节点字段名,默认为'children'
|
||||
* @returns 包含当前节点及所有子节点ID的数组
|
||||
*/
|
||||
export const getTreeNodeAndChildrenIds = (
|
||||
data: any[],
|
||||
id: any,
|
||||
idKey = 'id',
|
||||
childrenKey = 'children'
|
||||
): any[] => {
|
||||
const result: any[] = []
|
||||
|
||||
const traverse = (nodes: any[]) => {
|
||||
if (!nodes || !Array.isArray(nodes)) return
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node[idKey] === id) {
|
||||
// 找到目标节点,收集该节点及其所有子节点
|
||||
const collectIds = (n: any) => {
|
||||
result.push(n[idKey])
|
||||
if (n[childrenKey] && Array.isArray(n[childrenKey])) {
|
||||
n[childrenKey].forEach((child: any) => collectIds(child))
|
||||
}
|
||||
}
|
||||
collectIds(node)
|
||||
return // 找到目标节点后可以提前结束
|
||||
}
|
||||
|
||||
// 递归查找子节点
|
||||
if (node[childrenKey] && Array.isArray(node[childrenKey])) {
|
||||
traverse(node[childrenKey])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(data)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取树形数据中的所有叶子节点
|
||||
* @param data 树形数据数组
|
||||
* @param childrenKey 子节点字段名,默认为'children'
|
||||
* @returns 包含所有叶子节点的数组
|
||||
*/
|
||||
export const getTreeLeafNodes = (data: any[], childrenKey = 'children'): any[] => {
|
||||
const result: any[] = []
|
||||
|
||||
const traverse = (nodes: any[]) => {
|
||||
if (!nodes || !Array.isArray(nodes)) return
|
||||
|
||||
for (const node of nodes) {
|
||||
// 如果节点没有子节点或者子节点为空数组,则为叶子节点
|
||||
if (
|
||||
!node[childrenKey] ||
|
||||
!Array.isArray(node[childrenKey]) ||
|
||||
node[childrenKey].length === 0
|
||||
) {
|
||||
result.push(node)
|
||||
} else {
|
||||
// 递归查找子节点
|
||||
traverse(node[childrenKey])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(data)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取树形数据中的所有叶子节点ID
|
||||
* @param data 树形数据数组
|
||||
* @param idKey ID字段名,默认为'id'
|
||||
* @param childrenKey 子节点字段名,默认为'children'
|
||||
* @returns 包含所有叶子节点ID的数组
|
||||
*/
|
||||
export const getTreeLeafNodeIds = (data: any[], idKey = 'id', childrenKey = 'children'): any[] => {
|
||||
const result: any[] = []
|
||||
|
||||
const traverse = (nodes: any[]) => {
|
||||
if (!nodes || !Array.isArray(nodes)) return
|
||||
|
||||
for (const node of nodes) {
|
||||
// 如果节点没有子节点或者子节点为空数组,则为叶子节点
|
||||
if (
|
||||
!node[childrenKey] ||
|
||||
!Array.isArray(node[childrenKey]) ||
|
||||
node[childrenKey].length === 0
|
||||
) {
|
||||
result.push(node[idKey])
|
||||
} else {
|
||||
// 递归查找子节点
|
||||
traverse(node[childrenKey])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(data)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 数据格式化相关工具函数
|
||||
*/
|
||||
|
||||
// 时间戳转时间
|
||||
export function timestampToTime(timestamp: number = Date.now(), isMs: boolean = true): string {
|
||||
const date = new Date(isMs ? timestamp : timestamp * 1000)
|
||||
return date.toISOString().replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
// 数字格式化(千位分隔符)
|
||||
export function commafy(num: number): string {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
|
||||
// 生成随机数
|
||||
export function randomNum(min: number, max?: number): number {
|
||||
if (max === undefined) {
|
||||
max = min
|
||||
min = 0
|
||||
}
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
// 移除HTML标签
|
||||
export function removeHtmlTags(str: string = ''): string {
|
||||
return str.replace(/<[^>]*>/g, '')
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 格式化时间戳
|
||||
* @param dateTime 时间戳
|
||||
* @param fmt 格式化方式,默认:yyyy-mm-dd hh:MM:ss
|
||||
*/
|
||||
export const timestampFormat = (dateTime: string | number | null = null, fmt = 'yyyy-mm-dd hh:MM:ss') => {
|
||||
if (!dateTime) dateTime = Number(new Date())
|
||||
if (dateTime.toString().length === 10) {
|
||||
dateTime = +dateTime * 1000
|
||||
}
|
||||
|
||||
const date = new Date(dateTime)
|
||||
let ret
|
||||
const opt: any = {
|
||||
'y+': date.getFullYear().toString(), // 年
|
||||
'm+': (date.getMonth() + 1).toString(), // 月
|
||||
'd+': date.getDate().toString(), // 日
|
||||
'h+': date.getHours().toString(), // 时
|
||||
'M+': date.getMinutes().toString(), // 分
|
||||
's+': date.getSeconds().toString(), // 秒
|
||||
}
|
||||
for (const k in opt) {
|
||||
ret = new RegExp('(' + k + ')').exec(fmt)
|
||||
if (ret) {
|
||||
fmt = fmt.replace(ret[1], ret[1].length == 1 ? opt[k] : padStart(opt[k], ret[1].length, '0'))
|
||||
}
|
||||
}
|
||||
return fmt
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 字符串补位
|
||||
*/
|
||||
const padStart = (str: string, maxLength: number, fillString = ' ') => {
|
||||
if (str.length >= maxLength) return str
|
||||
|
||||
const fillLength = maxLength - str.length
|
||||
let times = Math.ceil(fillLength / fillString.length)
|
||||
while ((times >>= 1)) {
|
||||
fillString += fillString
|
||||
if (times === 1) {
|
||||
fillString += fillString
|
||||
}
|
||||
}
|
||||
return fillString.slice(0, fillLength) + str
|
||||
}
|
||||
|
||||
|
||||
export function downloadFile(res: any, downName: string = '') {
|
||||
const aLink = document.createElement('a');
|
||||
let fileName = downName;
|
||||
let blob = res;
|
||||
|
||||
// 处理 axios 响应(包含 data 字段的 blob)
|
||||
if (res && res.data instanceof Blob) {
|
||||
blob = res.data;
|
||||
}
|
||||
// 处理包含 data 字段的响应
|
||||
else if (res && res.data) {
|
||||
blob = new Blob([res.data], {
|
||||
type: res.headers?.['content-type']?.replace(';charset=utf8', '') || 'application/octet-stream'
|
||||
});
|
||||
if (!downName && res.headers && res.headers['content-disposition']) {
|
||||
const contentDisposition = decodeURI(res.headers['content-disposition']);
|
||||
const result = contentDisposition.match(/filename="(.+)/gi);
|
||||
fileName = result?.[0].replace(/filename="(.+)/gi, '') || '';
|
||||
fileName = fileName.replace('"', '');
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 blob 是有效的
|
||||
if (!blob || !(blob instanceof Blob)) {
|
||||
console.error('无效的文件数据:', blob);
|
||||
ElMessage.error('下载失败');
|
||||
return;
|
||||
}
|
||||
|
||||
aLink.href = URL.createObjectURL(blob);
|
||||
aLink.setAttribute('download', fileName || 'download');
|
||||
document.body.appendChild(aLink);
|
||||
aLink.click();
|
||||
document.body.removeChild(aLink);
|
||||
URL.revokeObjectURL(aLink.href);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 数组相关工具函数
|
||||
*/
|
||||
|
||||
// 数组去重
|
||||
export function noRepeat<T>(arr: T[]): T[] {
|
||||
return [...new Set(arr)]
|
||||
}
|
||||
|
||||
// 查找数组最大值
|
||||
export function arrayMax(arr: number[]): number {
|
||||
if (!arr.length) throw new Error('Array is empty')
|
||||
return Math.max(...arr)
|
||||
}
|
||||
|
||||
// 查找数组最小值
|
||||
export function arrayMin(arr: number[]): number {
|
||||
if (!arr.length) throw new Error('Array is empty')
|
||||
return Math.min(...arr)
|
||||
}
|
||||
|
||||
// 数组分割
|
||||
export function chunk<T>(arr: T[], size: number = 1): T[][] {
|
||||
if (size <= 0) return [arr.slice()]
|
||||
return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
|
||||
arr.slice(i * size, i * size + size)
|
||||
)
|
||||
}
|
||||
|
||||
// 检查元素出现次数
|
||||
export function countOccurrences<T>(arr: T[], value: T): number {
|
||||
return arr.reduce((count, current) => (current === value ? count + 1 : count), 0)
|
||||
}
|
||||
|
||||
// 扁平化数组
|
||||
export function flatten<T>(arr: any[], depth: number = Infinity): T[] {
|
||||
return arr.flat(depth)
|
||||
}
|
||||
|
||||
// 返回两个数组的差集
|
||||
export function difference<T>(arrA: T[], arrB: T[]): T[] {
|
||||
const setB = new Set(arrB)
|
||||
return arrA.filter((item) => !setB.has(item))
|
||||
}
|
||||
|
||||
// 返回两个数组的交集
|
||||
export function intersection<T>(arr1: T[], arr2: T[]): T[] {
|
||||
const set2 = new Set(arr2)
|
||||
return arr1.filter((item) => set2.has(item))
|
||||
}
|
||||
|
||||
// 从右删除 n 个元素
|
||||
export function dropRight<T>(arr: T[], n: number = 0): T[] {
|
||||
return arr.slice(0, Math.max(0, arr.length - n))
|
||||
}
|
||||
|
||||
// 返回间隔 nth 的元素
|
||||
export function everyNth<T>(arr: T[], nth: number): T[] {
|
||||
if (nth <= 0) return []
|
||||
return arr.filter((_, i) => i % nth === nth - 1)
|
||||
}
|
||||
|
||||
// 返回第 n 个元素
|
||||
export function nthElement<T>(arr: T[], n: number = 0): T | undefined {
|
||||
const index = n >= 0 ? n : arr.length + n
|
||||
return arr[index]
|
||||
}
|
||||
|
||||
// 数组乱序
|
||||
export function shuffle<T>(arr: T[]): T[] {
|
||||
const array = [...arr] // 创建新数组避免修改原数组
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[array[i], array[j]] = [array[j], array[i]]
|
||||
}
|
||||
return array
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* 数据格式化相关工具函数
|
||||
*/
|
||||
|
||||
// 时间戳转时间
|
||||
export function timestampToTime(timestamp: number = Date.now(), isMs: boolean = true): string {
|
||||
const date = new Date(isMs ? timestamp : timestamp * 1000)
|
||||
return date.toISOString().replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
|
||||
// 数字格式化(千位分隔符)
|
||||
export function commafy(num: number): string {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
|
||||
// 生成随机数
|
||||
export function randomNum(min: number, max?: number): number {
|
||||
if (max === undefined) {
|
||||
max = min
|
||||
min = 0
|
||||
}
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
// 移除HTML标签
|
||||
export function removeHtmlTags(str: string = ''): string {
|
||||
return str.replace(/<[^>]*>/g, '')
|
||||
}
|
||||
|
||||
// 格式化 ISO 8601 日期字符串
|
||||
export function formatISODate(
|
||||
isoDate: string,
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
},
|
||||
timeZone: string = 'Asia/Shanghai'
|
||||
): string {
|
||||
const date = new Date(isoDate);
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
...options,
|
||||
timeZone
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 格式化时间戳
|
||||
* @param dateTime 时间戳
|
||||
* @param fmt 格式化方式,默认:yyyy-mm-dd hh:MM:ss
|
||||
*/
|
||||
export const timestampFormat = (dateTime: string | number | null = null, fmt = 'yyyy-mm-dd hh:MM:ss') => {
|
||||
if (!dateTime) dateTime = Number(new Date())
|
||||
if (dateTime.toString().length === 10) {
|
||||
dateTime = +dateTime * 1000
|
||||
}
|
||||
|
||||
const date = new Date(dateTime)
|
||||
let ret
|
||||
const opt: any = {
|
||||
'y+': date.getFullYear().toString(), // 年
|
||||
'm+': (date.getMonth() + 1).toString(), // 月
|
||||
'd+': date.getDate().toString(), // 日
|
||||
'h+': date.getHours().toString(), // 时
|
||||
'M+': date.getMinutes().toString(), // 分
|
||||
's+': date.getSeconds().toString(), // 秒
|
||||
}
|
||||
for (const k in opt) {
|
||||
ret = new RegExp('(' + k + ')').exec(fmt)
|
||||
if (ret) {
|
||||
fmt = fmt.replace(ret[1], ret[1].length == 1 ? opt[k] : padStart(opt[k], ret[1].length, '0'))
|
||||
}
|
||||
}
|
||||
return fmt
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 字符串补位
|
||||
*/
|
||||
const padStart = (str: string, maxLength: number, fillString = ' ') => {
|
||||
if (str.length >= maxLength) return str
|
||||
|
||||
const fillLength = maxLength - str.length
|
||||
let times = Math.ceil(fillLength / fillString.length)
|
||||
while ((times >>= 1)) {
|
||||
fillString += fillString
|
||||
if (times === 1) {
|
||||
fillString += fillString
|
||||
}
|
||||
}
|
||||
return fillString.slice(0, fillLength) + str
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用日期时间格式化函数
|
||||
* @param time 时间参数(时间戳、字符串时间、国际时间、Date对象)
|
||||
* @param format 格式化方式,默认:yyyy-MM-dd HH:mm:ss
|
||||
* @param timezone 时区,默认:Asia/Shanghai
|
||||
* @returns 格式化后的时间字符串
|
||||
*/
|
||||
export function formatDateTime(
|
||||
time: number | string | Date = Date.now(),
|
||||
format: string = 'yyyy-MM-dd HH:mm:ss',
|
||||
timezone: string = 'Asia/Shanghai'
|
||||
): string {
|
||||
let date: Date;
|
||||
|
||||
// 处理空值情况
|
||||
if (time === null || time === undefined || time === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 处理不同类型的时间参数
|
||||
if (time instanceof Date) {
|
||||
date = time;
|
||||
} else if (typeof time === 'number') {
|
||||
// 处理时间戳,判断是秒还是毫秒
|
||||
if (time.toString().length === 10) {
|
||||
date = new Date(time * 1000);
|
||||
} else {
|
||||
date = new Date(time);
|
||||
}
|
||||
} else if (typeof time === 'string') {
|
||||
// 处理字符串时间
|
||||
date = new Date(time);
|
||||
} else {
|
||||
date = new Date();
|
||||
}
|
||||
|
||||
// 检查日期是否有效
|
||||
if (isNaN(date.getTime())) {
|
||||
return '无效时间';
|
||||
}
|
||||
|
||||
// 如果使用了自定义格式
|
||||
if (format !== 'intl') {
|
||||
let ret;
|
||||
const opt: any = {
|
||||
'y+': date.getFullYear().toString(), // 年
|
||||
'M+': (date.getMonth() + 1).toString(), // 月
|
||||
'd+': date.getDate().toString(), // 日
|
||||
'H+': date.getHours().toString(), // 时
|
||||
'm+': date.getMinutes().toString(), // 分
|
||||
's+': date.getSeconds().toString(), // 秒
|
||||
};
|
||||
|
||||
for (const k in opt) {
|
||||
ret = new RegExp('(' + k + ')').exec(format);
|
||||
if (ret) {
|
||||
format = format.replace(ret[1], ret[1].length == 1 ? opt[k] : padStart(opt[k], ret[1].length, '0'));
|
||||
}
|
||||
}
|
||||
return format;
|
||||
} else {
|
||||
// 使用 Intl.DateTimeFormat 进行格式化(支持时区)
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: timezone
|
||||
}).format(date);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './format'
|
||||
export * from './random'
|
||||
export * from './array'
|
||||
@@ -0,0 +1,59 @@
|
||||
const hexList: string[] = []
|
||||
for (let i = 0; i <= 15; i++) {
|
||||
hexList[i] = i.toString(16)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机数
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @returns 生成的随机数
|
||||
*/
|
||||
// export function randomNum(min: number, max: number) {
|
||||
// switch (arguments.length) {
|
||||
// case 1:
|
||||
// return parseInt((Math.random() * min + 1).toString(), 10)
|
||||
// break
|
||||
// case 2:
|
||||
// return parseInt((Math.random() * (max - min + 1) + min).toString(), 10)
|
||||
// break
|
||||
// default:
|
||||
// return 0
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* 生成全球唯一标识
|
||||
* @returns uuid
|
||||
*/
|
||||
export function uuid(): string {
|
||||
let uuid = ''
|
||||
for (let i = 1; i <= 36; i++) {
|
||||
if (i === 9 || i === 14 || i === 19 || i === 24) {
|
||||
uuid += '-'
|
||||
} else if (i === 15) {
|
||||
uuid += 4
|
||||
} else if (i === 20) {
|
||||
uuid += hexList[(Math.random() * 4) | 8]
|
||||
} else {
|
||||
uuid += hexList[(Math.random() * 16) | 0]
|
||||
}
|
||||
}
|
||||
return uuid
|
||||
}
|
||||
|
||||
|
||||
let uniqueCounter = 0
|
||||
|
||||
/**
|
||||
* 生成唯一标识
|
||||
* @param prefix 前缀
|
||||
* @returns 唯一标识
|
||||
*/
|
||||
export function shortUuid(prefix = ''): string {
|
||||
const time = Date.now()
|
||||
const random = Math.floor(Math.random() * 1000000000)
|
||||
uniqueCounter++
|
||||
return prefix + '_' + random + uniqueCounter + String(time)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 主题动画工具模块
|
||||
*
|
||||
* 提供主题切换的视觉动画效果
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 基于鼠标点击位置的圆形扩散动画
|
||||
* - View Transition API 支持(现代浏览器)
|
||||
* - 降级处理(不支持动画的浏览器)
|
||||
* - 暗黑主题切换过渡效果
|
||||
* - 页面刷新时的主题过渡优化
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 明暗主题切换
|
||||
* - 提升用户体验的视觉反馈
|
||||
* - 页面刷新时的平滑过渡
|
||||
*
|
||||
* ## 技术实现
|
||||
*
|
||||
* - 使用 CSS 变量存储点击位置和半径
|
||||
* - 利用 View Transition API 实现流畅动画
|
||||
* - 通过 CSS class 控制过渡效果
|
||||
* - 自动计算最大扩散半径
|
||||
*
|
||||
* @module utils/theme/animation
|
||||
* @author Art Design Pro 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)
|
||||
}
|
||||
}
|
||||
@@ -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 Art Design Pro 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 表情
|
||||
* 用于在消息提示的时候显示对应的表情
|
||||
*
|
||||
* 用法
|
||||
* ElMessage.success(`${EmojiText[200]} 图片上传成功`)
|
||||
* ElMessage.error(`${EmojiText[400]} 图片上传失败`)
|
||||
* ElMessage.error(`${EmojiText[500]} 图片上传失败`)
|
||||
*
|
||||
* @module utils/ui/emojo
|
||||
* @author Art Design Pro 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 Art Design Pro 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)
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* UI 相关工具函数统一导出
|
||||
*
|
||||
* @module utils/ui/index
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
export * from './colors'
|
||||
export * from './loading'
|
||||
export * from './tabs'
|
||||
export * from './emojo'
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 全局 Loading 加载管理模块
|
||||
*
|
||||
* 提供统一的全屏加载动画管理
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 全屏 Loading 显示和隐藏
|
||||
* - 自动适配明暗主题背景色
|
||||
* - 自定义 SVG 加载动画
|
||||
* - 单例模式防止重复创建
|
||||
* - 锁定页面交互
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 页面初始化加载
|
||||
* - 大量数据请求
|
||||
* - 路由切换过渡
|
||||
* - 异步操作等待
|
||||
*
|
||||
* ## 特性
|
||||
*
|
||||
* - 自动检测当前主题并应用对应背景色
|
||||
* - 使用自定义 SVG 动画(四点旋转)
|
||||
* - 单例模式确保同时只有一个 Loading
|
||||
* - 提供便捷的显示/隐藏方法
|
||||
*
|
||||
* @module utils/ui/loading
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 标签页布局配置模块
|
||||
*
|
||||
* 提供不同标签页样式的高度和间距配置
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 多种标签页样式配置(默认、卡片、谷歌风格)
|
||||
* - 标签页打开/关闭状态的高度管理
|
||||
* - 顶部间距自动计算
|
||||
* - 配置获取和默认值处理
|
||||
*
|
||||
* ## 使用场景
|
||||
*
|
||||
* - 工作标签页(Worktab)布局计算
|
||||
* - 页面内容区域高度调整
|
||||
* - 标签页显示/隐藏时的动画
|
||||
* - 响应式布局适配
|
||||
*
|
||||
* ## 配置项说明
|
||||
*
|
||||
* - openTop: 标签页显示时,内容区域距离顶部的距离
|
||||
* - closeTop: 标签页隐藏时,内容区域距离顶部的距离
|
||||
* - openHeight: 标签页显示时的总高度(包含标签栏)
|
||||
* - closeHeight: 标签页隐藏时的总高度(仅头部)
|
||||
*
|
||||
* ## 支持的样式
|
||||
*
|
||||
* - tab-default: 默认标签页样式
|
||||
* - tab-card: 卡片式标签页
|
||||
* - tab-google: 谷歌浏览器风格标签页
|
||||
*
|
||||
* @module utils/ui/tabs
|
||||
* @author Art Design Pro 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 配置
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* URL 工具模块
|
||||
*
|
||||
* 统一处理框架中的 URL,重新导出静态资源模块的所有 API
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 重新导出静态资源模块 API
|
||||
// ============================================================================
|
||||
|
||||
export {
|
||||
buildStaticUrl,
|
||||
buildStaticUrls,
|
||||
extractRelativePath,
|
||||
isCdnMode,
|
||||
getStaticUrlBase,
|
||||
joinUrls,
|
||||
staticUrl,
|
||||
staticUrls
|
||||
} from '@/utils/url/static-resource'
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 静态资源处理模块
|
||||
*
|
||||
* 统一处理框架中的静态资源 URL,支持两种部署模式:
|
||||
* 1. 同域访问(默认):dist 部署到后端 public 目录
|
||||
* 2. CDN 访问:使用对象存储或 CDN
|
||||
*
|
||||
* 优先级:系统配置 > 环境变量 > 默认值
|
||||
*/
|
||||
|
||||
import { useSystemConfigStore } from '@/store/modules/site-config'
|
||||
|
||||
// ============================================================================
|
||||
// 私有工具函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 检查是否是完整 URL
|
||||
*/
|
||||
const isFullUrl = (url: string): boolean => {
|
||||
return /^https?:\/\//i.test(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是 Data URL
|
||||
*/
|
||||
const isDataUrl = (url: string): boolean => {
|
||||
return /^data:[\w\+\/]+/i.test(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化路径(移除开头的斜杠)
|
||||
*/
|
||||
const normalizePath = (path: string): string => {
|
||||
return path.replace(/^\/+/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取静态资源基础 URL
|
||||
*
|
||||
* @param domain 可选的域名参数,手动覆盖默认配置
|
||||
* @returns 空字符串表示同域访问,字符串表示 CDN 域名
|
||||
*
|
||||
* 两种模式:
|
||||
* - 同域访问:返回空字符串,使用相对路径
|
||||
* - CDN 访问:返回完整域名
|
||||
*/
|
||||
const getStaticBaseUrl = (domain?: string): string => {
|
||||
// 1. 如果手动传入了 domain,优先使用
|
||||
if (domain) {
|
||||
if (/^https?:\/\//i.test(domain)) {
|
||||
// 完整 URL
|
||||
return domain.replace(/\/+$/, '')
|
||||
}
|
||||
// 相对路径前缀
|
||||
return domain.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// 2. 优先使用系统配置(后台动态配置)
|
||||
const systemConfig = useSystemConfigStore()
|
||||
const configStaticUrl = systemConfig.config.static_url
|
||||
|
||||
if (configStaticUrl) {
|
||||
// 处理系统配置的值
|
||||
if (/^https?:\/\//i.test(configStaticUrl)) {
|
||||
// CDN 模式:返回完整域名
|
||||
return configStaticUrl.replace(/\/+$/, '')
|
||||
}
|
||||
// 其他值视为相对路径前缀(如 /static)
|
||||
return configStaticUrl.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// 3. 其次使用环境变量
|
||||
const envStaticUrl = import.meta.env.VITE_STATIC_URL
|
||||
|
||||
// 留空:同域访问,使用相对路径
|
||||
if (!envStaticUrl || envStaticUrl.trim() === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
// useCurrentDomain:开发环境,使用当前域名
|
||||
if (envStaticUrl === 'useCurrentDomain') {
|
||||
return window.location.origin
|
||||
}
|
||||
|
||||
// CDN 模式:返回完整域名
|
||||
if (/^https?:\/\//i.test(envStaticUrl)) {
|
||||
return envStaticUrl.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// 相对路径前缀(如 /static)
|
||||
return envStaticUrl.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 公共 API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 构建静态资源完整 URL(支持 CDN 参数)
|
||||
*
|
||||
* @param path 资源路径(相对路径或完整 URL)
|
||||
* @param domain 可选的域名参数,手动覆盖默认配置
|
||||
* @param addCdnParams 是否添加 CDN 参数(默认 true)
|
||||
* @returns 完整的 URL 或相对路径
|
||||
*
|
||||
* @example
|
||||
* // 同域访问(默认配置)
|
||||
* buildStaticUrl('/storage/image.jpg')
|
||||
* // => '/storage/image.jpg'
|
||||
*
|
||||
* // CDN 访问(默认配置)
|
||||
* buildStaticUrl('/storage/image.jpg')
|
||||
* // => 'https://cdn.example.com/storage/image.jpg?token=xxx'
|
||||
*
|
||||
* // 手动指定 CDN
|
||||
* buildStaticUrl('/storage/image.jpg', 'https://mycdn.com')
|
||||
* // => 'https://mycdn.com/storage/image.jpg'
|
||||
*/
|
||||
export const buildStaticUrl = (path: string, domain?: string, addParams: boolean = true): string => {
|
||||
if (!path) return ''
|
||||
|
||||
// 已经是完整 URL,直接返回
|
||||
if (isFullUrl(path)) return addParams ? addCdnParams(path) : path
|
||||
|
||||
// Data URL,直接返回
|
||||
if (isDataUrl(path)) return path
|
||||
|
||||
const baseUrl = getStaticBaseUrl(domain)
|
||||
const normalizedPath = normalizePath(path)
|
||||
|
||||
// 同域访问:返回相对路径
|
||||
if (!baseUrl) {
|
||||
return `/${normalizedPath}`
|
||||
}
|
||||
|
||||
// CDN 访问:拼接完整 URL
|
||||
const fullUrl = `${baseUrl}/${normalizedPath}`
|
||||
return addParams ? addCdnParams(fullUrl) : fullUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量构建静态资源 URL
|
||||
*
|
||||
* @param paths 资源路径数组或逗号分隔的字符串
|
||||
* @param domain 可选的域名参数,手动覆盖默认配置
|
||||
* @returns 完整的 URL 数组
|
||||
*
|
||||
* @example
|
||||
* buildStaticUrls(['/img/1.jpg', '/img/2.jpg'])
|
||||
* // => ['/storage/img/1.jpg', '/storage/img/2.jpg']
|
||||
*
|
||||
* // 手动指定 CDN
|
||||
* buildStaticUrls(['/img/1.jpg'], 'https://mycdn.com')
|
||||
* // => ['https://mycdn.com/img/1.jpg']
|
||||
*/
|
||||
export const buildStaticUrls = (paths: string | string[], domain?: string): string[] => {
|
||||
if (typeof paths === 'string') {
|
||||
return paths.split(',').filter(Boolean).map(p => buildStaticUrl(p.trim(), domain))
|
||||
}
|
||||
return paths.filter(Boolean).map(p => buildStaticUrl(p, domain))
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 URL 中的公共路径前缀(支持 CDN 参数移除)
|
||||
*
|
||||
* @param url 待处理的 URL
|
||||
* @param domain 可选指定要移除的特定域名(默认自动检测)
|
||||
* @returns 移除前缀后的相对路径
|
||||
*
|
||||
* @example
|
||||
* extractRelativePath('https://cdn.example.com/storage/image.jpg?token=xxx')
|
||||
* // => 'storage/image.jpg'
|
||||
*
|
||||
* extractRelativePath('/adminapi/upload/image.jpg')
|
||||
* // => 'upload/image.jpg'
|
||||
*/
|
||||
export const extractRelativePath = (url: string, domain?: string): string => {
|
||||
if (!url) return ''
|
||||
|
||||
// Data URL,直接返回
|
||||
if (isDataUrl(url)) return url
|
||||
|
||||
const systemConfig = useSystemConfigStore()
|
||||
const baseDomain = domain || systemConfig.config.cdnUrl || getStaticBaseUrl()
|
||||
|
||||
// 如果有指定的域名,尝试移除
|
||||
if (baseDomain) {
|
||||
const normalizedDomain = baseDomain.replace(/\/+$/, '')
|
||||
let result = url
|
||||
.replace(new RegExp(`^https?:\\/\\/${normalizedDomain}\\/?`), '')
|
||||
.replace(new RegExp(`^${normalizedDomain}\\/?`), '')
|
||||
|
||||
// 移除 CDN 参数(如果来自同一 CDN)
|
||||
if (normalizedDomain === systemConfig.config.cdnUrl && systemConfig.config.cdnUrlParams) {
|
||||
const paramStr = `[?&]${systemConfig.config.cdnUrlParams.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`
|
||||
result = result.replace(new RegExp(`${paramStr}(?:&|$)`), '')
|
||||
}
|
||||
|
||||
url = result
|
||||
}
|
||||
|
||||
// 完整 URL:提取路径部分
|
||||
if (isFullUrl(url)) {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
let path = urlObj.pathname
|
||||
|
||||
// 移除 API 前缀
|
||||
const apiPrefixes = ['/adminapi', '/webapi', '/api', '/sys']
|
||||
for (const prefix of apiPrefixes) {
|
||||
if (path.startsWith(prefix)) {
|
||||
path = path.substring(prefix.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return normalizePath(path)
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// 已经是相对路径
|
||||
const apiPrefixes = ['/adminapi', '/webapi', '/api', '/sys']
|
||||
for (const prefix of apiPrefixes) {
|
||||
if (url.startsWith(prefix)) {
|
||||
return url.substring(prefix.length).replace(/^\/+/, '')
|
||||
}
|
||||
}
|
||||
return normalizePath(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前是否使用 CDN 模式
|
||||
*
|
||||
* @returns true 表示使用 CDN,false 表示同域访问
|
||||
*/
|
||||
export const isCdnMode = (): boolean => {
|
||||
const baseUrl = getStaticBaseUrl()
|
||||
return baseUrl !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取静态资源基础 URL(公共 API)
|
||||
* 用于需要手动处理资源路径的场景
|
||||
*
|
||||
* @param domain 可选的域名参数,手动覆盖默认配置
|
||||
* @returns 空字符串表示同域访问,字符串表示 CDN 域名
|
||||
*
|
||||
* @example
|
||||
* // 使用默认配置
|
||||
* getStaticUrlBase()
|
||||
* // => '' (同域) 或 'https://cdn.example.com' (CDN)
|
||||
*
|
||||
* // 手动指定
|
||||
* getStaticUrlBase('https://mycdn.com')
|
||||
* // => 'https://mycdn.com'
|
||||
*/
|
||||
export const getStaticUrlBase = (domain?: string): string => {
|
||||
return getStaticBaseUrl(domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 CDN URL 参数
|
||||
*
|
||||
* @param url 基础 URL
|
||||
* @returns 添加参数后的 URL
|
||||
*/
|
||||
const addCdnParams = (url: string): string => {
|
||||
const systemConfig = useSystemConfigStore()
|
||||
const cdnUrl = systemConfig.config.cdnUrl
|
||||
const cdnParams = systemConfig.config.cdnUrlParams
|
||||
|
||||
// 只有当使用 CDN 且配置了参数时才添加
|
||||
if (!cdnUrl || !cdnParams || !url.includes(cdnUrl)) {
|
||||
return url
|
||||
}
|
||||
|
||||
const separator = url.includes('?') ? '&' : '?'
|
||||
return `${url}${separator}${cdnParams}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接多个 URL 路径
|
||||
*
|
||||
* @param base 基础路径
|
||||
* @param paths 要拼接的路径
|
||||
* @returns 拼接后的完整路径
|
||||
*
|
||||
* @example
|
||||
* joinUrls('/api', 'users', '1')
|
||||
* // => '/api/users/1'
|
||||
*
|
||||
* joinUrls('https://example.com', 'api', 'users')
|
||||
* // => 'https://example.com/api/users'
|
||||
*/
|
||||
export const joinUrls = (base: string, ...paths: string[]): string => {
|
||||
return [base.replace(/\/+$/, ''), ...paths.map(p => p.replace(/^\/+/, ''))]
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 导出别名,方便使用
|
||||
// ============================================================================
|
||||
export const staticUrl = buildStaticUrl
|
||||
export const staticUrls = buildStaticUrls
|
||||
Reference in New Issue
Block a user