Initial commit: OneOS frontend based on yudao-ui-admin-vben
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled

This commit is contained in:
k kfluous
2026-03-11 22:18:23 +08:00
commit 2650d1cdf0
5166 changed files with 641242 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { StorageManager } from '../storage-manager';
describe('storageManager', () => {
let storageManager: StorageManager;
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
storageManager = new StorageManager({
prefix: 'test_',
});
});
it('should set and get an item', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
const user = storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should return default value if item does not exist', () => {
const user = storageManager.getItem('nonexistent', {
age: 0,
name: 'Default User',
});
expect(user).toEqual({ age: 0, name: 'Default User' });
});
it('should remove an item', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
storageManager.removeItem('user');
const user = storageManager.getItem('user');
expect(user).toBeNull();
});
it('should clear all items with the prefix', () => {
storageManager.setItem('user1', { age: 30, name: 'John Doe' });
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
storageManager.clear();
expect(storageManager.getItem('user1')).toBeNull();
expect(storageManager.getItem('user2')).toBeNull();
});
it('should clear expired items', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
vi.advanceTimersByTime(1001); // 快进时间
storageManager.clearExpiredItems();
const user = storageManager.getItem('user');
expect(user).toBeNull();
});
it('should not clear non-expired items', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
vi.advanceTimersByTime(5000); // 快进时间
storageManager.clearExpiredItems();
const user = storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should handle JSON parse errors gracefully', () => {
localStorage.setItem('test_user', '{ invalid JSON }');
const user = storageManager.getItem('user', {
age: 0,
name: 'Default User',
});
expect(user).toEqual({ age: 0, name: 'Default User' });
});
it('should return null for non-existent items without default value', () => {
const user = storageManager.getItem('nonexistent');
expect(user).toBeNull();
});
it('should overwrite existing items', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
storageManager.setItem('user', { age: 25, name: 'Jane Doe' });
const user = storageManager.getItem('user');
expect(user).toEqual({ age: 25, name: 'Jane Doe' });
});
it('should handle items without expiry correctly', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
vi.advanceTimersByTime(5000);
const user = storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should remove expired items when accessed', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
vi.advanceTimersByTime(1001); // 快进时间
const user = storageManager.getItem('user');
expect(user).toBeNull();
});
it('should not remove non-expired items when accessed', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' }, 10_000); // 10秒过期
vi.advanceTimersByTime(5000); // 快进时间
const user = storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should handle multiple items with different expiry times', () => {
storageManager.setItem('user1', { age: 30, name: 'John Doe' }, 1000); // 1秒过期
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' }, 2000); // 2秒过期
vi.advanceTimersByTime(1500); // 快进时间
storageManager.clearExpiredItems();
const user1 = storageManager.getItem('user1');
const user2 = storageManager.getItem('user2');
expect(user1).toBeNull();
expect(user2).toEqual({ age: 25, name: 'Jane Doe' });
});
it('should handle items with no expiry', () => {
storageManager.setItem('user', { age: 30, name: 'John Doe' });
vi.advanceTimersByTime(10_000); // 快进时间
storageManager.clearExpiredItems();
const user = storageManager.getItem('user');
expect(user).toEqual({ age: 30, name: 'John Doe' });
});
it('should clear all items correctly', () => {
storageManager.setItem('user1', { age: 30, name: 'John Doe' });
storageManager.setItem('user2', { age: 25, name: 'Jane Doe' });
storageManager.clear();
const user1 = storageManager.getItem('user1');
const user2 = storageManager.getItem('user2');
expect(user1).toBeNull();
expect(user2).toBeNull();
});
});

View File

@@ -0,0 +1 @@
export * from './storage-manager';

View File

@@ -0,0 +1,118 @@
type StorageType = 'localStorage' | 'sessionStorage';
interface StorageManagerOptions {
prefix?: string;
storageType?: StorageType;
}
interface StorageItem<T> {
expiry?: number;
value: T;
}
class StorageManager {
private prefix: string;
private storage: Storage;
constructor({
prefix = '',
storageType = 'localStorage',
}: StorageManagerOptions = {}) {
this.prefix = prefix;
this.storage =
storageType === 'localStorage'
? window.localStorage
: window.sessionStorage;
}
/**
* 清除所有带前缀的存储项
*/
clear(): void {
const keysToRemove: string[] = [];
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key && key.startsWith(this.prefix)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach((key) => this.storage.removeItem(key));
}
/**
* 清除所有过期的存储项
*/
clearExpiredItems(): void {
for (let i = 0; i < this.storage.length; i++) {
const key = this.storage.key(i);
if (key && key.startsWith(this.prefix)) {
const shortKey = key.replace(this.prefix, '');
this.getItem(shortKey); // 调用 getItem 方法检查并移除过期项
}
}
}
/**
* 获取存储项
* @param key 键
* @param defaultValue 当项不存在或已过期时返回的默认值
* @returns 值,如果项已过期或解析错误则返回默认值
*/
getItem<T>(key: string, defaultValue: null | T = null): null | T {
const fullKey = this.getFullKey(key);
const itemStr = this.storage.getItem(fullKey);
if (!itemStr) {
return defaultValue;
}
try {
const item: StorageItem<T> = JSON.parse(itemStr);
if (item.expiry && Date.now() > item.expiry) {
this.storage.removeItem(fullKey);
return defaultValue;
}
return item.value;
} catch (error) {
console.error(`Error parsing item with key "${fullKey}":`, error);
this.storage.removeItem(fullKey); // 如果解析失败,删除该项
return defaultValue;
}
}
/**
* 移除存储项
* @param key 键
*/
removeItem(key: string): void {
const fullKey = this.getFullKey(key);
this.storage.removeItem(fullKey);
}
/**
* 设置存储项
* @param key 键
* @param value 值
* @param ttl 存活时间(毫秒)
*/
setItem<T>(key: string, value: T, ttl?: number): void {
const fullKey = this.getFullKey(key);
const expiry = ttl ? Date.now() + ttl : undefined;
const item: StorageItem<T> = { expiry, value };
try {
this.storage.setItem(fullKey, JSON.stringify(item));
} catch (error) {
console.error(`Error setting item with key "${fullKey}":`, error);
}
}
/**
* 获取完整的存储键
* @param key 原始键
* @returns 带前缀的完整键
*/
private getFullKey(key: string): string {
return `${this.prefix}-${key}`;
}
}
export { StorageManager };

View File

@@ -0,0 +1,17 @@
type StorageType = 'localStorage' | 'sessionStorage';
interface StorageValue<T> {
data: T;
expiry: null | number;
}
interface IStorageCache {
clear(): void;
getItem<T>(key: string): null | T;
key(index: number): null | string;
length(): number;
removeItem(key: string): void;
setItem<T>(key: string, value: T, expiryInMinutes?: number): void;
}
export type { IStorageCache, StorageType, StorageValue };

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import {
convertToHsl,
convertToHslCssVar,
convertToRgb,
isValidColor,
} from '../convert';
describe('color conversion functions', () => {
it('should correctly convert color to HSL format', () => {
const color = '#ff0000';
const expectedHsl = 'hsl(0 100% 50%)';
expect(convertToHsl(color)).toEqual(expectedHsl);
});
it('should correctly convert color with alpha to HSL format', () => {
const color = 'rgba(255, 0, 0, 0.5)';
const expectedHsl = 'hsl(0 100% 50%) 0.5';
expect(convertToHsl(color)).toEqual(expectedHsl);
});
it('should correctly convert color to HSL CSS variable format', () => {
const color = '#ff0000';
const expectedHsl = '0 100% 50%';
expect(convertToHslCssVar(color)).toEqual(expectedHsl);
});
it('should correctly convert color with alpha to HSL CSS variable format', () => {
const color = 'rgba(255, 0, 0, 0.5)';
const expectedHsl = '0 100% 50% / 0.5';
expect(convertToHslCssVar(color)).toEqual(expectedHsl);
});
it('should correctly convert color to RGB CSS variable format', () => {
const color = 'hsl(284, 100%, 50%)';
const expectedRgb = 'rgb(187, 0, 255)';
expect(convertToRgb(color)).toEqual(expectedRgb);
});
it('should correctly convert color with alpha to RGBA CSS variable format', () => {
const color = 'hsla(284, 100%, 50%, 0.92)';
const expectedRgba = 'rgba(187, 0, 255, 0.92)';
expect(convertToRgb(color)).toEqual(expectedRgba);
});
});
describe('isValidColor', () => {
it('isValidColor function', () => {
// 测试有效颜色
expect(isValidColor('blue')).toBe(true);
expect(isValidColor('#000000')).toBe(true);
// 测试无效颜色
expect(isValidColor('invalid color')).toBe(false);
expect(isValidColor()).toBe(false);
});
});

View File

@@ -0,0 +1,9 @@
import { TinyColor } from '@ctrl/tinycolor';
export function isDarkColor(color: string) {
return new TinyColor(color).isDark();
}
export function isLightColor(color: string) {
return new TinyColor(color).isLight();
}

View File

@@ -0,0 +1,62 @@
import { TinyColor } from '@ctrl/tinycolor';
/**
* 将颜色转换为HSL格式。
*
* HSL是一种颜色模型包括色相(Hue)、饱和度(Saturation)和亮度(Lightness)三个部分。
*
* @param {string} color 输入的颜色。
* @returns {string} HSL格式的颜色字符串。
*/
function convertToHsl(color: string): string {
const { a, h, l, s } = new TinyColor(color).toHsl();
const hsl = `hsl(${Math.round(h)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%)`;
return a < 1 ? `${hsl} ${a}` : hsl;
}
/**
* 将颜色转换为HSL CSS变量。
*
* 这个函数与convertToHsl函数类似但是返回的字符串格式稍有不同
* 以便可以作为CSS变量使用。
*
* @param {string} color 输入的颜色。
* @returns {string} 可以作为CSS变量使用的HSL格式的颜色字符串。
*/
function convertToHslCssVar(color: string): string {
const { a, h, l, s } = new TinyColor(color).toHsl();
const hsl = `${Math.round(h)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
return a < 1 ? `${hsl} / ${a}` : hsl;
}
/**
* 将颜色转换为RGB颜色字符串
* TinyColor无法处理hsl内包含'deg'、'grad'、'rad'或'turn'的字符串
* 比如 hsl(231deg 98% 65%)将被解析为rgb(0, 0, 0)
* 这里在转换之前先将这些单位去掉
* @param str 表示HLS颜色值的字符串
* @returns 如果颜色值有效则返回对应的RGB颜色字符串如果无效则返回rgb(0, 0, 0)
*/
function convertToRgb(str: string): string {
return new TinyColor(str.replaceAll(/deg|grad|rad|turn/g, '')).toRgbString();
}
/**
* 检查颜色是否有效
* @param {string} color - 待检查的颜色
* 如果颜色有效返回true否则返回false
*/
function isValidColor(color?: string) {
if (!color) {
return false;
}
return new TinyColor(color).isValid;
}
export {
convertToHsl,
convertToHslCssVar,
convertToRgb,
isValidColor,
TinyColor,
};

View File

@@ -0,0 +1,45 @@
import { getColors } from 'theme-colors';
import { convertToHslCssVar, TinyColor } from './convert';
interface ColorItem {
alias?: string;
color: string;
name: string;
}
function generatorColorVariables(colorItems: ColorItem[]) {
const colorVariables: Record<string, string> = {};
colorItems.forEach(({ alias, color, name }) => {
if (color) {
const colorsMap = getColors(new TinyColor(color).toHexString());
let mainColor = colorsMap['500'];
const colorKeys = Object.keys(colorsMap);
colorKeys.forEach((key) => {
const colorValue = colorsMap[key];
if (colorValue) {
const hslColor = convertToHslCssVar(colorValue);
colorVariables[`--${name}-${key}`] = hslColor;
if (alias) {
colorVariables[`--${alias}-${key}`] = hslColor;
}
if (key === '500') {
mainColor = hslColor;
}
}
});
if (alias && mainColor) {
colorVariables[`--${alias}`] = mainColor;
}
}
});
return colorVariables;
}
export { generatorColorVariables };

View File

@@ -0,0 +1,3 @@
export * from './color';
export * from './convert';
export * from './generator';

View File

@@ -0,0 +1,16 @@
/** layout content 组件的高度 */
export const CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT = `--vben-content-height`;
/** layout content 组件的宽度 */
export const CSS_VARIABLE_LAYOUT_CONTENT_WIDTH = `--vben-content-width`;
/** layout header 组件的高度 */
export const CSS_VARIABLE_LAYOUT_HEADER_HEIGHT = `--vben-header-height`;
/** layout footer 组件的高度 */
export const CSS_VARIABLE_LAYOUT_FOOTER_HEIGHT = `--vben-footer-height`;
/** 内容区域的组件ID */
export const ELEMENT_ID_MAIN_CONTENT = `__vben_main_content`;
/**
* @zh_CN 默认命名空间
*/
export const DEFAULT_NAMESPACE = 'vben';

View File

@@ -0,0 +1,2 @@
export * from './globals';
export * from './vben';

View File

@@ -0,0 +1,33 @@
/**
* @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_ANTDV_NEXT_PREVIEW_URL = 'https://antdv-next.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,45 @@
/**
* 全局复用的变量、组件、配置,各个模块之间共享
* 通过单例模式实现,单例必须注意不受请求影响例如用户信息这些需要根据请求获取的。后续如果有ssr需求也不会影响
*/
interface ComponentsState {
[key: string]: any;
}
interface MessageState {
copyPreferencesSuccess?: (title: string, content?: string) => void;
}
export interface IGlobalSharedState {
components: ComponentsState;
message: MessageState;
}
class GlobalShareState {
#components: ComponentsState = {};
#message: MessageState = {};
/**
* 定义框架内部各个场景的消息提示
*/
public defineMessage({ copyPreferencesSuccess }: MessageState) {
this.#message = {
copyPreferencesSuccess,
};
}
public getComponents(): ComponentsState {
return this.#components;
}
public getMessage(): MessageState {
return this.#message;
}
public setComponents(value: ComponentsState) {
this.#components = value;
}
}
export const globalShareState = new GlobalShareState();

View File

@@ -0,0 +1 @@
export * from '@tanstack/vue-store';

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,53 @@
import { describe, expect, it } from 'vitest';
import { diff } from '../diff';
describe('diff function', () => {
it('should return an empty object when comparing identical objects', () => {
const obj1 = { a: 1, b: { c: 2 } };
const obj2 = { a: 1, b: { c: 2 } };
expect(diff(obj1, obj2)).toEqual(undefined);
});
it('should detect simple changes in primitive values', () => {
const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1, b: 3 };
expect(diff(obj1, obj2)).toEqual({ b: 3 });
});
it('should detect nested object changes', () => {
const obj1 = { a: 1, b: { c: 2, d: 4 } };
const obj2 = { a: 1, b: { c: 3, d: 4 } };
expect(diff(obj1, obj2)).toEqual({ b: { c: 3 } });
});
it('should handle array changes', () => {
const obj1 = { a: [1, 2, 3], b: 2 };
const obj2 = { a: [1, 2, 4], b: 2 };
expect(diff(obj1, obj2)).toEqual({ a: [1, 2, 4] });
});
it('should handle added keys', () => {
const obj1 = { a: 1 };
const obj2 = { a: 1, b: 2 };
expect(diff(obj1, obj2)).toEqual({ b: 2 });
});
it('should handle removed keys', () => {
const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1 };
expect(diff(obj1, obj2)).toEqual(undefined);
});
it('should handle boolean value changes', () => {
const obj1 = { a: true, b: false };
const obj2 = { a: true, b: true };
expect(diff(obj1, obj2)).toEqual({ b: true });
});
it('should handle null and undefined values', () => {
const obj1 = { a: null, b: undefined };
const obj2: any = { a: 1, b: undefined };
expect(diff(obj1, obj2)).toEqual({ a: 1 });
});
});

View File

@@ -0,0 +1,127 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getElementVisibleRect } from '../dom';
describe('getElementVisibleRect', () => {
// 设置浏览器视口尺寸的 mock
beforeEach(() => {
vi.spyOn(document.documentElement, 'clientHeight', 'get').mockReturnValue(
800,
);
vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(800);
vi.spyOn(document.documentElement, 'clientWidth', 'get').mockReturnValue(
1000,
);
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000);
});
it('should return default rect if element is undefined', () => {
expect(getElementVisibleRect()).toEqual({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
});
});
it('should return default rect if element is null', () => {
expect(getElementVisibleRect(null)).toEqual({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
});
});
it('should return correct visible rect when element is fully visible', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 400,
height: 300,
left: 200,
right: 600,
top: 100,
width: 400,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 400,
height: 300,
left: 200,
right: 600,
top: 100,
width: 400,
});
});
it('should return correct visible rect when element is partially off-screen at the top', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 200,
height: 250,
left: 100,
right: 500,
top: -50,
width: 400,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 200,
height: 200,
left: 100,
right: 500,
top: 0,
width: 400,
});
});
it('should return correct visible rect when element is partially off-screen at the right', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 400,
height: 300,
left: 800,
right: 1200,
top: 100,
width: 400,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 400,
height: 300,
left: 800,
right: 1000,
top: 100,
width: 200,
});
});
it('should return all zeros when element is completely off-screen', () => {
const element = {
getBoundingClientRect: () => ({
bottom: 1200,
height: 300,
left: 1100,
right: 1400,
top: 900,
width: 300,
}),
} as HTMLElement;
expect(getElementVisibleRect(element)).toEqual({
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
});
});
});

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest';
import {
getFirstNonNullOrUndefined,
isBoolean,
isEmpty,
isHttpUrl,
isObject,
isUndefined,
isWindow,
} from '../inference';
describe('isHttpUrl', () => {
it("should return true when given 'http://example.com'", () => {
expect(isHttpUrl('http://example.com')).toBe(true);
});
it("should return true when given 'https://example.com'", () => {
expect(isHttpUrl('https://example.com')).toBe(true);
});
it("should return false when given 'ftp://example.com'", () => {
expect(isHttpUrl('ftp://example.com')).toBe(false);
});
it("should return false when given 'example.com'", () => {
expect(isHttpUrl('example.com')).toBe(false);
});
});
describe('isUndefined', () => {
it('isUndefined should return true for undefined values', () => {
expect(isUndefined()).toBe(true);
});
it('isUndefined should return false for null values', () => {
expect(isUndefined(null)).toBe(false);
});
it('isUndefined should return false for defined values', () => {
expect(isUndefined(0)).toBe(false);
expect(isUndefined('')).toBe(false);
expect(isUndefined(false)).toBe(false);
});
it('isUndefined should return false for objects and arrays', () => {
expect(isUndefined({})).toBe(false);
expect(isUndefined([])).toBe(false);
});
});
describe('isEmpty', () => {
it('should return true for empty string', () => {
expect(isEmpty('')).toBe(true);
});
it('should return true for empty array', () => {
expect(isEmpty([])).toBe(true);
});
it('should return true for empty object', () => {
expect(isEmpty({})).toBe(true);
});
it('should return false for non-empty string', () => {
expect(isEmpty('hello')).toBe(false);
});
it('should return false for non-empty array', () => {
expect(isEmpty([1, 2, 3])).toBe(false);
});
it('should return false for non-empty object', () => {
expect(isEmpty({ a: 1 })).toBe(false);
});
it('should return true for null or undefined', () => {
expect(isEmpty(null)).toBe(true);
expect(isEmpty()).toBe(true);
});
it('should return false for number or boolean', () => {
expect(isEmpty(0)).toBe(false);
expect(isEmpty(true)).toBe(false);
});
});
describe('isWindow', () => {
it('should return true for the window object', () => {
expect(isWindow(window)).toBe(true);
});
it('should return false for other objects', () => {
expect(isWindow({})).toBe(false);
expect(isWindow([])).toBe(false);
expect(isWindow(null)).toBe(false);
});
});
describe('isBoolean', () => {
it('should return true for boolean values', () => {
expect(isBoolean(true)).toBe(true);
expect(isBoolean(false)).toBe(true);
});
it('should return false for non-boolean values', () => {
expect(isBoolean(null)).toBe(false);
expect(isBoolean(42)).toBe(false);
expect(isBoolean('string')).toBe(false);
expect(isBoolean({})).toBe(false);
expect(isBoolean([])).toBe(false);
});
});
describe('isObject', () => {
it('should return true for objects', () => {
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
});
it('should return false for non-objects', () => {
expect(isObject(null)).toBe(false);
expect(isObject(42)).toBe(false);
expect(isObject('string')).toBe(false);
expect(isObject(true)).toBe(false);
expect(isObject([1, 2, 3])).toBe(true);
expect(isObject(new Date())).toBe(true);
expect(isObject(/regex/)).toBe(true);
});
});
describe('getFirstNonNullOrUndefined', () => {
describe('getFirstNonNullOrUndefined', () => {
it('should return the first non-null and non-undefined value for a number array', () => {
expect(getFirstNonNullOrUndefined<number>(undefined, null, 0, 42)).toBe(
0,
);
expect(getFirstNonNullOrUndefined<number>(null, undefined, 42, 123)).toBe(
42,
);
});
it('should return the first non-null and non-undefined value for a string array', () => {
expect(
getFirstNonNullOrUndefined<string>(undefined, null, '', 'hello'),
).toBe('');
expect(
getFirstNonNullOrUndefined<string>(null, undefined, 'test', 'world'),
).toBe('test');
});
it('should return undefined if all values are null or undefined', () => {
expect(getFirstNonNullOrUndefined(undefined, null)).toBeUndefined();
expect(getFirstNonNullOrUndefined(null)).toBeUndefined();
});
it('should work with a single value', () => {
expect(getFirstNonNullOrUndefined(42)).toBe(42);
expect(getFirstNonNullOrUndefined()).toBeUndefined();
expect(getFirstNonNullOrUndefined(null)).toBeUndefined();
});
it('should handle mixed types correctly', () => {
expect(
getFirstNonNullOrUndefined<number | object | string>(
undefined,
null,
'test',
123,
{ key: 'value' },
),
).toBe('test');
expect(
getFirstNonNullOrUndefined<number | object | string>(
null,
undefined,
[1, 2, 3],
'string',
),
).toEqual([1, 2, 3]);
});
});
});

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import {
capitalizeFirstLetter,
kebabToCamelCase,
toCamelCase,
toLowerCaseFirstLetter,
} from '../letter';
describe('capitalizeFirstLetter', () => {
it('should capitalize the first letter of a string', () => {
expect(capitalizeFirstLetter('hello')).toBe('Hello');
expect(capitalizeFirstLetter('world')).toBe('World');
});
it('should handle empty strings', () => {
expect(capitalizeFirstLetter('')).toBe('');
});
it('should handle single character strings', () => {
expect(capitalizeFirstLetter('a')).toBe('A');
expect(capitalizeFirstLetter('b')).toBe('B');
});
it('should not change the case of other characters', () => {
expect(capitalizeFirstLetter('hElLo')).toBe('HElLo');
});
});
describe('toLowerCaseFirstLetter', () => {
it('should convert the first letter to lowercase', () => {
expect(toLowerCaseFirstLetter('CommonAppName')).toBe('commonAppName');
expect(toLowerCaseFirstLetter('AnotherKeyExample')).toBe(
'anotherKeyExample',
);
});
it('should return the same string if the first letter is already lowercase', () => {
expect(toLowerCaseFirstLetter('alreadyLowerCase')).toBe('alreadyLowerCase');
});
it('should handle empty strings', () => {
expect(toLowerCaseFirstLetter('')).toBe('');
});
it('should handle single character strings', () => {
expect(toLowerCaseFirstLetter('A')).toBe('a');
expect(toLowerCaseFirstLetter('a')).toBe('a');
});
it('should handle strings with only one uppercase letter', () => {
expect(toLowerCaseFirstLetter('A')).toBe('a');
});
it('should handle strings with special characters', () => {
expect(toLowerCaseFirstLetter('!Special')).toBe('!Special');
expect(toLowerCaseFirstLetter('123Number')).toBe('123Number');
});
});
describe('toCamelCase', () => {
it('should return the key if parentKey is empty', () => {
expect(toCamelCase('child', '')).toBe('child');
});
it('should combine parentKey and key in camel case', () => {
expect(toCamelCase('child', 'parent')).toBe('parentChild');
});
it('should handle empty key and parentKey', () => {
expect(toCamelCase('', '')).toBe('');
});
it('should handle key with capital letters', () => {
expect(toCamelCase('Child', 'parent')).toBe('parentChild');
expect(toCamelCase('Child', 'Parent')).toBe('ParentChild');
});
});
describe('kebabToCamelCase', () => {
it('should convert kebab-case to camelCase correctly', () => {
expect(kebabToCamelCase('my-component-name')).toBe('myComponentName');
});
it('should handle multiple consecutive hyphens', () => {
expect(kebabToCamelCase('my--component--name')).toBe('myComponentName');
});
it('should trim leading and trailing hyphens', () => {
expect(kebabToCamelCase('-my-component-name-')).toBe('myComponentName');
});
it('should preserve the case of the first word', () => {
expect(kebabToCamelCase('My-component-name')).toBe('MyComponentName');
});
it('should convert a single word correctly', () => {
expect(kebabToCamelCase('component')).toBe('component');
});
it('should return an empty string if input is empty', () => {
expect(kebabToCamelCase('')).toBe('');
});
it('should handle strings with no hyphens', () => {
expect(kebabToCamelCase('mycomponentname')).toBe('mycomponentname');
});
it('should handle strings with only hyphens', () => {
expect(kebabToCamelCase('---')).toBe('');
});
it('should handle mixed case inputs', () => {
expect(kebabToCamelCase('my-Component-Name')).toBe('myComponentName');
});
});

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,107 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { createStack, Stack } from '../stack';
describe('stack', () => {
let stack: Stack<number>;
beforeEach(() => {
stack = new Stack<number>();
});
it('push & size should work', () => {
stack.push(1, 2);
expect(stack.size).toBe(2);
});
it('peek should return top element without removing it', () => {
stack.push(1, 2);
expect(stack.peek()).toBe(2);
expect(stack.size).toBe(2);
});
it('pop should remove and return top element', () => {
stack.push(1, 2);
expect(stack.pop()).toBe(2);
expect(stack.size).toBe(1);
expect(stack.peek()).toBe(1);
});
it('pop on empty stack should return undefined', () => {
expect(stack.pop()).toBeUndefined();
expect(stack.peek()).toBeUndefined();
});
it('clear should remove all elements', () => {
stack.push(1, 2);
stack.clear();
expect(stack.size).toBe(0);
expect(stack.peek()).toBeUndefined();
});
it('toArray should return a shallow copy', () => {
stack.push(1, 2);
const arr = stack.toArray();
arr.push(3);
expect(stack.size).toBe(2);
expect(stack.toArray()).toEqual([1, 2]);
});
it('dedup should remove existing item before push', () => {
stack.push(1, 2, 1);
expect(stack.toArray()).toEqual([2, 1]);
expect(stack.size).toBe(2);
});
it('dedup = false should allow duplicate items', () => {
const s = new Stack<number>(false);
s.push(1, 1, 1);
expect(s.toArray()).toEqual([1, 1, 1]);
expect(s.size).toBe(3);
});
it('remove should delete all matching items', () => {
stack.push(1, 2, 1);
stack.remove(1);
expect(stack.toArray()).toEqual([2]);
expect(stack.size).toBe(1);
});
it('maxSize should limit stack capacity', () => {
const s = new Stack<number>(true, 3);
s.push(1, 2, 3, 4);
expect(s.toArray()).toEqual([2, 3, 4]);
expect(s.size).toBe(3);
});
it('dedup + maxSize should work together', () => {
const s = new Stack<number>(true, 3);
s.push(1, 2, 3, 2); // 去重并重新入栈
expect(s.toArray()).toEqual([1, 3, 2]);
expect(s.size).toBe(3);
});
it('createStack should create a stack instance', () => {
const s = createStack<number>(true, 2);
s.push(1, 2, 3);
expect(s.toArray()).toEqual([2, 3]);
});
});

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { StateHandler } from '../state-handler';
describe('stateHandler', () => {
it('should resolve when condition is set to true', async () => {
const handler = new StateHandler();
// 模拟异步设置 condition 为 true
setTimeout(() => {
handler.setConditionTrue(); // 明确触发 condition 为 true
}, 10);
// 等待条件被设置为 true
await handler.waitForCondition();
expect(handler.isConditionTrue()).toBe(true);
});
it('should resolve immediately if condition is already true', async () => {
const handler = new StateHandler();
handler.setConditionTrue(); // 提前设置为 true
// 立即 resolve因为 condition 已经是 true
await handler.waitForCondition();
expect(handler.isConditionTrue()).toBe(true);
});
it('should reject when condition is set to false after waiting', async () => {
const handler = new StateHandler();
// 模拟异步设置 condition 为 false
setTimeout(() => {
handler.setConditionFalse(); // 明确触发 condition 为 false
}, 10);
// 等待过程中,期望 Promise 被 reject
await expect(handler.waitForCondition()).rejects.toThrow();
expect(handler.isConditionTrue()).toBe(false);
});
it('should reset condition to false', () => {
const handler = new StateHandler();
handler.setConditionTrue(); // 设置为 true
handler.reset(); // 重置为 false
expect(handler.isConditionTrue()).toBe(false);
});
it('should resolve when condition is set to true after reset', async () => {
const handler = new StateHandler();
handler.reset(); // 确保初始为 false
setTimeout(() => {
handler.setConditionTrue(); // 重置后设置为 true
}, 10);
await handler.waitForCondition();
expect(handler.isConditionTrue()).toBe(true);
});
});

View File

@@ -0,0 +1,196 @@
import { describe, expect, it } from 'vitest';
import { filterTree, mapTree, traverseTreeValues } from '../tree';
describe('traverseTreeValues', () => {
interface Node {
children?: Node[];
name: string;
}
type NodeValue = string;
const sampleTree: Node[] = [
{
name: 'A',
children: [
{ name: 'B' },
{
name: 'C',
children: [{ name: 'D' }, { name: 'E' }],
},
],
},
{
name: 'F',
children: [
{ name: 'G' },
{
name: 'H',
children: [{ name: 'I' }],
},
],
},
];
it('traverses tree and returns all node values', () => {
const values = traverseTreeValues<Node, NodeValue>(
sampleTree,
(node) => node.name,
{
childProps: 'children',
},
);
expect(values).toEqual(['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']);
});
it('handles empty tree', () => {
const values = traverseTreeValues<Node, NodeValue>([], (node) => node.name);
expect(values).toEqual([]);
});
it('handles tree with only root node', () => {
const rootNode = { name: 'A' };
const values = traverseTreeValues<Node, NodeValue>(
[rootNode],
(node) => node.name,
);
expect(values).toEqual(['A']);
});
it('handles tree with only leaf nodes', () => {
const leafNodes = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
const values = traverseTreeValues<Node, NodeValue>(
leafNodes,
(node) => node.name,
);
expect(values).toEqual(['A', 'B', 'C']);
});
});
describe('filterTree', () => {
const tree = [
{
id: 1,
children: [
{ id: 2 },
{ id: 3, children: [{ id: 4 }, { id: 5 }, { id: 6 }] },
{ id: 7 },
],
},
{ id: 8, children: [{ id: 9 }, { id: 10 }] },
{ id: 11 },
];
it('should return all nodes when condition is always true', () => {
const result = filterTree(tree, () => true, { childProps: 'children' });
expect(result).toEqual(tree);
});
it('should return only root nodes when condition is always false', () => {
const result = filterTree(tree, () => false);
expect(result).toEqual([]);
});
it('should return nodes with even id values', () => {
const result = filterTree(tree, (node) => node.id % 2 === 0);
expect(result).toEqual([{ id: 8, children: [{ id: 10 }] }]);
});
it('should return nodes with odd id values and their ancestors', () => {
const result = filterTree(tree, (node) => node.id % 2 === 1);
expect(result).toEqual([
{
id: 1,
children: [{ id: 3, children: [{ id: 5 }] }, { id: 7 }],
},
{ id: 11 },
]);
});
it('should return nodes with "leaf" in their name', () => {
const tree = [
{
name: 'root',
children: [
{ name: 'leaf 1' },
{
name: 'branch',
children: [{ name: 'leaf 2' }, { name: 'leaf 3' }],
},
{ name: 'leaf 4' },
],
},
];
const result = filterTree(
tree,
(node) => node.name.includes('leaf') || node.name === 'root',
);
expect(result).toEqual([
{
name: 'root',
children: [{ name: 'leaf 1' }, { name: 'leaf 4' }],
},
]);
});
});
describe('mapTree', () => {
it('map infinite depth tree using mapTree', () => {
const tree = [
{
id: 1,
name: 'node1',
children: [
{ id: 2, name: 'node2' },
{ id: 3, name: 'node3' },
{
id: 4,
name: 'node4',
children: [
{
id: 5,
name: 'node5',
children: [
{ id: 6, name: 'node6' },
{ id: 7, name: 'node7' },
],
},
{ id: 8, name: 'node8' },
],
},
],
},
];
const newTree = mapTree(tree, (node) => ({
...node,
name: `${node.name}-new`,
}));
expect(newTree).toEqual([
{
id: 1,
name: 'node1-new',
children: [
{ id: 2, name: 'node2-new' },
{ id: 3, name: 'node3-new' },
{
id: 4,
name: 'node4-new',
children: [
{
id: 5,
name: 'node5-new',
children: [
{ id: 6, name: 'node6-new' },
{ id: 7, name: 'node7-new' },
],
},
{ id: 8, name: 'node8-new' },
],
},
],
},
]);
});
});

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { uniqueByField } from '../unique';
describe('uniqueByField', () => {
it('should return an array with unique items based on id field', () => {
const items = [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' },
{ id: 1, name: 'Duplicate Item' },
];
const uniqueItems = uniqueByField(items, 'id');
expect(uniqueItems).toHaveLength(3);
expect(uniqueItems).toEqual([
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' },
{ id: 3, name: 'Item 3' },
]);
});
it('should return an empty array when input array is empty', () => {
const items: any[] = []; // Empty array
const uniqueItems = uniqueByField(items, 'id');
// Assert expected results
expect(uniqueItems).toEqual([]);
});
it('should handle arrays with only one item correctly', () => {
const items = [{ id: 1, name: 'Item 1' }];
const uniqueItems = uniqueByField(items, 'id');
// Assert expected results
expect(uniqueItems).toHaveLength(1);
expect(uniqueItems).toEqual([{ id: 1, name: 'Item 1' }]);
});
it('should preserve the order of the first occurrence of each item', () => {
const items = [
{ id: 2, name: 'Item 2' },
{ id: 1, name: 'Item 1' },
{ id: 3, name: 'Item 3' },
{ id: 1, name: 'Duplicate Item' },
];
const uniqueItems = uniqueByField(items, 'id');
// Assert expected results (order of first occurrences preserved)
expect(uniqueItems).toEqual([
{ id: 2, name: 'Item 2' },
{ id: 1, name: 'Item 1' },
{ id: 3, name: 'Item 3' },
]);
});
});

View File

@@ -0,0 +1,30 @@
import { expect, it } from 'vitest';
import { updateCSSVariables } from '../update-css-variables';
it('updateCSSVariables should update CSS variables in :root selector', () => {
// 模拟初始的内联样式表内容
const initialStyleContent = ':root { --primaryColor: red; }';
document.head.innerHTML = `<style id="custom-styles">${initialStyleContent}</style>`;
// 要更新的CSS变量和它们的新值
const updatedVariables = {
fontSize: '16px',
primaryColor: 'blue',
secondaryColor: 'green',
};
// 调用函数来更新CSS变量
updateCSSVariables(updatedVariables, 'custom-styles');
// 获取更新后的样式内容
const styleElement = document.querySelector('#custom-styles');
const updatedStyleContent = styleElement ? styleElement.textContent : '';
// 检查更新后的样式内容是否包含正确的更新值
expect(
updatedStyleContent?.includes('primaryColor: blue;') &&
updatedStyleContent?.includes('secondaryColor: green;') &&
updatedStyleContent?.includes('fontSize: 16px;'),
).toBe(true);
});

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest';
import { bindMethods, getNestedValue } from '../util';
class TestClass {
public value: string;
constructor(value: string) {
this.value = value;
bindMethods(this); // 调用通用方法
}
getValue() {
return this.value;
}
setValue(newValue: string) {
this.value = newValue;
}
}
describe('bindMethods', () => {
it('should bind methods to the instance correctly', () => {
const instance = new TestClass('initial');
// 解构方法
const { getValue } = instance;
// 检查 getValue 是否能正确调用,并且 this 绑定了 instance
expect(getValue()).toBe('initial');
});
it('should bind multiple methods', () => {
const instance = new TestClass('initial');
const { getValue, setValue } = instance;
// 检查 getValue 和 setValue 方法是否正确绑定了 this
setValue('newValue');
expect(getValue()).toBe('newValue');
});
it('should not bind non-function properties', () => {
const instance = new TestClass('initial');
// 检查普通属性是否保持原样
expect(instance.value).toBe('initial');
});
it('should not bind constructor method', () => {
const instance = new TestClass('test');
// 检查 constructor 是否没有被绑定
expect(instance.constructor.name).toBe('TestClass');
});
it('should not bind getter/setter properties', () => {
class TestWithGetterSetter {
get value() {
return this._value;
}
set value(newValue: string) {
this._value = newValue;
}
private _value: string = 'test';
constructor() {
bindMethods(this);
}
}
const instance = new TestWithGetterSetter();
const { value } = instance;
// Getter 和 setter 不应被绑定
expect(value).toBe('test');
});
});
describe('getNestedValue', () => {
interface UserProfile {
age: number;
name: string;
}
interface UserSettings {
theme: string;
}
interface Data {
user: {
profile: UserProfile;
settings: UserSettings;
};
}
const data: Data = {
user: {
profile: {
age: 25,
name: 'Alice',
},
settings: {
theme: 'dark',
},
},
};
it('should get a nested value when the path is valid', () => {
const result = getNestedValue(data, 'user.profile.name');
expect(result).toBe('Alice');
});
it('should return undefined for non-existent property', () => {
const result = getNestedValue(data, 'user.profile.gender');
expect(result).toBeUndefined();
});
it('should return undefined when accessing a non-existent deep path', () => {
const result = getNestedValue(data, 'user.nonexistent.field');
expect(result).toBeUndefined();
});
it('should return undefined if a middle level is undefined', () => {
const result = getNestedValue({ user: undefined }, 'user.profile.name');
expect(result).toBeUndefined();
});
it('should return the correct value for a nested setting', () => {
const result = getNestedValue(data, 'user.settings.theme');
expect(result).toBe('dark');
});
it('should work for a single-level path', () => {
const result = getNestedValue({ a: 1, b: 2 }, 'b');
expect(result).toBe(2);
});
it('should return the entire object if path is empty', () => {
expect(() => getNestedValue(data, '')()).toThrow();
});
it('should handle paths with array indexes', () => {
const complexData = { list: [{ name: 'Item1' }, { name: 'Item2' }] };
const result = getNestedValue(complexData, 'list.1.name');
expect(result).toBe('Item2');
});
it('should return undefined when accessing an out-of-bounds array index', () => {
const complexData = { list: [{ name: 'Item1' }] };
const result = getNestedValue(complexData, 'list.2.name');
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,33 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { openWindow } from '../window';
describe('openWindow', () => {
// 保存原始的 window.open 函数
let originalOpen: typeof window.open;
beforeEach(() => {
originalOpen = window.open;
});
afterEach(() => {
window.open = originalOpen;
});
it('should call window.open with correct arguments', () => {
const url = 'https://example.com';
const options = { noopener: true, noreferrer: true, target: '_blank' };
window.open = vi.fn();
// 调用函数
openWindow(url, options);
// 验证 window.open 是否被正确地调用
expect(window.open).toHaveBeenCalledWith(
url,
options.target,
'noopener=yes,noreferrer=yes',
);
});
});

View File

@@ -0,0 +1,10 @@
import type { ClassValue } from 'clsx';
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export { cn };

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,96 @@
// type Diff<T = any> = T;
// 比较两个数组是否相等
function arraysEqual<T>(a: T[], b: T[]): boolean {
if (a.length !== b.length) return false;
const counter = new Map<T, number>();
for (const value of a) {
counter.set(value, (counter.get(value) || 0) + 1);
}
for (const value of b) {
const count = counter.get(value);
if (count === undefined || count === 0) {
return false;
}
counter.set(value, count - 1);
}
return true;
}
// 深度对比两个值
// function deepEqual<T>(oldVal: T, newVal: T): boolean {
// if (
// typeof oldVal === 'object' &&
// oldVal !== null &&
// typeof newVal === 'object' &&
// newVal !== null
// ) {
// return Array.isArray(oldVal) && Array.isArray(newVal)
// ? arraysEqual(oldVal, newVal)
// : diff(oldVal as any, newVal as any) === null;
// } else {
// return oldVal === newVal;
// }
// }
// // diff 函数
// function diff<T extends object>(
// oldObj: T,
// newObj: T,
// ignoreFields: (keyof T)[] = [],
// ): { [K in keyof T]?: Diff<T[K]> } | null {
// const difference: { [K in keyof T]?: Diff<T[K]> } = {};
// for (const key in oldObj) {
// if (ignoreFields.includes(key)) continue;
// const oldValue = oldObj[key];
// const newValue = newObj[key];
// if (!deepEqual(oldValue, newValue)) {
// difference[key] = newValue;
// }
// }
// return Object.keys(difference).length === 0 ? null : difference;
// }
type DiffResult<T> = Partial<{
[K in keyof T]: T[K] extends object ? DiffResult<T[K]> : T[K];
}>;
function diff<T extends Record<string, any>>(obj1: T, obj2: T): DiffResult<T> {
function findDifferences(o1: any, o2: any): any {
if (Array.isArray(o1) && Array.isArray(o2)) {
if (!arraysEqual(o1, o2)) {
return o2;
}
return undefined;
}
if (
typeof o1 === 'object' &&
typeof o2 === 'object' &&
o1 !== null &&
o2 !== null
) {
const diffResult: any = {};
const keys = new Set([...Object.keys(o1), ...Object.keys(o2)]);
keys.forEach((key) => {
const valueDiff = findDifferences(o1[key], o2[key]);
if (valueDiff !== undefined) {
diffResult[key] = valueDiff;
}
});
return Object.keys(diffResult).length > 0 ? diffResult : undefined;
}
return o1 === o2 ? undefined : o2;
}
return findDifferences(obj1, obj2);
}
export { arraysEqual, diff };

View File

@@ -0,0 +1,107 @@
export interface VisibleDomRect {
bottom: number;
height: number;
left: number;
right: number;
top: number;
width: number;
}
/**
* 获取元素可见信息
* @param element
*/
export function getElementVisibleRect(
element?: HTMLElement | null | undefined,
): VisibleDomRect {
if (!element) {
return {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
};
}
const rect = element.getBoundingClientRect();
const viewHeight = Math.max(
document.documentElement.clientHeight,
window.innerHeight,
);
const top = Math.max(rect.top, 0);
const bottom = Math.min(rect.bottom, viewHeight);
const viewWidth = Math.max(
document.documentElement.clientWidth,
window.innerWidth,
);
const left = Math.max(rect.left, 0);
const right = Math.min(rect.right, viewWidth);
// 如果元素完全不可见,则返回一个空的矩形
if (top >= viewHeight || bottom <= 0 || left >= viewWidth || right <= 0) {
return {
bottom: 0,
height: 0,
left: 0,
right: 0,
top: 0,
width: 0,
};
}
return {
bottom,
height: Math.max(0, bottom - top),
left,
right,
top,
width: Math.max(0, right - left),
};
}
export function getScrollbarWidth() {
const scrollDiv = document.createElement('div');
scrollDiv.style.visibility = 'hidden';
scrollDiv.style.overflow = 'scroll';
scrollDiv.style.position = 'absolute';
scrollDiv.style.top = '-9999px';
document.body.append(scrollDiv);
const innerDiv = document.createElement('div');
scrollDiv.append(innerDiv);
const scrollbarWidth = scrollDiv.offsetWidth - innerDiv.offsetWidth;
scrollDiv.remove();
return scrollbarWidth;
}
export function needsScrollbar() {
const doc = document.documentElement;
const body = document.body;
// 检查 body 的 overflow-y 样式
const overflowY = window.getComputedStyle(body).overflowY;
// 如果明确设置了需要滚动条的样式
if (overflowY === 'scroll' || overflowY === 'auto') {
return doc.scrollHeight > window.innerHeight;
}
// 在其他情况下,根据 scrollHeight 和 innerHeight 比较判断
return doc.scrollHeight > window.innerHeight;
}
export function triggerWindowResize(): void {
// 创建一个新的 resize 事件
const resizeEvent = new Event('resize');
// 触发 window 的 resize 事件
window.dispatchEvent(resizeEvent);
}

View File

@@ -0,0 +1,271 @@
import { openWindow } from './window';
interface DownloadOptions<T = string> {
fileName?: string;
source: T;
target?: string;
}
const DEFAULT_FILENAME = 'downloaded_file';
/**
* 通过 URL 下载文件,支持跨域
* @throws {Error} - 当下载失败时抛出错误
*/
export async function downloadFileFromUrl({
fileName,
source,
target = '_blank',
}: DownloadOptions): Promise<void> {
if (!source || typeof source !== 'string') {
throw new Error('Invalid URL.');
}
const isChrome = window.navigator.userAgent.toLowerCase().includes('chrome');
const isSafari = window.navigator.userAgent.toLowerCase().includes('safari');
if (/iP/.test(window.navigator.userAgent)) {
console.error('Your browser does not support download!');
return;
}
if (isChrome || isSafari) {
triggerDownload(source, resolveFileName(source, fileName));
return;
}
if (!source.includes('?')) {
source += '?download';
}
openWindow(source, { target });
}
/**
* 下载图片(允许跨域)
* @param url - 图片 URL
* @param canvasWidth - 画布宽度
* @param canvasHeight - 画布高度
* @param drawWithImageSize - 将图片绘制在画布上时带上图片的宽高值, 默认是要带上的
* @returns
*/
export function downloadImageByCanvas({
url,
canvasWidth,
canvasHeight,
drawWithImageSize = true,
}: {
canvasHeight?: number;
canvasWidth?: number;
drawWithImageSize?: boolean;
url: string;
}) {
const image = new Image();
// image.setAttribute('crossOrigin', 'anonymous')
image.src = url;
image.addEventListener('load', () => {
const canvas = document.createElement('canvas');
canvas.width = canvasWidth || image.width;
canvas.height = canvasHeight || image.height;
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
ctx?.clearRect(0, 0, canvas.width, canvas.height);
if (drawWithImageSize) {
ctx.drawImage(image, 0, 0, image.width, image.height);
} else {
ctx.drawImage(image, 0, 0);
}
const url = canvas.toDataURL('image/png');
downloadFileFromImageUrl({ source: url, fileName: 'image.png' });
});
}
/**
* 通过 Base64 下载文件
*/
export function downloadFileFromBase64({ fileName, source }: DownloadOptions) {
if (!source || typeof source !== 'string') {
throw new Error('Invalid Base64 data.');
}
const resolvedFileName = fileName || DEFAULT_FILENAME;
triggerDownload(source, resolvedFileName);
}
/**
* 通过图片 URL 下载图片文件
*/
export async function downloadFileFromImageUrl({
fileName,
source,
}: DownloadOptions) {
const base64 = await urlToBase64(source);
downloadFileFromBase64({ fileName, source: base64 });
}
/**
* 通过 Blob 下载文件
*/
export function downloadFileFromBlob({
fileName = DEFAULT_FILENAME,
source,
}: DownloadOptions<Blob>): void {
if (!(source instanceof Blob)) {
throw new TypeError('Invalid Blob data.');
}
const url = URL.createObjectURL(source);
triggerDownload(url, fileName);
}
/**
* 下载文件,支持 Blob、字符串和其他 BlobPart 类型
*/
export function downloadFileFromBlobPart({
fileName = DEFAULT_FILENAME,
source,
}: DownloadOptions<BlobPart>): void {
// 如果 data 不是 Blob则转换为 Blob
const blob =
source instanceof Blob
? source
: new Blob([source], { type: 'application/octet-stream' });
// 创建对象 URL 并触发下载
const url = URL.createObjectURL(blob);
triggerDownload(url, fileName);
}
/**
* @description: base64 to blob
*/
export function dataURLtoBlob(base64Buf: string): Blob {
const arr = base64Buf.split(',');
const typeItem = arr[0];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mime = typeItem!.match(/:(.*?);/)![1];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const bstr = window.atob(arr[1]!);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
u8arr[n] = bstr.codePointAt(n)!;
}
return new Blob([u8arr], { type: mime });
}
/**
* img url to base64
* @param url
*/
export function urlToBase64(url: string, mineType?: string): Promise<string> {
return new Promise((resolve, reject) => {
let canvas = document.createElement('CANVAS') as HTMLCanvasElement | null;
const ctx = canvas?.getContext('2d');
const img = new Image();
img.crossOrigin = '';
img.addEventListener('load', () => {
if (!canvas || !ctx) {
return reject(new Error('Failed to create canvas.'));
}
canvas.height = img.height;
canvas.width = img.width;
ctx.drawImage(img, 0, 0);
const dataURL = canvas.toDataURL(mineType || 'image/png');
canvas = null;
resolve(dataURL);
});
img.src = url;
});
}
/**
* 将 Base64 字符串转换为文件对象
* @param base64 - Base64 字符串
* @param fileName - 文件名
* @returns File 对象
*/
export function base64ToFile(base64: string, fileName: string): File {
// 输入验证
if (!base64 || typeof base64 !== 'string') {
throw new Error('base64 参数必须是非空字符串');
}
// 将 base64 按照逗号进行分割,将前缀与后续内容分隔开
const data = base64.split(',');
if (data.length !== 2 || !data[0] || !data[1]) {
throw new Error('无效的 base64 格式');
}
// 利用正则表达式从前缀中获取类型信息image/png、image/jpeg、image/webp等
const typeMatch = data[0].match(/:(.*?);/);
if (!typeMatch || !typeMatch[1]) {
throw new Error('无法解析 base64 类型信息');
}
const type = typeMatch[1];
// 从类型信息中获取具体的文件格式后缀png、jpeg、webp
const typeParts = type.split('/');
if (typeParts.length !== 2 || !typeParts[1]) {
throw new Error('无效的 MIME 类型格式');
}
const suffix = typeParts[1];
try {
// 使用 atob() 对 base64 数据进行解码,结果是一个文件数据流以字符串的格式输出
const bstr = window.atob(data[1]);
// 获取解码结果字符串的长度
const n = bstr.length;
// 根据解码结果字符串的长度创建一个等长的整型数字数组
const u8arr = new Uint8Array(n);
// 优化的 Uint8Array 填充逻辑
for (let i = 0; i < n; i++) {
// 使用 charCodeAt() 获取字符对应的字节值Base64 解码后的字符串是字节级别的)
// eslint-disable-next-line unicorn/prefer-code-point
u8arr[i] = bstr.charCodeAt(i);
}
// 返回 File 文件对象
return new File([u8arr], `${fileName}.${suffix}`, { type });
} catch (error) {
throw new Error(
`Base64 解码失败: ${error instanceof Error ? error.message : '未知错误'}`,
);
}
}
/**
* 通用下载触发函数
* @param href - 文件下载的 URL
* @param fileName - 下载文件的名称,如果未提供则自动识别
* @param revokeDelay - 清理 URL 的延迟时间 (毫秒)
*/
export function triggerDownload(
href: string,
fileName: string | undefined,
revokeDelay: number = 100,
): void {
const defaultFileName = 'downloaded_file';
const finalFileName = fileName || defaultFileName;
const link = document.createElement('a');
link.href = href;
link.download = finalFileName;
link.style.display = 'none';
if (link.download === undefined) {
link.setAttribute('target', '_blank');
}
document.body.append(link);
link.click();
link.remove();
// 清理临时 URL 以释放内存
setTimeout(() => URL.revokeObjectURL(href), revokeDelay);
}
function resolveFileName(url: string, fileName?: string): string {
return fileName || url.slice(url.lastIndexOf('/') + 1) || DEFAULT_FILENAME;
}

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,26 @@
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 './stack';
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,165 @@
// eslint-disable-next-line vue/prefer-import-from-vue
import { isFunction, isObject, isString } from '@vue/shared';
/**
* 检查传入的值是否为undefined。
*
* @param {unknown} value 要检查的值。
* @returns {boolean} 如果值是undefined返回true否则返回false。
*/
function isUndefined(value?: unknown): value is undefined {
return value === undefined;
}
/**
* 检查传入的值是否为boolean
* @param value
* @returns 如果值是布尔值返回true否则返回false。
*/
function isBoolean(value: unknown): value is boolean {
return typeof value === 'boolean';
}
/**
* 检查传入的值是否为空。
*
* 以下情况将被认为是空:
* - 值为null。
* - 值为undefined。
* - 值为一个空字符串。
* - 值为一个长度为0的数组。
* - 值为一个没有元素的Map或Set。
* - 值为一个没有属性的对象。
*
* @param {T} value 要检查的值。
* @returns {boolean} 如果值为空返回true否则返回false。
*/
function isEmpty<T = unknown>(value?: T): value is T {
if (value === null || value === undefined) {
return true;
}
if (Array.isArray(value) || isString(value)) {
return value.length === 0;
}
if (value instanceof Map || value instanceof Set) {
return value.size === 0;
}
if (isObject(value)) {
return Object.keys(value).length === 0;
}
return false;
}
/**
* 检查传入的字符串是否为有效的HTTP或HTTPS URL。
*
* @param {string} url 要检查的字符串。
* @return {boolean} 如果字符串是有效的HTTP或HTTPS URL返回true否则返回false。
*/
function isHttpUrl(url?: string): boolean {
if (!url) {
return false;
}
// 使用正则表达式测试URL是否以http:// 或 https:// 开头
const httpRegex = /^https?:\/\/.*$/;
return httpRegex.test(url);
}
/**
* 检查传入的值是否为window对象。
*
* @param {any} value 要检查的值。
* @returns {boolean} 如果值是window对象返回true否则返回false。
*/
function isWindow(value: any): value is Window {
return (
typeof window !== 'undefined' && value !== null && value === value.window
);
}
/**
* 检查当前运行环境是否为Mac OS。
*
* 这个函数通过检查navigator.userAgent字符串来判断当前运行环境。
* 如果userAgent字符串中包含"macintosh"或"mac os x"不区分大小写则认为当前环境是Mac OS。
*
* @returns {boolean} 如果当前环境是Mac OS返回true否则返回false。
*/
function isMacOs(): boolean {
const macRegex = /macintosh|mac os x/i;
return macRegex.test(navigator.userAgent);
}
/**
* 检查当前运行环境是否为Windows OS。
*
* 这个函数通过检查navigator.userAgent字符串来判断当前运行环境。
* 如果userAgent字符串中包含"windows"或"win32"不区分大小写则认为当前环境是Windows OS。
*
* @returns {boolean} 如果当前环境是Windows OS返回true否则返回false。
*/
function isWindowsOs(): boolean {
const windowsRegex = /windows|win32/i;
return windowsRegex.test(navigator.userAgent);
}
/**
* 检查传入的值是否为数字
* @param value
*/
function isNumber(value: any): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
/**
* Returns the first value in the provided list that is neither `null` nor `undefined`.
*
* This function iterates over the input values and returns the first one that is
* not strictly equal to `null` or `undefined`. If all values are either `null` or
* `undefined`, it returns `undefined`.
*
* @template T - The type of the input values.
* @param {...(T | null | undefined)[]} values - A list of values to evaluate.
* @returns {T | undefined} - The first value that is not `null` or `undefined`, or `undefined` if none are found.
*
* @example
* // Returns 42 because it is the first non-null, non-undefined value.
* getFirstNonNullOrUndefined(undefined, null, 42, 'hello'); // 42
*
* @example
* // Returns 'hello' because it is the first non-null, non-undefined value.
* getFirstNonNullOrUndefined(null, undefined, 'hello', 123); // 'hello'
*
* @example
* // Returns undefined because all values are either null or undefined.
* getFirstNonNullOrUndefined(undefined, null); // undefined
*/
function getFirstNonNullOrUndefined<T>(
...values: (null | T | undefined)[]
): T | undefined {
for (const value of values) {
if (value !== undefined && value !== null) {
return value;
}
}
return undefined;
}
export {
getFirstNonNullOrUndefined,
isBoolean,
isEmpty,
isFunction,
isHttpUrl,
isMacOs,
isNumber,
isObject,
isString,
isUndefined,
isWindow,
isWindowsOs,
};

View File

@@ -0,0 +1,47 @@
/**
* 将字符串的首字母大写
* @param string
*/
function capitalizeFirstLetter(string: string): string {
return string.charAt(0).toUpperCase() + string.slice(1);
}
/**
* 将字符串的首字母转换为小写。
*
* @param str 要转换的字符串
* @returns 首字母小写的字符串
*/
function toLowerCaseFirstLetter(str: string): string {
if (!str) return str; // 如果字符串为空,直接返回
return str.charAt(0).toLowerCase() + str.slice(1);
}
/**
* 生成驼峰命名法的键名
* @param key
* @param parentKey
*/
function toCamelCase(key: string, parentKey: string): string {
if (!parentKey) {
return key;
}
return parentKey + key.charAt(0).toUpperCase() + key.slice(1);
}
function kebabToCamelCase(str: string): string {
return str
.split('-')
.filter(Boolean)
.map((word, index) =>
index === 0 ? word : word.charAt(0).toUpperCase() + word.slice(1),
)
.join('');
}
export {
capitalizeFirstLetter,
kebabToCamelCase,
toCamelCase,
toLowerCaseFirstLetter,
};

View File

@@ -0,0 +1,10 @@
import { createDefu } from 'defu';
export { createDefu as createMerge, defu as merge } from 'defu';
export const mergeWithArrayOverride = createDefu((originObj, key, updates) => {
if (Array.isArray(originObj[key]) && Array.isArray(updates)) {
originObj[key] = updates;
return true;
}
});

View File

@@ -0,0 +1,43 @@
import type NProgress from 'nprogress';
// 创建一个NProgress实例的变量初始值为null
let nProgressInstance: null | typeof NProgress = null;
/**
* 动态加载NProgress库并进行配置。
* 此函数首先检查是否已经加载过NProgress库如果已经加载过则直接返回NProgress实例。
* 否则动态导入NProgress库进行配置然后返回NProgress实例。
*
* @returns NProgress实例的Promise对象。
*/
async function loadNprogress() {
if (nProgressInstance) {
return nProgressInstance;
}
nProgressInstance = await import('nprogress');
nProgressInstance.configure({
showSpinner: true,
speed: 300,
});
return nProgressInstance;
}
/**
* 开始显示进度条。
* 此函数首先加载NProgress库然后调用NProgress的start方法开始显示进度条。
*/
async function startProgress() {
const nprogress = await loadNprogress();
nprogress?.start();
}
/**
* 停止显示进度条,并隐藏进度条。
* 此函数首先加载NProgress库然后调用NProgress的done方法停止并隐藏进度条。
*/
async function stopProgress() {
const nprogress = await loadNprogress();
nprogress?.done();
}
export { startProgress, stopProgress };

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,103 @@
/**
* @zh_CN 栈数据结构
*/
export class Stack<T> {
/**
* @zh_CN 栈内元素数量
*/
get size() {
return this.items.length;
}
/**
* @zh_CN 是否去重
*/
private readonly dedup: boolean;
/**
* @zh_CN 栈内元素
*/
private items: T[] = [];
/**
* @zh_CN 栈的最大容量
*/
private readonly maxSize?: number;
constructor(dedup = true, maxSize?: number) {
this.maxSize = maxSize;
this.dedup = dedup;
}
/**
* @zh_CN 清空栈内元素
*/
clear() {
this.items.length = 0;
}
/**
* @zh_CN 查看栈顶元素
* @returns 栈顶元素
*/
peek(): T | undefined {
return this.items[this.items.length - 1];
}
/**
* @zh_CN 出栈
* @returns 栈顶元素
*/
pop(): T | undefined {
return this.items.pop();
}
/**
* @zh_CN 入栈
* @param items 要入栈的元素
*/
push(...items: T[]) {
items.forEach((item) => {
// 去重
if (this.dedup) {
const index = this.items.indexOf(item);
if (index !== -1) {
this.items.splice(index, 1);
}
}
this.items.push(item);
if (this.maxSize && this.items.length > this.maxSize) {
this.items.splice(0, this.items.length - this.maxSize);
}
});
}
/**
* @zh_CN 移除栈内元素
* @param itemList 要移除的元素列表
*/
remove(...itemList: T[]) {
this.items = this.items.filter((i) => !itemList.includes(i));
}
/**
* @zh_CN 保留栈内元素
* @param itemList 要保留的元素列表
*/
retain(itemList: T[]) {
this.items = this.items.filter((i) => itemList.includes(i));
}
/**
* @zh_CN 转换为数组
* @returns 栈内元素数组
*/
toArray(): T[] {
return [...this.items];
}
}
/**
* @zh_CN 创建一个栈实例
* @param dedup 是否去重
* @param maxSize 栈的最大容量
* @returns 栈实例
*/
export const createStack = <T>(dedup = true, maxSize?: number) =>
new Stack<T>(dedup, maxSize);

View File

@@ -0,0 +1,50 @@
export class StateHandler {
private condition: boolean = false;
private rejectCondition: (() => void) | null = null;
private resolveCondition: (() => void) | null = null;
isConditionTrue(): boolean {
return this.condition;
}
reset() {
this.condition = false;
this.clearPromises();
}
// 触发状态为 false 时reject
setConditionFalse() {
this.condition = false;
if (this.rejectCondition) {
this.rejectCondition();
this.clearPromises();
}
}
// 触发状态为 true 时resolve
setConditionTrue() {
this.condition = true;
if (this.resolveCondition) {
this.resolveCondition();
this.clearPromises();
}
}
// 返回一个 Promise等待 condition 变为 true
waitForCondition(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.condition) {
resolve(); // 如果 condition 已经为 true立即 resolve
} else {
this.resolveCondition = resolve;
this.rejectCondition = reject;
}
});
}
// 清理 resolve/reject 函数
private clearPromises() {
this.resolveCondition = null;
this.rejectCondition = null;
}
}

View File

@@ -0,0 +1,299 @@
import dayjs from 'dayjs';
import { formatDate } from './date';
/**
* @param {Date | number | string} time 需要转换的时间
* @param {string} fmt 需要转换的格式 如 yyyy-MM-dd、yyyy-MM-dd HH:mm:ss
*/
export function formatTime(time: Date | number | string, fmt: string) {
if (time) {
const date = new Date(time);
const o = {
'M+': date.getMonth() + 1,
'd+': date.getDate(),
'H+': date.getHours(),
'm+': date.getMinutes(),
's+': date.getSeconds(),
'q+': Math.floor((date.getMonth() + 3) / 3),
S: date.getMilliseconds(),
};
const yearMatch = fmt.match(/y+/);
if (yearMatch) {
fmt = fmt.replace(
yearMatch[0],
`${date.getFullYear()}`.slice(4 - yearMatch[0].length),
);
}
for (const k in o) {
const match = fmt.match(new RegExp(`(${k})`));
if (match) {
fmt = fmt.replace(
match[0],
match[0].length === 1
? (o[k as keyof typeof o] as any)
: `00${o[k as keyof typeof o]}`.slice(
`${o[k as keyof typeof o]}`.length,
),
);
}
}
return fmt;
} else {
return '';
}
}
/**
* 获取当前日期是第几周
* @param dateTime 当前传入的日期值
* @returns 返回第几周数字值
*/
export function getWeek(dateTime: Date): number {
const temptTime = new Date(dateTime);
// 周几
const weekday = temptTime.getDay() || 7;
// 周1+5天=周六
temptTime.setDate(temptTime.getDate() - weekday + 1 + 5);
let firstDay = new Date(temptTime.getFullYear(), 0, 1);
const dayOfWeek = firstDay.getDay();
let spendDay = 1;
if (dayOfWeek !== 0) spendDay = 7 - dayOfWeek + 1;
firstDay = new Date(temptTime.getFullYear(), 0, 1 + spendDay);
const d = Math.ceil((temptTime.valueOf() - firstDay.valueOf()) / 86_400_000);
return Math.ceil(d / 7);
}
/**
* 将时间转换为 `几秒前`、`几分钟前`、`几小时前`、`几天前`
* @param param 当前时间new Date() 格式或者字符串时间格式
* @param format 需要转换的时间格式字符串
* @description param 10秒 10 * 1000
* @description param 1分 60 * 1000
* @description param 1小时 60 * 60 * 1000
* @description param 24小时60 * 60 * 24 * 1000
* @description param 3天 60 * 60* 24 * 1000 * 3
* @returns 返回拼接后的时间字符串
*/
export function formatPast(
param: Date | string,
format = 'YYYY-MM-DD HH:mm:ss',
): string {
// 传入格式处理、存储转换值
let s: number, t: any;
// 获取js 时间戳
let time: number = Date.now();
// 是否是对象
typeof param === 'string' || typeof param === 'object'
? (t = new Date(param).getTime())
: (t = param);
// 当前时间戳 - 传入时间戳
time = Number.parseInt(`${time - t}`);
if (time < 10_000) {
// 10秒内
return '刚刚';
} else if (time < 60_000 && time >= 10_000) {
// 超过10秒少于1分钟内
s = Math.floor(time / 1000);
return `${s}秒前`;
} else if (time < 3_600_000 && time >= 60_000) {
// 超过1分钟少于1小时
s = Math.floor(time / 60_000);
return `${s}分钟前`;
} else if (time < 86_400_000 && time >= 3_600_000) {
// 超过1小时少于24小时
s = Math.floor(time / 3_600_000);
return `${s}小时前`;
} else if (time < 259_200_000 && time >= 86_400_000) {
// 超过1天少于3天内
s = Math.floor(time / 86_400_000);
return `${s}天前`;
} else {
// 超过3天
const date =
typeof param === 'string' || typeof param === 'object'
? new Date(param)
: param;
return formatDate(date, format) as string;
}
}
/**
* 时间问候语
* @param param 当前时间new Date() 格式
* @description param 调用 `formatAxis(new Date())` 输出 `上午好`
* @returns 返回拼接后的时间字符串
*/
export function formatAxis(param: Date): string {
const hour: number = new Date(param).getHours();
if (hour < 6) return '凌晨好';
else if (hour < 9) return '早上好';
else if (hour < 12) return '上午好';
else if (hour < 14) return '中午好';
else if (hour < 17) return '下午好';
else if (hour < 19) return '傍晚好';
else if (hour < 22) return '晚上好';
else return '夜里好';
}
/**
* 将毫秒转换成时间字符串。例如说xx 分钟
*
* @param ms 毫秒
* @returns {string} 字符串
*/
export function formatPast2(ms: number): string {
const day = Math.floor(ms / (24 * 60 * 60 * 1000));
const hour = Math.floor(ms / (60 * 60 * 1000) - day * 24);
const minute = Math.floor(ms / (60 * 1000) - day * 24 * 60 - hour * 60);
const second = Math.floor(
ms / 1000 - day * 24 * 60 * 60 - hour * 60 * 60 - minute * 60,
);
if (day > 0) {
return `${day}${hour} 小时 ${minute} 分钟`;
}
if (hour > 0) {
return `${hour} 小时 ${minute} 分钟`;
}
if (minute > 0) {
return `${minute} 分钟`;
}
return second > 0 ? `${second}` : `${0}`;
}
/**
* 设置起始日期时间为00:00:00
* @param param 传入日期
* @returns 带时间00:00:00的日期
*/
export function beginOfDay(param: Date): Date {
return new Date(
param.getFullYear(),
param.getMonth(),
param.getDate(),
0,
0,
0,
);
}
/**
* 设置结束日期时间为23:59:59
* @param param 传入日期
* @returns 带时间23:59:59的日期
*/
export function endOfDay(param: Date): Date {
return new Date(
param.getFullYear(),
param.getMonth(),
param.getDate(),
23,
59,
59,
);
}
/**
* 计算两个日期间隔天数
* @param param1 日期1
* @param param2 日期2
*/
export function betweenDay(param1: Date, param2: Date): number {
param1 = convertDate(param1);
param2 = convertDate(param2);
// 计算差值
return Math.floor((param2.getTime() - param1.getTime()) / (24 * 3600 * 1000));
}
/**
* 日期计算
* @param param1 日期
* @param param2 添加的时间
*/
export function addTime(param1: Date, param2: number): Date {
param1 = convertDate(param1);
return new Date(param1.getTime() + param2);
}
/**
* 日期转换
* @param param 日期
*/
export function convertDate(param: Date | string): Date {
if (typeof param === 'string') {
return new Date(param);
}
return param;
}
/**
* 指定的两个日期, 是否为同一天
* @param a 日期 A
* @param b 日期 B
*/
export function isSameDay(a: dayjs.ConfigType, b: dayjs.ConfigType): boolean {
if (!a || !b) return false;
const aa = dayjs(a);
const bb = dayjs(b);
return (
aa.year() === bb.year() &&
aa.month() === bb.month() &&
aa.day() === bb.day()
);
}
/**
* 获取一天的开始时间、截止时间
* @param date 日期
* @param days 天数
*/
export function getDayRange(
date: dayjs.ConfigType,
days: number,
): [dayjs.ConfigType, dayjs.ConfigType] {
const day = dayjs(date).add(days, 'd');
return getDateRange(day, day);
}
/**
* 获取最近7天的开始时间、截止时间
*/
export function getLast7Days(): [dayjs.ConfigType, dayjs.ConfigType] {
const lastWeekDay = dayjs().subtract(7, 'd');
const yesterday = dayjs().subtract(1, 'd');
return getDateRange(lastWeekDay, yesterday);
}
/**
* 获取最近30天的开始时间、截止时间
*/
export function getLast30Days(): [dayjs.ConfigType, dayjs.ConfigType] {
const lastMonthDay = dayjs().subtract(30, 'd');
const yesterday = dayjs().subtract(1, 'd');
return getDateRange(lastMonthDay, yesterday);
}
/**
* 获取最近1年的开始时间、截止时间
*/
export function getLast1Year(): [dayjs.ConfigType, dayjs.ConfigType] {
const lastYearDay = dayjs().subtract(1, 'y');
const yesterday = dayjs().subtract(1, 'd');
return getDateRange(lastYearDay, yesterday);
}
/**
* 获取指定日期的开始时间、截止时间
* @param beginDate 开始日期
* @param endDate 截止日期
*/
export function getDateRange(
beginDate: dayjs.ConfigType,
endDate: dayjs.ConfigType,
): [string, string] {
return [
dayjs(beginDate).startOf('d').format('YYYY-MM-DD HH:mm:ss'),
dayjs(endDate).endOf('d').format('YYYY-MM-DD HH:mm:ss'),
];
}

View File

@@ -0,0 +1,21 @@
/**
* @param { Readonly<Promise> } promise
* @param {object=} errorExt - Additional Information you can pass to the err object
* @return { Promise }
*/
export async function to<T, U = Error>(
promise: Readonly<Promise<T>>,
errorExt?: object,
): Promise<[null, T] | [U, undefined]> {
try {
const data = await promise;
const result: [null, T] = [null, data];
return result;
} catch (error) {
if (errorExt) {
const parsedError = Object.assign({}, error, errorExt);
return [parsedError as U, undefined];
}
return [error as U, undefined];
}
}

View File

@@ -0,0 +1,246 @@
interface TreeConfigOptions {
// 子属性的名称,默认为'children'
childProps: string;
}
interface TreeNode {
[key: string]: any;
children?: TreeNode[];
}
/**
* @zh_CN 遍历树形结构,并返回所有节点中指定的值。
* @param tree 树形结构数组
* @param getValue 获取节点值的函数
* @param options 作为子节点数组的可选属性名称。
* @returns 所有节点中指定的值的数组
*/
function traverseTreeValues<T, V>(
tree: T[],
getValue: (node: T) => V,
options?: TreeConfigOptions,
): V[] {
const result: V[] = [];
const { childProps } = options || {
childProps: 'children',
};
const dfs = (treeNode: T) => {
const value = getValue(treeNode);
result.push(value);
const children = (treeNode as Record<string, any>)?.[childProps];
if (!children) {
return;
}
if (children.length > 0) {
for (const child of children) {
dfs(child);
}
}
};
for (const treeNode of tree) {
dfs(treeNode);
}
return result.filter(Boolean);
}
/**
* 根据条件过滤给定树结构的节点,并以原有顺序返回所有匹配节点的数组。
* @param tree 要过滤的树结构的根节点数组。
* @param filter 用于匹配每个节点的条件。
* @param options 作为子节点数组的可选属性名称。
* @returns 包含所有匹配节点的数组。
*/
function filterTree<T extends Record<string, any>>(
tree: T[],
filter: (node: T) => boolean,
options?: TreeConfigOptions,
): T[] {
const { childProps } = options || {
childProps: 'children',
};
const _filterTree = (nodes: T[]): T[] => {
return nodes.filter((node: Record<string, any>) => {
if (filter(node as T)) {
if (node[childProps]) {
node[childProps] = _filterTree(node[childProps]);
}
return true;
}
return false;
});
};
return _filterTree(tree);
}
/**
* 根据条件重新映射给定树结构的节
* @param tree 要过滤的树结构的根节点数组。
* @param mapper 用于map每个节点的条件。
* @param options 作为子节点数组的可选属性名称。
*/
function mapTree<T, V extends Record<string, any>>(
tree: T[],
mapper: (node: T) => V,
options?: TreeConfigOptions,
): V[] {
const { childProps } = options || {
childProps: 'children',
};
return tree.map((node) => {
const mapperNode: Record<string, any> = mapper(node);
if (mapperNode[childProps]) {
mapperNode[childProps] = mapTree(mapperNode[childProps], mapper, options);
}
return mapperNode as V;
});
}
/**
* 构造树型结构数据
*
* @param {*} data 数据源
* @param {*} id id字段 默认 'id'
* @param {*} parentId 父节点字段 默认 'parentId'
* @param {*} children 孩子节点字段 默认 'children'
*/
function handleTree(
data: TreeNode[],
id: string = 'id',
parentId: string = 'parentId',
children: string = 'children',
): TreeNode[] {
if (!Array.isArray(data)) {
console.warn('data must be an array');
return [];
}
const config = {
id,
parentId,
childrenList: children,
};
const childrenListMap: Record<number | string, TreeNode[]> = {};
const nodeIds: Record<number | string, TreeNode> = {};
const tree: TreeNode[] = [];
// 1. 数据预处理
// 1.1 第一次遍历,生成 childrenListMap 和 nodeIds 映射
for (const d of data) {
const pId = d[config.parentId];
if (childrenListMap[pId] === undefined) {
childrenListMap[pId] = [];
}
nodeIds[d[config.id]] = d;
childrenListMap[pId].push(d);
}
// 1.2 第二次遍历,找出根节点
for (const d of data) {
const pId = d[config.parentId];
if (nodeIds[pId] === undefined) {
tree.push(d);
}
}
// 2. 构建树结:递归构建子节点
const adaptToChildrenList = (node: TreeNode): void => {
const nodeId = node[config.id];
if (childrenListMap[nodeId]) {
node[config.childrenList] = childrenListMap[nodeId];
// 递归处理子节点
for (const child of node[config.childrenList]) {
adaptToChildrenList(child);
}
}
};
// 3. 从根节点开始构建完整树
for (const rootNode of tree) {
adaptToChildrenList(rootNode);
}
return tree;
}
/**
* 获取节点的完整结构
* @param tree 树数据
* @param nodeId 节点 id
*/
function treeToString(tree: any[], nodeId: number | string) {
if (tree === undefined || !Array.isArray(tree) || tree.length === 0) {
console.warn('tree must be an array');
return '';
}
// 校验是否是一级节点
const node = tree.find((item) => item.id === nodeId);
if (node !== undefined) {
return node.name;
}
let str = '';
function performAThoroughValidation(arr: any[]) {
if (arr === undefined || !Array.isArray(arr) || arr.length === 0) {
return false;
}
for (const item of arr) {
if (item.id === nodeId) {
str += ` / ${item.name}`;
return true;
} else if (item.children !== undefined && item.children.length > 0) {
str += ` / ${item.name}`;
if (performAThoroughValidation(item.children)) {
return true;
}
}
}
return false;
}
for (const item of tree) {
str = `${item.name}`;
if (performAThoroughValidation(item.children)) {
break;
}
}
return str;
}
/**
* 对树形结构数据进行递归排序
* @param treeData - 树形数据数组
* @param sortFunction - 排序函数,用于定义排序规则
* @param options - 配置选项,包括子节点属性名
* @returns 排序后的树形数据
*/
function sortTree<T extends Record<string, any>>(
treeData: T[],
sortFunction: (a: T, b: T) => number,
options?: TreeConfigOptions,
): T[] {
const { childProps } = options || {
childProps: 'children',
};
return treeData.toSorted(sortFunction).map((item) => {
const children = item[childProps];
if (children && Array.isArray(children) && children.length > 0) {
return {
...item,
[childProps]: sortTree(children, sortFunction, options),
};
}
return item;
});
}
export {
filterTree,
handleTree,
mapTree,
sortTree,
traverseTreeValues,
treeToString,
};

View File

@@ -0,0 +1,15 @@
/**
* 根据指定字段对对象数组进行去重
* @param arr 要去重的对象数组
* @param key 去重依据的字段名
* @returns 去重后的对象数组
*/
function uniqueByField<T>(arr: T[], key: keyof T): T[] {
const seen = new Map<any, T>();
return arr.filter((item) => {
const value = item[key];
return seen.has(value) ? false : (seen.set(value, item), true);
});
}
export { uniqueByField };

View File

@@ -0,0 +1,35 @@
/**
* 更新 CSS 变量的函数
* @param variables 要更新的 CSS 变量与其新值的映射
*/
function updateCSSVariables(
variables: { [key: string]: string },
id = '__vben-styles__',
): void {
// 获取或创建内联样式表元素
const styleElement =
document.querySelector(`#${id}`) || document.createElement('style');
styleElement.id = id;
// 构建要更新的 CSS 变量的样式文本
let cssText = ':root {';
for (const key in variables) {
if (Object.prototype.hasOwnProperty.call(variables, key)) {
cssText += `${key}: ${variables[key]};`;
}
}
cssText += '}';
// 将样式文本赋值给内联样式表
styleElement.textContent = cssText;
// 将内联样式表添加到文档头部
if (!document.querySelector(`#${id}`)) {
setTimeout(() => {
document.head.append(styleElement);
});
}
}
export { updateCSSVariables };

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,39 @@
const hexList: string[] = [];
for (let i = 0; i <= 15; i++) {
hexList[i] = i.toString(16);
}
export function buildUUID(): string {
let uuid = '';
for (let i = 1; i <= 36; i++) {
switch (i) {
case 9:
case 14:
case 19:
case 24: {
uuid += '-';
break;
}
case 15: {
uuid += 4;
break;
}
case 20: {
uuid += hexList[(Math.random() * 4) | 8];
break;
}
default: {
uuid += hexList[Math.trunc(Math.random() * 16)];
}
}
}
return uuid.replaceAll('-', '');
}
let unique = 0;
export function buildShortUUID(prefix = ''): string {
const time = Date.now();
const random = Math.floor(Math.random() * 1_000_000_000);
unique++;
return `${prefix}_${random}${unique}${String(time)}`;
}

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 };