443 lines
11 KiB
TypeScript
443 lines
11 KiB
TypeScript
|
|
import { DATE_FORMATS } from '@/constants';
|
|||
|
|
|
|||
|
|
// 日期格式化
|
|||
|
|
export const formatDate = (
|
|||
|
|
date: string | Date,
|
|||
|
|
format: string = DATE_FORMATS.DISPLAY_DATETIME
|
|||
|
|
): string => {
|
|||
|
|
if (!date) return '';
|
|||
|
|
|
|||
|
|
const d = new Date(date);
|
|||
|
|
if (isNaN(d.getTime())) return '';
|
|||
|
|
|
|||
|
|
const year = d.getFullYear();
|
|||
|
|
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|||
|
|
const day = String(d.getDate()).padStart(2, '0');
|
|||
|
|
const hours = String(d.getHours()).padStart(2, '0');
|
|||
|
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|||
|
|
const seconds = String(d.getSeconds()).padStart(2, '0');
|
|||
|
|
|
|||
|
|
return format
|
|||
|
|
.replace('YYYY', String(year))
|
|||
|
|
.replace('MM', month)
|
|||
|
|
.replace('DD', day)
|
|||
|
|
.replace('HH', hours)
|
|||
|
|
.replace('mm', minutes)
|
|||
|
|
.replace('ss', seconds)
|
|||
|
|
.replace('年', '年')
|
|||
|
|
.replace('月', '月')
|
|||
|
|
.replace('日', '日');
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 日期时间格式化(formatDate 的别名,用于向后兼容)
|
|||
|
|
export const formatDateTime = formatDate;
|
|||
|
|
|
|||
|
|
// 相对时间格式化
|
|||
|
|
export const formatRelativeTime = (date: string | Date): string => {
|
|||
|
|
if (!date) return '';
|
|||
|
|
|
|||
|
|
const d = new Date(date);
|
|||
|
|
if (isNaN(d.getTime())) return '';
|
|||
|
|
|
|||
|
|
const now = new Date();
|
|||
|
|
const diff = now.getTime() - d.getTime();
|
|||
|
|
const seconds = Math.floor(diff / 1000);
|
|||
|
|
const minutes = Math.floor(seconds / 60);
|
|||
|
|
const hours = Math.floor(minutes / 60);
|
|||
|
|
const days = Math.floor(hours / 24);
|
|||
|
|
|
|||
|
|
if (seconds < 60) return '刚刚';
|
|||
|
|
if (minutes < 60) return `${minutes}分钟前`;
|
|||
|
|
if (hours < 24) return `${hours}小时前`;
|
|||
|
|
if (days < 7) return `${days}天前`;
|
|||
|
|
|
|||
|
|
return formatDate(date, DATE_FORMATS.DISPLAY_DATE);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 货币格式化
|
|||
|
|
export const formatCurrency = (
|
|||
|
|
amount: number,
|
|||
|
|
currency: string = 'USD',
|
|||
|
|
locale: string = 'zh-CN'
|
|||
|
|
): string => {
|
|||
|
|
if (typeof amount !== 'number' || isNaN(amount)) return '¥0.00';
|
|||
|
|
|
|||
|
|
const formatter = new Intl.NumberFormat(locale, {
|
|||
|
|
style: 'currency',
|
|||
|
|
currency: currency,
|
|||
|
|
minimumFractionDigits: 2,
|
|||
|
|
maximumFractionDigits: 2
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return formatter.format(amount);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 数字格式化
|
|||
|
|
export const formatNumber = (
|
|||
|
|
num: number,
|
|||
|
|
locale: string = 'zh-CN'
|
|||
|
|
): string => {
|
|||
|
|
if (typeof num !== 'number' || isNaN(num)) return '0';
|
|||
|
|
|
|||
|
|
return new Intl.NumberFormat(locale).format(num);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 百分比格式化
|
|||
|
|
export const formatPercentage = (
|
|||
|
|
value: number,
|
|||
|
|
decimals: number = 1
|
|||
|
|
): string => {
|
|||
|
|
if (typeof value !== 'number' || isNaN(value)) return '0%';
|
|||
|
|
|
|||
|
|
return `${value.toFixed(decimals)}%`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 文件大小格式化
|
|||
|
|
export const formatFileSize = (bytes: number): string => {
|
|||
|
|
if (bytes === 0) return '0 B';
|
|||
|
|
|
|||
|
|
const k = 1024;
|
|||
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|||
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|||
|
|
|
|||
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 时长格式化(秒转换为时分秒)
|
|||
|
|
export const formatDuration = (seconds: number): string => {
|
|||
|
|
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) return '0秒';
|
|||
|
|
|
|||
|
|
const hours = Math.floor(seconds / 3600);
|
|||
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|||
|
|
const remainingSeconds = seconds % 60;
|
|||
|
|
|
|||
|
|
if (hours > 0) {
|
|||
|
|
return `${hours}小时${minutes}分钟${remainingSeconds}秒`;
|
|||
|
|
} else if (minutes > 0) {
|
|||
|
|
return `${minutes}分钟${remainingSeconds}秒`;
|
|||
|
|
} else {
|
|||
|
|
return `${remainingSeconds}秒`;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 字符串截断
|
|||
|
|
export const truncateText = (
|
|||
|
|
text: string,
|
|||
|
|
maxLength: number = 50,
|
|||
|
|
suffix: string = '...'
|
|||
|
|
): string => {
|
|||
|
|
if (!text || typeof text !== 'string') return '';
|
|||
|
|
if (text.length <= maxLength) return text;
|
|||
|
|
|
|||
|
|
return text.substring(0, maxLength - suffix.length) + suffix;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 邮箱验证
|
|||
|
|
export const isValidEmail = (email: string): boolean => {
|
|||
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|||
|
|
return emailRegex.test(email);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 手机号验证(中国大陆)
|
|||
|
|
export const isValidPhone = (phone: string): boolean => {
|
|||
|
|
const phoneRegex = /^1[3-9]\d{9}$/;
|
|||
|
|
return phoneRegex.test(phone);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// URL验证
|
|||
|
|
export const isValidUrl = (url: string): boolean => {
|
|||
|
|
try {
|
|||
|
|
new URL(url);
|
|||
|
|
return true;
|
|||
|
|
} catch {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 密码强度验证
|
|||
|
|
export const getPasswordStrength = (password: string): {
|
|||
|
|
score: number;
|
|||
|
|
level: 'weak' | 'medium' | 'strong';
|
|||
|
|
feedback: string[];
|
|||
|
|
} => {
|
|||
|
|
const feedback: string[] = [];
|
|||
|
|
let score = 0;
|
|||
|
|
|
|||
|
|
if (password.length >= 8) {
|
|||
|
|
score += 1;
|
|||
|
|
} else {
|
|||
|
|
feedback.push('密码长度至少8位');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (/[a-z]/.test(password)) {
|
|||
|
|
score += 1;
|
|||
|
|
} else {
|
|||
|
|
feedback.push('包含小写字母');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (/[A-Z]/.test(password)) {
|
|||
|
|
score += 1;
|
|||
|
|
} else {
|
|||
|
|
feedback.push('包含大写字母');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (/\d/.test(password)) {
|
|||
|
|
score += 1;
|
|||
|
|
} else {
|
|||
|
|
feedback.push('包含数字');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
|||
|
|
score += 1;
|
|||
|
|
} else {
|
|||
|
|
feedback.push('包含特殊字符');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let level: 'weak' | 'medium' | 'strong';
|
|||
|
|
if (score <= 2) {
|
|||
|
|
level = 'weak';
|
|||
|
|
} else if (score <= 3) {
|
|||
|
|
level = 'medium';
|
|||
|
|
} else {
|
|||
|
|
level = 'strong';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { score, level, feedback };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 深拷贝
|
|||
|
|
export const deepClone = <T>(obj: T): T => {
|
|||
|
|
if (obj === null || typeof obj !== 'object') return obj;
|
|||
|
|
if (obj instanceof Date) return new Date(obj.getTime()) as unknown as T;
|
|||
|
|
if (obj instanceof Array) return obj.map(item => deepClone(item)) as unknown as T;
|
|||
|
|
if (typeof obj === 'object') {
|
|||
|
|
const clonedObj = {} as { [key: string]: any };
|
|||
|
|
for (const key in obj) {
|
|||
|
|
if (obj.hasOwnProperty(key)) {
|
|||
|
|
clonedObj[key] = deepClone((obj as { [key: string]: any })[key]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return clonedObj as T;
|
|||
|
|
}
|
|||
|
|
return obj;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 防抖函数
|
|||
|
|
export const debounce = <T extends (...args: any[]) => any>(
|
|||
|
|
func: T,
|
|||
|
|
wait: number
|
|||
|
|
): ((...args: Parameters<T>) => void) => {
|
|||
|
|
let timeout: NodeJS.Timeout;
|
|||
|
|
|
|||
|
|
return (...args: Parameters<T>) => {
|
|||
|
|
clearTimeout(timeout);
|
|||
|
|
timeout = setTimeout(() => func(...args), wait);
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 节流函数
|
|||
|
|
export const throttle = <T extends (...args: any[]) => any>(
|
|||
|
|
func: T,
|
|||
|
|
wait: number
|
|||
|
|
): ((...args: Parameters<T>) => void) => {
|
|||
|
|
let inThrottle = false;
|
|||
|
|
|
|||
|
|
return (...args: Parameters<T>) => {
|
|||
|
|
if (!inThrottle) {
|
|||
|
|
func(...args);
|
|||
|
|
inThrottle = true;
|
|||
|
|
setTimeout(() => inThrottle = false, wait);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 生成随机字符串
|
|||
|
|
export const generateRandomString = (length: number = 8): string => {
|
|||
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|||
|
|
let result = '';
|
|||
|
|
for (let i = 0; i < length; i++) {
|
|||
|
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|||
|
|
}
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 生成UUID
|
|||
|
|
export const generateUUID = (): string => {
|
|||
|
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|||
|
|
const r = Math.random() * 16 | 0;
|
|||
|
|
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|||
|
|
return v.toString(16);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 数组去重
|
|||
|
|
export const uniqueArray = <T>(array: T[], key?: keyof T): T[] => {
|
|||
|
|
if (!key) {
|
|||
|
|
return [...new Set(array)];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const seen = new Set();
|
|||
|
|
return array.filter(item => {
|
|||
|
|
const keyValue = item[key];
|
|||
|
|
if (seen.has(keyValue)) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
seen.add(keyValue);
|
|||
|
|
return true;
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 数组分组
|
|||
|
|
export const groupBy = <T>(
|
|||
|
|
array: T[],
|
|||
|
|
key: keyof T | ((item: T) => string | number)
|
|||
|
|
): Record<string, T[]> => {
|
|||
|
|
return array.reduce((groups, item) => {
|
|||
|
|
const groupKey = typeof key === 'function' ? key(item) : item[key];
|
|||
|
|
const keyStr = String(groupKey);
|
|||
|
|
|
|||
|
|
if (!groups[keyStr]) {
|
|||
|
|
groups[keyStr] = [];
|
|||
|
|
}
|
|||
|
|
groups[keyStr].push(item);
|
|||
|
|
|
|||
|
|
return groups;
|
|||
|
|
}, {} as Record<string, T[]>);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 对象转查询字符串
|
|||
|
|
export const objectToQueryString = (obj: Record<string, any>): string => {
|
|||
|
|
const params = new URLSearchParams();
|
|||
|
|
|
|||
|
|
Object.entries(obj).forEach(([key, value]) => {
|
|||
|
|
if (value !== null && value !== undefined && value !== '') {
|
|||
|
|
if (Array.isArray(value)) {
|
|||
|
|
value.forEach(item => params.append(key, String(item)));
|
|||
|
|
} else {
|
|||
|
|
params.append(key, String(value));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return params.toString();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 查询字符串转对象
|
|||
|
|
export const queryStringToObject = (queryString: string): Record<string, any> => {
|
|||
|
|
const params = new URLSearchParams(queryString);
|
|||
|
|
const result: Record<string, any> = {};
|
|||
|
|
|
|||
|
|
for (const [key, value] of params.entries()) {
|
|||
|
|
if (result[key]) {
|
|||
|
|
if (Array.isArray(result[key])) {
|
|||
|
|
result[key].push(value);
|
|||
|
|
} else {
|
|||
|
|
result[key] = [result[key], value];
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
result[key] = value;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return result;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 本地存储封装
|
|||
|
|
export const storage = {
|
|||
|
|
get: <T>(key: string, defaultValue?: T): T | null => {
|
|||
|
|
try {
|
|||
|
|
const item = localStorage.getItem(key);
|
|||
|
|
return item ? JSON.parse(item) : defaultValue || null;
|
|||
|
|
} catch {
|
|||
|
|
return defaultValue || null;
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
set: (key: string, value: any): void => {
|
|||
|
|
try {
|
|||
|
|
localStorage.setItem(key, JSON.stringify(value));
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Storage set error:', error);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
remove: (key: string): void => {
|
|||
|
|
try {
|
|||
|
|
localStorage.removeItem(key);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Storage remove error:', error);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
clear: (): void => {
|
|||
|
|
try {
|
|||
|
|
localStorage.clear();
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Storage clear error:', error);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 颜色工具
|
|||
|
|
export const colorUtils = {
|
|||
|
|
// 十六进制转RGB
|
|||
|
|
hexToRgb: (hex: string): { r: number; g: number; b: number } | null => {
|
|||
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|||
|
|
return result ? {
|
|||
|
|
r: parseInt(result[1], 16),
|
|||
|
|
g: parseInt(result[2], 16),
|
|||
|
|
b: parseInt(result[3], 16)
|
|||
|
|
} : null;
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// RGB转十六进制
|
|||
|
|
rgbToHex: (r: number, g: number, b: number): string => {
|
|||
|
|
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 获取对比色
|
|||
|
|
getContrastColor: (hex: string): string => {
|
|||
|
|
const rgb = colorUtils.hexToRgb(hex);
|
|||
|
|
if (!rgb) return '#000000';
|
|||
|
|
|
|||
|
|
const brightness = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
|
|||
|
|
return brightness > 128 ? '#000000' : '#ffffff';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 设备检测
|
|||
|
|
export const deviceUtils = {
|
|||
|
|
isMobile: (): boolean => {
|
|||
|
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
isTablet: (): boolean => {
|
|||
|
|
return /iPad|Android/i.test(navigator.userAgent) && !deviceUtils.isMobile();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
isDesktop: (): boolean => {
|
|||
|
|
return !deviceUtils.isMobile() && !deviceUtils.isTablet();
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
getScreenSize: (): 'xs' | 'sm' | 'md' | 'lg' | 'xl' => {
|
|||
|
|
const width = window.innerWidth;
|
|||
|
|
if (width < 576) return 'xs';
|
|||
|
|
if (width < 768) return 'sm';
|
|||
|
|
if (width < 992) return 'md';
|
|||
|
|
if (width < 1200) return 'lg';
|
|||
|
|
return 'xl';
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 错误处理
|
|||
|
|
export const handleError = (error: any): string => {
|
|||
|
|
if (typeof error === 'string') return error;
|
|||
|
|
if (error?.message) return error.message;
|
|||
|
|
if (error?.response?.data?.message) return error.response.data.message;
|
|||
|
|
return '操作失败,请稍后重试';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 成功提示
|
|||
|
|
export const handleSuccess = (message?: string): string => {
|
|||
|
|
return message || '操作成功';
|
|||
|
|
};
|