提交新版本

This commit is contained in:
luob
2025-12-23 17:14:38 +08:00
3632 changed files with 498895 additions and 0 deletions

View File

@@ -0,0 +1,104 @@
{
"name": "@vben-core/shared",
"version": "5.5.9",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/base/shared"
},
"license": "MIT",
"type": "module",
"scripts": {
"build": "pnpm unbuild",
"stub": "pnpm unbuild --stub"
},
"files": [
"dist"
],
"sideEffects": false,
"exports": {
"./constants": {
"types": "./src/constants/index.ts",
"development": "./src/constants/index.ts",
"default": "./dist/constants/index.mjs"
},
"./utils": {
"types": "./src/utils/index.ts",
"development": "./src/utils/index.ts",
"default": "./dist/utils/index.mjs"
},
"./color": {
"types": "./src/color/index.ts",
"development": "./src/color/index.ts",
"default": "./dist/color/index.mjs"
},
"./cache": {
"types": "./src/cache/index.ts",
"development": "./src/cache/index.ts",
"default": "./dist/cache/index.mjs"
},
"./store": {
"types": "./src/store.ts",
"development": "./src/store.ts",
"default": "./dist/store.mjs"
},
"./global-state": {
"types": "./src/global-state.ts",
"development": "./src/global-state.ts",
"default": "./dist/global-state.mjs"
}
},
"publishConfig": {
"exports": {
"./constants": {
"types": "./dist/constants/index.d.ts",
"default": "./dist/constants/index.mjs"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"default": "./dist/utils/index.mjs"
},
"./color": {
"types": "./dist/color/index.d.ts",
"default": "./dist/color/index.mjs"
},
"./cache": {
"types": "./dist/cache/index.d.ts",
"default": "./dist/cache/index.mjs"
},
"./store": {
"types": "./dist/store.d.ts",
"default": "./dist/store.mjs"
},
"./global-state": {
"types": "./dist/global-state.d.ts",
"default": "./dist/global-state.mjs"
}
}
},
"dependencies": {
"@ctrl/tinycolor": "catalog:",
"@tanstack/vue-store": "catalog:",
"@vue/shared": "catalog:",
"clsx": "catalog:",
"crypto-js": "catalog:",
"dayjs": "catalog:",
"defu": "catalog:",
"es-toolkit": "catalog:",
"jsencrypt": "catalog:",
"lodash.clonedeep": "catalog:",
"nprogress": "catalog:",
"tailwind-merge": "catalog:",
"theme-colors": "catalog:"
},
"devDependencies": {
"@types/crypto-js": "catalog:",
"@types/lodash.clonedeep": "catalog:",
"@types/lodash.get": "catalog:",
"@types/lodash.isequal": "catalog:",
"@types/lodash.set": "catalog:",
"@types/nprogress": "catalog:"
}
}

View File

@@ -0,0 +1,31 @@
/**
* @zh_CN GITHUB 仓库地址
*/
// export const VBEN_GITHUB_URL = 'https://github.com/vbenjs/vue-vben-admin';
export const VBEN_GITHUB_URL =
'https://github.com/yudaocode/yudao-ui-admin-vben';
/**
* @zh_CN 文档地址
*/
// export const VBEN_DOC_URL = 'https://doc.vben.pro';
export const VBEN_DOC_URL = 'https://doc.iocoder.cn/';
/**
* @zh_CN Vben Logo
*/
export const VBEN_LOGO_URL =
'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp';
/**
* @zh_CN Vben Admin 首页地址
*/
export const VBEN_PREVIEW_URL = 'https://www.vben.pro';
export const VBEN_ELE_PREVIEW_URL = 'https://ele.vben.pro';
export const VBEN_NAIVE_PREVIEW_URL = 'https://naive.vben.pro';
export const VBEN_ANT_PREVIEW_URL = 'https://ant.vben.pro';
export const VBEN_TD_PREVIEW_URL = 'https://tdesign.vben.pro';

View File

@@ -0,0 +1,143 @@
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
formatDate,
formatDateTime,
getCurrentTimezone,
getSystemTimezone,
isDate,
isDayjsObject,
setCurrentTimezone,
} from '../date';
dayjs.extend(utc);
dayjs.extend(timezone);
describe('dateUtils', () => {
const sampleISO = '2024-10-30T12:34:56Z';
const sampleTimestamp = Date.parse(sampleISO);
beforeEach(() => {
// 重置时区
dayjs.tz.setDefault();
setCurrentTimezone(); // 重置为系统默认
});
afterEach(() => {
vi.restoreAllMocks();
});
// ===============================
// formatDate
// ===============================
describe('formatDate', () => {
it('should format a valid ISO date string', () => {
const formatted = formatDate(sampleISO, 'YYYY/MM/DD');
expect(formatted).toMatch(/2024\/10\/30/);
});
it('should format a timestamp correctly', () => {
const formatted = formatDate(sampleTimestamp);
expect(formatted).toMatch(/2024-10-30/);
});
it('should format a Date object', () => {
const formatted = formatDate(new Date(sampleISO));
expect(formatted).toMatch(/2024-10-30/);
});
it('should format a dayjs object', () => {
const formatted = formatDate(dayjs(sampleISO));
expect(formatted).toMatch(/2024-10-30/);
});
it('should return original input if date is invalid', () => {
const invalid = 'not-a-date';
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const formatted = formatDate(invalid);
expect(formatted).toBe(invalid);
expect(spy).toHaveBeenCalledOnce();
});
it('should apply given format', () => {
const formatted = formatDate(sampleISO, 'YYYY-MM-DD HH:mm');
expect(formatted).toMatch(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}/);
});
});
// ===============================
// formatDateTime
// ===============================
describe('formatDateTime', () => {
it('should format date into full datetime', () => {
const result = formatDateTime(sampleISO);
expect(result).toMatch(/2024-10-30 \d{2}:\d{2}:\d{2}/);
});
});
// ===============================
// isDate
// ===============================
describe('isDate', () => {
it('should return true for Date instances', () => {
expect(isDate(new Date())).toBe(true);
});
it('should return false for non-Date values', () => {
expect(isDate('2024-10-30')).toBe(false);
expect(isDate(null)).toBe(false);
expect(isDate(undefined)).toBe(false);
});
});
// ===============================
// isDayjsObject
// ===============================
describe('isDayjsObject', () => {
it('should return true for dayjs objects', () => {
expect(isDayjsObject(dayjs())).toBe(true);
});
it('should return false for other values', () => {
expect(isDayjsObject(new Date())).toBe(false);
expect(isDayjsObject('string')).toBe(false);
});
});
// ===============================
// getSystemTimezone
// ===============================
describe('getSystemTimezone', () => {
it('should return a valid IANA timezone string', () => {
const tz = getSystemTimezone();
expect(typeof tz).toBe('string');
expect(tz).toMatch(/^[A-Z]+\/[A-Z_]+/i);
});
});
// ===============================
// setCurrentTimezone / getCurrentTimezone
// ===============================
describe('setCurrentTimezone & getCurrentTimezone', () => {
it('should set and retrieve the current timezone', () => {
setCurrentTimezone('Asia/Shanghai');
expect(getCurrentTimezone()).toBe('Asia/Shanghai');
});
it('should reset to system timezone when called with no args', () => {
const guessed = getSystemTimezone();
setCurrentTimezone();
expect(getCurrentTimezone()).toBe(guessed);
});
it('should update dayjs default timezone', () => {
setCurrentTimezone('America/New_York');
const d = dayjs('2024-01-01T00:00:00Z');
// 校验时区转换生效(小时变化)
expect(d.tz().format('HH')).not.toBe('00');
});
});
});

View File

@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { loadScript } from '../resources';
const testJsPath =
'https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js';
describe('loadScript', () => {
beforeEach(() => {
// 每个测试前清空 head保证环境干净
document.head.innerHTML = '';
});
it('should resolve when the script loads successfully', async () => {
const promise = loadScript(testJsPath);
// 此时脚本元素已被创建并插入
const script = document.querySelector(
`script[src="${testJsPath}"]`,
) as HTMLScriptElement;
expect(script).toBeTruthy();
// 模拟加载成功
script.dispatchEvent(new Event('load'));
// 等待 promise resolve
await expect(promise).resolves.toBeUndefined();
});
it('should not insert duplicate script and resolve immediately if already loaded', async () => {
// 先手动插入一个相同 src 的 script
const existing = document.createElement('script');
existing.src = 'bar.js';
document.head.append(existing);
// 再次调用
const promise = loadScript('bar.js');
// 立即 resolve
await expect(promise).resolves.toBeUndefined();
// head 中只保留一个
const scripts = document.head.querySelectorAll('script[src="bar.js"]');
expect(scripts).toHaveLength(1);
});
it('should reject when the script fails to load', async () => {
const promise = loadScript('error.js');
const script = document.querySelector(
'script[src="error.js"]',
) as HTMLScriptElement;
expect(script).toBeTruthy();
// 模拟加载失败
script.dispatchEvent(new Event('error'));
await expect(promise).rejects.toThrow('Failed to load script: error.js');
});
it('should handle multiple concurrent calls and only insert one script tag', async () => {
const p1 = loadScript(testJsPath);
const p2 = loadScript(testJsPath);
const script = document.querySelector(
`script[src="${testJsPath}"]`,
) as HTMLScriptElement;
expect(script).toBeTruthy();
// 触发一次 load两个 promise 都应该 resolve
script.dispatchEvent(new Event('load'));
await expect(p1).resolves.toBeUndefined();
await expect(p2).resolves.toBeUndefined();
// 只插入一次
const scripts = document.head.querySelectorAll(
`script[src="${testJsPath}"]`,
);
expect(scripts).toHaveLength(1);
});
});

View File

@@ -0,0 +1,99 @@
import dayjs from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
dayjs.extend(timezone);
type FormatDate = Date | dayjs.Dayjs | number | string;
type Format =
| 'HH'
| 'HH:mm'
| 'HH:mm:ss'
| 'YYYY'
| 'YYYY-MM'
| 'YYYY-MM-DD'
| 'YYYY-MM-DD HH'
| 'YYYY-MM-DD HH:mm'
| 'YYYY-MM-DD HH:mm:ss'
| (string & {});
export function formatDate(time?: FormatDate, format: Format = 'YYYY-MM-DD') {
// 日期不存在,则返回空
if (!time) {
return '';
}
try {
const date = dayjs.isDayjs(time) ? time : dayjs(time);
if (!date.isValid()) {
throw new Error('Invalid date');
}
return date.tz().format(format);
} catch (error) {
console.error(`Error formatting date: ${error}`);
return String(time ?? '');
}
}
export function formatDateTime(time?: FormatDate) {
return formatDate(time, 'YYYY-MM-DD HH:mm:ss');
}
export function formatDate2(date: Date, format?: string): string {
// 日期不存在,则返回空
if (!date) {
return '';
}
// 日期存在,则进行格式化
return date ? dayjs(date).format(format ?? 'YYYY-MM-DD HH:mm:ss') : '';
}
export function isDate(value: any): value is Date {
return value instanceof Date;
}
export function isDayjsObject(value: any): value is dayjs.Dayjs {
return dayjs.isDayjs(value);
}
/**
* element plus 的时间 Formatter 实现,使用 YYYY-MM-DD HH:mm:ss 格式
*
* @param _row
* @param _column
* @param cellValue 字段值
*/
export function dateFormatter(_row: any, _column: any, cellValue: any): string {
return cellValue ? formatDate(cellValue)?.toString() || '' : '';
}
/**
* 获取当前时区
* @returns 当前时区
*/
export const getSystemTimezone = () => {
return dayjs.tz.guess();
};
/**
* 自定义设置的时区
*/
let currentTimezone = getSystemTimezone();
/**
* 设置默认时区
* @param timezone
*/
export const setCurrentTimezone = (timezone?: string) => {
currentTimezone = timezone || getSystemTimezone();
dayjs.tz.setDefault(currentTimezone);
};
/**
* 获取设置的时区
* @returns 设置的时区
*/
export const getCurrentTimezone = () => {
return currentTimezone;
};

View File

@@ -0,0 +1,267 @@
import CryptoJS from 'crypto-js';
import { JSEncrypt } from 'jsencrypt';
/**
* API 加解密工具类
* 支持 AES 和 RSA 加密算法
*/
/**
* AES 加密工具类
*/
export const AES = {
/**
* AES 加密
* @param data 要加密的数据
* @param key 加密密钥
* @returns 加密后的字符串
*/
encrypt(data: string, key: string): string {
try {
if (!key) {
throw new Error('AES 加密密钥不能为空');
}
if (key.length !== 32 && key.length !== 16) {
throw new Error(
`AES 加密密钥长度必须为 32 位或 16 位,当前长度: ${key.length}`,
);
}
const keyUtf8 = CryptoJS.enc.Utf8.parse(key);
const encrypted = CryptoJS.AES.encrypt(data, keyUtf8, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.toString();
} catch (error) {
console.error('AES 加密失败:', error);
throw error;
}
},
/**
* AES 解密
* @param encryptedData 加密的数据
* @param key 解密密钥
* @returns 解密后的字符串
*/
decrypt(encryptedData: string, key: string): string {
try {
if (!key) {
throw new Error('AES 解密密钥不能为空');
}
if (key.length !== 32) {
throw new Error(
`AES 解密密钥长度必须为 32 位,当前长度: ${key.length}`,
);
}
if (!encryptedData) {
throw new Error('AES 解密数据不能为空');
}
const keyUtf8 = CryptoJS.enc.Utf8.parse(key);
const decrypted = CryptoJS.AES.decrypt(encryptedData, keyUtf8, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
const result = decrypted.toString(CryptoJS.enc.Utf8);
if (!result) {
throw new Error('AES 解密结果为空,可能是密钥错误或数据损坏');
}
return result;
} catch (error) {
console.error('AES 解密失败:', error);
throw error;
}
},
};
/**
* RSA 加密工具类
*/
export const RSA = {
/**
* RSA 加密
* @param data 要加密的数据
* @param publicKey 公钥(必需)
* @returns 加密后的字符串
*/
encrypt(data: string, publicKey: string): false | string {
try {
if (!publicKey) {
throw new Error('RSA 公钥不能为空');
}
const encryptor = new JSEncrypt();
encryptor.setPublicKey(publicKey);
const result = encryptor.encrypt(data);
if (result === false) {
throw new Error('RSA 加密失败,可能是公钥格式错误或数据过长');
}
return result;
} catch (error) {
console.error('RSA 加密失败:', error);
throw error;
}
},
/**
* RSA 解密
* @param encryptedData 加密的数据
* @param privateKey 私钥(必需)
* @returns 解密后的字符串
*/
decrypt(encryptedData: string, privateKey: string): false | string {
try {
if (!privateKey) {
throw new Error('RSA 私钥不能为空');
}
if (!encryptedData) {
throw new Error('RSA 解密数据不能为空');
}
const encryptor = new JSEncrypt();
encryptor.setPrivateKey(privateKey);
const result = encryptor.decrypt(encryptedData);
if (result === false) {
throw new Error('RSA 解密失败,可能是私钥错误或数据损坏');
}
return result;
} catch (error) {
console.error('RSA 解密失败:', error);
throw error;
}
},
};
/**
* API 加解密配置接口
*/
export interface ApiEncryptConfig {
/** 加密算法 */
algorithm: 'AES' | 'RSA';
/** 是否启用加解密 */
enable: boolean;
/** 加密头名称 */
header: string;
/** 请求加密密钥AES密钥或RSA公钥 */
requestKey: string;
/** 响应解密密钥AES密钥或RSA私钥 */
responseKey: string;
}
/**
* API 加解密主类
*/
export class ApiEncrypt {
private config: ApiEncryptConfig;
constructor(config: ApiEncryptConfig) {
this.config = config;
}
/**
* 解密响应数据
* @param encryptedData 加密的响应数据
* @returns 解密后的数据
*/
decryptResponse(encryptedData: string): any {
if (!this.config.enable) {
return encryptedData;
}
try {
let decryptedData: false | string = '';
if (this.config.algorithm.toUpperCase() === 'AES') {
if (!this.config.responseKey) {
throw new Error('AES 响应解密密钥未配置');
}
decryptedData = AES.decrypt(encryptedData, this.config.responseKey);
} else if (this.config.algorithm.toUpperCase() === 'RSA') {
if (!this.config.responseKey) {
throw new Error('RSA 私钥未配置');
}
decryptedData = RSA.decrypt(encryptedData, this.config.responseKey);
if (decryptedData === false) {
throw new Error('RSA 解密失败');
}
} else {
throw new Error(`不支持的解密算法: ${this.config.algorithm}`);
}
if (!decryptedData) {
throw new Error('解密结果为空');
}
// 尝试解析为 JSON如果失败则返回原字符串
try {
return JSON.parse(decryptedData);
} catch {
return decryptedData;
}
} catch (error) {
console.error('响应数据解密失败:', error);
throw error;
}
}
/**
* 加密请求数据
* @param data 要加密的数据
* @returns 加密后的数据
*/
encryptRequest(data: any): string {
if (!this.config.enable) {
return data;
}
try {
const jsonData = typeof data === 'string' ? data : JSON.stringify(data);
if (this.config.algorithm.toUpperCase() === 'AES') {
if (!this.config.requestKey) {
throw new Error('AES 请求加密密钥未配置');
}
return AES.encrypt(jsonData, this.config.requestKey);
} else if (this.config.algorithm.toUpperCase() === 'RSA') {
if (!this.config.requestKey) {
throw new Error('RSA 公钥未配置');
}
const result = RSA.encrypt(jsonData, this.config.requestKey);
if (result === false) {
throw new Error('RSA 加密失败');
}
return result;
} else {
throw new Error(`不支持的加密算法: ${this.config.algorithm}`);
}
} catch (error) {
console.error('请求数据加密失败:', error);
throw error;
}
}
/**
* 获取加密头名称
*/
getEncryptHeader(): string {
return this.config.header;
}
}
/**
* 创建基于环境变量的 API 加解密实例
* @param env 环境变量对象
* @returns ApiEncrypt 实例
*/
export function createApiEncrypt(env: Record<string, any>): ApiEncrypt {
const config: ApiEncryptConfig = {
enable: env.VITE_APP_API_ENCRYPT_ENABLE === 'true',
header: env.VITE_APP_API_ENCRYPT_HEADER || 'X-Api-Encrypt',
algorithm: env.VITE_APP_API_ENCRYPT_ALGORITHM || 'AES',
requestKey: env.VITE_APP_API_ENCRYPT_REQUEST_KEY || '',
responseKey: env.VITE_APP_API_ENCRYPT_RESPONSE_KEY || '',
};
return new ApiEncrypt(config);
}

View File

@@ -0,0 +1,199 @@
import { isEmpty, isString, isUndefined } from './inference';
/**
* 将一个整数转换为分数保留传入的小数
* @param num
* @param digit
*/
export function formatToFractionDigit(
num: number | string | undefined,
digit: number = 2,
): string {
if (isUndefined(num)) return '0.00';
const parsedNumber = isString(num) ? Number.parseFloat(num) : num;
return (parsedNumber / 100).toFixed(digit);
}
/**
* 将一个整数转换为分数保留两位小数
* @param num
*/
export function formatToFraction(num: number | string | undefined): string {
return formatToFractionDigit(num, 2);
}
/**
* 将一个数转换为 1.00 这样
* 数据呈现的时候使用
*
* @param num 整数
*/
export function floatToFixed2(num: number | string | undefined): string {
let str = '0.00';
if (isUndefined(num)) return str;
const f = formatToFraction(num);
const decimalPart = f.toString().split('.')[1];
const len = decimalPart ? decimalPart.length : 0;
switch (len) {
case 0: {
str = `${f.toString()}.00`;
break;
}
case 1: {
str = `${f.toString()}0`;
break;
}
case 2: {
str = f.toString();
break;
}
}
return str;
}
/**
* 将一个分数转换为整数
* @param num
*/
export function convertToInteger(num: number | string | undefined): number {
if (isUndefined(num)) return 0;
const parsedNumber = isString(num) ? Number.parseFloat(num) : num;
return Math.round(parsedNumber * 100);
}
/**
* 元转分
*/
export function yuanToFen(amount: number | string): number {
return convertToInteger(amount);
}
/**
* 分转元
*/
export function fenToYuan(price: number | string): string {
return formatToFraction(price);
}
// 格式化金额【分转元】
export const fenToYuanFormat = (_: any, __: any, cellValue: any, ___: any) => {
return `${floatToFixed2(cellValue)}`;
};
/**
* 计算环比
*
* @param value 当前数值
* @param reference 对比数值
*/
export function calculateRelativeRate(
value?: number,
reference?: number,
): number {
// 防止除0
if (!reference || reference === 0) return 0;
return Number.parseFloat(
((100 * ((value || 0) - reference)) / reference).toFixed(0),
);
}
// ========== ERP 专属方法 ==========
const ERP_COUNT_DIGIT = 3;
const ERP_PRICE_DIGIT = 2;
/**
* 【ERP】格式化 Input 数字
*
* 例如说:库存数量
*
* @param num 数量
* @package
* @return 格式化后的数量
*/
export function erpNumberFormatter(
num: number | string | undefined,
digit: number,
) {
if (num === null || num === undefined) {
return '';
}
if (typeof num === 'string') {
num = Number.parseFloat(num);
}
// 如果非 number则直接返回空串
if (Number.isNaN(num)) {
return '';
}
return num.toFixed(digit);
}
/**
* 【ERP】格式化数量保留三位小数
*
* 例如说:库存数量
*
* @param num 数量
* @return 格式化后的数量
*/
export function erpCountInputFormatter(num: number | string | undefined) {
return erpNumberFormatter(num, ERP_COUNT_DIGIT);
}
/**
* 【ERP】格式化数量保留三位小数
*
* @param cellValue 数量
* @return 格式化后的数量
*/
export function erpCountTableColumnFormatter(cellValue: any) {
return erpNumberFormatter(cellValue, ERP_COUNT_DIGIT);
}
/**
* 【ERP】格式化金额保留二位小数
*
* 例如说:库存数量
*
* @param num 数量
* @return 格式化后的数量
*/
export function erpPriceInputFormatter(num: number | string | undefined) {
return erpNumberFormatter(num, ERP_PRICE_DIGIT);
}
/**
* 【ERP】格式化金额保留二位小数
*
* @param cellValue 数量
* @return 格式化后的数量
*/
export function erpPriceTableColumnFormatter(cellValue: any) {
return erpNumberFormatter(cellValue, ERP_PRICE_DIGIT);
}
/**
* 【ERP】价格计算四舍五入保留两位小数
*
* @param price 价格
* @param count 数量
* @return 总价格。如果有任一为空,则返回 undefined
*/
export function erpPriceMultiply(price: number, count: number) {
if (isEmpty(price) || isEmpty(count)) return undefined;
return Number.parseFloat((price * count).toFixed(ERP_PRICE_DIGIT));
}
/**
* 【ERP】百分比计算四舍五入保留两位小数
*
* 如果 total 为 0则返回 0
*
* @param value 当前值
* @param total 总值
*/
export function erpCalculatePercentage(value: number, total: number) {
if (total === 0) return 0;
return ((value / total) * 100).toFixed(2);
}

View File

@@ -0,0 +1,25 @@
export * from './cn';
export * from './date';
export * from './diff';
export * from './dom';
export * from './download';
export * from './encrypt';
export * from './formatNumber';
export * from './inference';
export * from './letter';
export * from './merge';
export * from './nprogress';
export * from './resources';
export * from './state-handler';
export * from './time';
export * from './to';
export * from './tree';
export * from './unique';
export * from './update-css-variables';
export * from './upload';
export * from './util';
export * from './uuid'; // add by 芋艿:从 vben2.0 复制
export * from './window';
export { get, isEqual, set } from 'es-toolkit/compat';
// export { cloneDeep } from 'es-toolkit/object';
export { default as cloneDeep } from 'lodash.clonedeep';

View File

@@ -0,0 +1,21 @@
/**
* 加载js文件
* @param src js文件地址
*/
function loadScript(src: string) {
return new Promise<void>((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) {
// 如果已经加载过,直接 resolve
return resolve();
}
const script = document.createElement('script');
script.src = src;
script.addEventListener('load', () => resolve());
script.addEventListener('error', () =>
reject(new Error(`Failed to load script: ${src}`)),
);
document.head.append(script);
});
}
export { loadScript };

View File

@@ -0,0 +1,224 @@
/**
* 根据支持的文件类型生成 accept 属性值
*
* @param supportedFileTypes 支持的文件类型数组,如 ['PDF', 'DOC', 'DOCX']
* @returns 用于文件上传组件 accept 属性的字符串
*/
export function generateAcceptedFileTypes(
supportedFileTypes: string[],
): string {
const allowedExtensions = supportedFileTypes.map((ext) => ext.toLowerCase());
const mimeTypes: string[] = [];
// 添加常见的 MIME 类型映射
if (allowedExtensions.includes('txt')) {
mimeTypes.push('text/plain');
}
if (allowedExtensions.includes('pdf')) {
mimeTypes.push('application/pdf');
}
if (allowedExtensions.includes('html') || allowedExtensions.includes('htm')) {
mimeTypes.push('text/html');
}
if (allowedExtensions.includes('csv')) {
mimeTypes.push('text/csv');
}
if (allowedExtensions.includes('xlsx') || allowedExtensions.includes('xls')) {
mimeTypes.push(
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
}
if (allowedExtensions.includes('docx') || allowedExtensions.includes('doc')) {
mimeTypes.push(
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
);
}
if (allowedExtensions.includes('pptx') || allowedExtensions.includes('ppt')) {
mimeTypes.push(
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
);
}
if (allowedExtensions.includes('xml')) {
mimeTypes.push('application/xml', 'text/xml');
}
if (
allowedExtensions.includes('md') ||
allowedExtensions.includes('markdown')
) {
mimeTypes.push('text/markdown');
}
if (allowedExtensions.includes('epub')) {
mimeTypes.push('application/epub+zip');
}
if (allowedExtensions.includes('eml')) {
mimeTypes.push('message/rfc822');
}
if (allowedExtensions.includes('msg')) {
mimeTypes.push('application/vnd.ms-outlook');
}
// 添加文件扩展名
const extensions = allowedExtensions.map((ext) => `.${ext}`);
return [...mimeTypes, ...extensions].join(',');
}
/**
* 从 URL 中提取文件名
*
* @param url 文件 URL
* @returns 文件名,如果无法提取则返回 'unknown'
*/
export function getFileNameFromUrl(url: null | string | undefined): string {
// 处理空值
if (!url) {
return 'unknown';
}
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const fileName = pathname.split('/').pop() || 'unknown';
return decodeURIComponent(fileName);
} catch {
// 如果 URL 解析失败,尝试从字符串中提取
const parts = url.split('/');
return parts[parts.length - 1] || 'unknown';
}
}
/**
* 默认图片类型
*/
export const defaultImageAccepts = [
'bmp',
'gif',
'jpeg',
'jpg',
'png',
'svg',
'webp',
];
/**
* 判断文件是否为图片
*
* @param filename 文件名
* @param accepts 支持的文件类型
* @returns 是否为图片
*/
export function isImage(
filename: null | string | undefined,
accepts: string[] = defaultImageAccepts,
): boolean {
if (!filename || accepts.length === 0) {
return false;
}
const ext = filename.split('.').pop()?.toLowerCase() || '';
return accepts.includes(ext);
}
/**
* 判断文件是否为指定类型
*
* @param file 文件
* @param accepts 支持的文件类型
* @returns 是否为指定类型
*/
export function checkFileType(file: File, accepts: string[]) {
if (!accepts || accepts.length === 0) {
return true;
}
const newTypes = accepts.join('|');
const reg = new RegExp(`${String.raw`\.(` + newTypes})$`, 'i');
return reg.test(file.name);
}
/**
* 格式化文件大小
*
* @param bytes 文件大小(字节)
* @returns 格式化后的文件大小字符串
*/
export function formatFileSize(bytes: number, digits = 2): string {
if (bytes === 0) {
return '0 B';
}
const k = 1024;
const unitArr = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const index = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** index).toFixed(digits))} ${unitArr[index]}`;
}
/**
* 获取文件图标Lucide Icons
*
* @param filename 文件名
* @returns Lucide 图标名称
*/
export function getFileIcon(filename: null | string | undefined): string {
if (!filename) {
return 'lucide:file';
}
const ext = filename.split('.').pop()?.toLowerCase() || '';
if (isImage(ext)) {
return 'lucide:image';
}
if (['pdf'].includes(ext)) {
return 'lucide:file-text';
}
if (['doc', 'docx'].includes(ext)) {
return 'lucide:file-text';
}
if (['xls', 'xlsx'].includes(ext)) {
return 'lucide:file-spreadsheet';
}
if (['ppt', 'pptx'].includes(ext)) {
return 'lucide:presentation';
}
if (['aac', 'm4a', 'mp3', 'wav'].includes(ext)) {
return 'lucide:music';
}
if (['avi', 'mov', 'mp4', 'wmv'].includes(ext)) {
return 'lucide:video';
}
return 'lucide:file';
}
/**
* 获取文件类型样式类Tailwind CSS 渐变色)
*
* @param filename 文件名
* @returns Tailwind CSS 渐变类名
*/
export function getFileTypeClass(filename: null | string | undefined): string {
if (!filename) {
return 'from-gray-500 to-gray-700';
}
const ext = filename.split('.').pop()?.toLowerCase() || '';
if (isImage(ext)) {
return 'from-yellow-400 to-orange-500';
}
if (['pdf'].includes(ext)) {
return 'from-red-500 to-red-700';
}
if (['doc', 'docx'].includes(ext)) {
return 'from-blue-600 to-blue-800';
}
if (['xls', 'xlsx'].includes(ext)) {
return 'from-green-600 to-green-800';
}
if (['ppt', 'pptx'].includes(ext)) {
return 'from-orange-600 to-orange-800';
}
if (['aac', 'm4a', 'mp3', 'wav'].includes(ext)) {
return 'from-purple-500 to-purple-700';
}
if (['avi', 'mov', 'mp4', 'wmv'].includes(ext)) {
return 'from-red-500 to-red-700';
}
return 'from-gray-500 to-gray-700';
}

View File

@@ -0,0 +1,116 @@
export function bindMethods<T extends object>(instance: T): void {
const prototype = Object.getPrototypeOf(instance);
const propertyNames = Object.getOwnPropertyNames(prototype);
propertyNames.forEach((propertyName) => {
const descriptor = Object.getOwnPropertyDescriptor(prototype, propertyName);
const propertyValue = instance[propertyName as keyof T];
if (
typeof propertyValue === 'function' &&
propertyName !== 'constructor' &&
descriptor &&
!descriptor.get &&
!descriptor.set
) {
instance[propertyName as keyof T] = propertyValue.bind(instance);
}
});
}
/**
* 获取嵌套对象的字段值
* @param obj - 要查找的对象
* @param path - 用于查找字段的路径,使用小数点分隔
* @returns 字段值,或者未找到时返回 undefined
*/
export function getNestedValue<T>(obj: T, path: string): any {
if (typeof path !== 'string' || path.length === 0) {
throw new Error('Path must be a non-empty string');
}
// 把路径字符串按 "." 分割成数组
const keys = path.split('.') as (number | string)[];
let current: any = obj;
for (const key of keys) {
if (current === null || current === undefined) {
return undefined;
}
current = current[key as keyof typeof current];
}
return current;
}
/**
* 获取链接的参数值(值类型)
* @param key 参数键名
* @param urlStr 链接地址,默认为当前浏览器的地址
*/
export function getUrlNumberValue(
key: string,
urlStr: string = location.href,
): number {
return Number(getUrlValue(key, urlStr));
}
/**
* 获取链接的参数值
* @param key 参数键名
* @param urlStr 链接地址,默认为当前浏览器的地址
*/
export function getUrlValue(
key: string,
urlStr: string = location.href,
): string {
if (!urlStr || !key) return '';
const url = new URL(decodeURIComponent(urlStr));
return url.searchParams.get(key) ?? '';
}
/**
* 将值复制到目标对象且以目标对象属性为准target: {a:1} source:{a:2,b:3} 结果为:{a:2}
* @param target 目标对象
* @param source 源对象
*/
export function copyValueToTarget(target: any, source: any) {
const newObj = Object.assign({}, target, source);
// 删除多余属性
Object.keys(newObj).forEach((key) => {
// 如果不是target中的属性则删除
if (!Object.keys(target).includes(key)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete newObj[key];
}
});
// 更新目标对象值
Object.assign(target, newObj);
}
/** 实现 groupBy 功能 */
export function groupBy(array: any[], key: string) {
const result: Record<string, any[]> = {};
for (const item of array) {
const groupKey = item[key];
if (!result[groupKey]) {
result[groupKey] = [];
}
result[groupKey].push(item);
}
return result;
}
/**
* 解析 JSON 字符串
*
* @param str
*/
export function jsonParse(str: string) {
try {
return JSON.parse(str);
} catch {
console.warn(`str[${str}] 不是一个 JSON 字符串`);
return str;
}
}

View File

@@ -0,0 +1,37 @@
interface OpenWindowOptions {
noopener?: boolean;
noreferrer?: boolean;
target?: '_blank' | '_parent' | '_self' | '_top' | string;
}
/**
* 新窗口打开URL。
*
* @param url - 需要打开的网址。
* @param options - 打开窗口的选项。
*/
function openWindow(url: string, options: OpenWindowOptions = {}): void {
// 解构并设置默认值
const { noopener = true, noreferrer = true, target = '_blank' } = options;
// 基于选项创建特性字符串
const features = [noopener && 'noopener=yes', noreferrer && 'noreferrer=yes']
.filter(Boolean)
.join(',');
// 打开窗口
window.open(url, target, features);
}
/**
* 在新窗口中打开路由。
* @param path
*/
function openRouteInNewWindow(path: string) {
const { hash, origin } = location;
const fullPath = path.startsWith('/') ? path : `/${path}`;
const url = `${origin}${hash && !fullPath.startsWith('/#') ? '/#' : ''}${fullPath}`;
openWindow(url, { target: '_blank' });
}
export { openRouteInNewWindow, openWindow };