chore: rename @vben-core -> @core

This commit is contained in:
vben
2024-06-08 21:27:43 +08:00
parent 1d6b1f4926
commit dcc1fcd528
388 changed files with 63 additions and 113 deletions

3
packages/@core/README.md Normal file
View File

@@ -0,0 +1,3 @@
# @vben-core
系统一些比较基础的SDK和UI组件库该目录后续可能会迁移出去或者发布到npm请勿将任何业务逻辑和业务包放在该目录。

View File

@@ -0,0 +1,3 @@
# @vben-core/forward
该目录内的包可直接被app所引用

View File

@@ -0,0 +1,7 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@@ -0,0 +1,46 @@
{
"name": "@vben-core/helpers",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/forward/helpers"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
"build": "pnpm unbuild",
"stub": "pnpm unbuild --stub"
},
"files": [
"dist"
],
"sideEffects": false,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"vue-router": "^4.3.2"
}
}

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import { flattenObject } from './flatten-object';
describe('flattenObject', () => {
it('should flatten a nested object correctly', () => {
const nestedObject = {
language: 'en',
notifications: {
email: true,
push: {
sound: true,
vibration: false,
},
},
theme: 'light',
};
const expected = {
language: 'en',
notificationsEmail: true,
notificationsPushSound: true,
notificationsPushVibration: false,
theme: 'light',
};
const result = flattenObject(nestedObject);
expect(result).toEqual(expected);
});
it('should handle empty objects', () => {
const nestedObject = {};
const expected = {};
const result = flattenObject(nestedObject);
expect(result).toEqual(expected);
});
it('should handle objects with primitive values', () => {
const nestedObject = {
active: true,
age: 30,
name: 'Alice',
};
const expected = {
active: true,
age: 30,
name: 'Alice',
};
const result = flattenObject(nestedObject);
expect(result).toEqual(expected);
});
it('should handle objects with null values', () => {
const nestedObject = {
user: {
age: null,
name: null,
},
};
const expected = {
userAge: null,
userName: null,
};
const result = flattenObject(nestedObject);
expect(result).toEqual(expected);
});
it('should handle nested empty objects', () => {
const nestedObject = {
a: {},
b: { c: {} },
};
const expected = {};
const result = flattenObject(nestedObject);
expect(result).toEqual(expected);
});
it('should handle arrays within objects', () => {
const nestedObject = {
hobbies: ['reading', 'gaming'],
name: 'Alice',
};
const expected = {
hobbies: ['reading', 'gaming'],
name: 'Alice',
};
const result = flattenObject(nestedObject);
expect(result).toEqual(expected);
});
it('should flatten objects with nested arrays correctly', () => {
const nestedObject = {
person: {
hobbies: ['reading', 'gaming'],
name: 'Alice',
},
};
const expected = {
personHobbies: ['reading', 'gaming'],
personName: 'Alice',
};
const result = flattenObject(nestedObject);
expect(result).toEqual(expected);
});
it('should handle objects with undefined values', () => {
const nestedObject = {
user: {
age: undefined,
name: 'Alice',
},
};
const expected = {
userAge: undefined,
userName: 'Alice',
};
const result = flattenObject(nestedObject);
expect(result).toEqual(expected);
});
});

View File

@@ -0,0 +1,82 @@
import type { Flatten } from '@vben-core/typings';
import { capitalizeFirstLetter } from '@vben-core/toolkit';
/**
* 将嵌套对象扁平化
* @param obj - 需要扁平化的对象
* @param parentKey - 父键名,用于递归时拼接键名
* @param result - 存储结果的对象
* @returns 扁平化后的对象
*
* 示例:
* const nestedObj = {
* user: {
* name: 'Alice',
* address: {
* city: 'Wonderland',
* zip: '12345'
* }
* },
* items: [
* { id: 1, name: 'Item 1' },
* { id: 2, name: 'Item 2' }
* ],
* active: true
* };
* const flatObj = flattenObject(nestedObj);
* console.log(flatObj);
* 输出:
* {
* userName: 'Alice',
* userAddressCity: 'Wonderland',
* userAddressZip: '12345',
* items: [ { id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' } ],
* active: true
* }
*/
function flattenObject<T extends Record<string, any>>(
obj: T,
parentKey: string = '',
result: Record<string, any> = {},
): Flatten<T> {
Object.keys(obj).forEach((key) => {
const newKey = parentKey
? `${parentKey}${capitalizeFirstLetter(key)}`
: key;
const value = obj[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
flattenObject(value, newKey, result);
} else {
result[newKey] = value;
}
});
return result as Flatten<T>;
}
export { flattenObject };
// 定义递归类型,用于推断扁平化后的对象类型
// 限制递归深度的辅助类型
// type FlattenDepth<
// T,
// Depth extends number,
// CurrentDepth extends number[] = [],
// > = {
// [K in keyof T as CurrentDepth['length'] extends Depth
// ? K
// : T[K] extends object
// ? `${CurrentDepth['length'] extends 0 ? Uncapitalize<K & string> : Capitalize<K & string>}${keyof FlattenDepth<T[K], Depth, [...CurrentDepth, 1]> extends string ? Capitalize<keyof FlattenDepth<T[K], Depth, [...CurrentDepth, 1]>> : ''}`
// : `${CurrentDepth['length'] extends 0 ? Uncapitalize<K & string> : Capitalize<K & string>}`]: CurrentDepth['length'] extends Depth
// ? T[K]
// : T[K] extends object
// ? FlattenDepth<T[K], Depth, [...CurrentDepth, 1]>[keyof FlattenDepth<
// T[K],
// Depth,
// [...CurrentDepth, 1]
// >]
// : T[K];
// };
// type Flatten<T, Depth extends number = 4> = FlattenDepth<T, Depth>;

View File

@@ -0,0 +1,230 @@
import { describe, expect, it, vi } from 'vitest';
import { generatorMenus } from './generator-menus'; // 替换为您的实际路径
import {
type RouteRecordRaw,
type Router,
createRouter,
createWebHistory,
} from 'vue-router';
// Nested route setup to test child inclusion and hideChildrenInMenu functionality
describe('generatorMenus', () => {
// 模拟路由数据
const mockRoutes = [
{
meta: { icon: 'home-icon', title: '首页' },
name: 'home',
path: '/home',
},
{
meta: { hideChildrenInMenu: true, icon: 'about-icon', title: '关于' },
name: 'about',
path: '/about',
children: [
{
path: 'team',
name: 'team',
meta: { icon: 'team-icon', title: '团队' },
},
],
},
] as RouteRecordRaw[];
// 模拟 Vue 路由器实例
const mockRouter = {
getRoutes: vi.fn(() => [
{ name: 'home', path: '/home' },
{ name: 'about', path: '/about' },
{ name: 'team', path: '/about/team' },
]),
};
it('the correct menu list should be generated according to the route', async () => {
const expectedMenus = [
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: 'home-icon',
name: '首页',
order: undefined,
parent: undefined,
parents: undefined,
path: '/home',
children: [],
},
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: 'about-icon',
name: '关于',
order: undefined,
parent: undefined,
parents: undefined,
path: '/about',
children: [],
},
];
const menus = await generatorMenus(mockRoutes, mockRouter as any);
expect(menus).toEqual(expectedMenus);
});
it('includes additional meta properties in menu items', async () => {
const mockRoutesWithMeta = [
{
meta: { icon: 'user-icon', order: 1, title: 'Profile' },
name: 'profile',
path: '/profile',
},
] as RouteRecordRaw[];
const menus = await generatorMenus(mockRoutesWithMeta, mockRouter as any);
expect(menus).toEqual([
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: 'user-icon',
name: 'Profile',
order: 1,
parent: undefined,
parents: undefined,
path: '/profile',
children: [],
},
]);
});
it('handles dynamic route parameters correctly', async () => {
const mockRoutesWithParams = [
{
meta: { icon: 'details-icon', title: 'User Details' },
name: 'userDetails',
path: '/users/:userId',
},
] as RouteRecordRaw[];
const menus = await generatorMenus(mockRoutesWithParams, mockRouter as any);
expect(menus).toEqual([
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: 'details-icon',
name: 'User Details',
order: undefined,
parent: undefined,
parents: undefined,
path: '/users/:userId',
children: [],
},
]);
});
it('processes routes with redirects correctly', async () => {
const mockRoutesWithRedirect = [
{
name: 'redirectedRoute',
path: '/old-path',
redirect: '/new-path',
},
{
meta: { icon: 'path-icon', title: 'New Path' },
name: 'newPath',
path: '/new-path',
},
] as RouteRecordRaw[];
const menus = await generatorMenus(
mockRoutesWithRedirect,
mockRouter as any,
);
expect(menus).toEqual([
// Assuming your generatorMenus function excludes redirect routes from the menu
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: undefined,
name: 'redirectedRoute',
order: undefined,
parent: undefined,
parents: undefined,
path: '/old-path',
children: [],
},
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: 'path-icon',
name: 'New Path',
order: undefined,
parent: undefined,
parents: undefined,
path: '/new-path',
children: [],
},
]);
});
const routes: any = [
{
meta: { order: 2, title: 'Home' },
name: 'home',
path: '/',
},
{
meta: { order: 1, title: 'About' },
name: 'about',
path: '/about',
},
];
const router: Router = createRouter({
history: createWebHistory(),
routes,
});
it('should generate menu list with correct order', async () => {
const menus = await generatorMenus(routes, router);
const expectedMenus = [
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: undefined,
name: 'About',
order: 1,
parent: undefined,
parents: undefined,
path: '/about',
children: [],
},
{
badge: undefined,
badgeType: undefined,
badgeVariants: undefined,
icon: undefined,
name: 'Home',
order: 2,
parent: undefined,
parents: undefined,
path: '/',
children: [],
},
];
expect(menus).toEqual(expectedMenus);
});
it('should handle empty routes', async () => {
const emptyRoutes: any[] = [];
const menus = await generatorMenus(emptyRoutes, router);
expect(menus).toEqual([]);
});
});

View File

@@ -0,0 +1,73 @@
import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben-core/typings';
import type { RouteRecordRaw, Router } from 'vue-router';
import { mapTree } from '@vben-core/toolkit';
/**
* 根据 routes 生成菜单列表
* @param routes
*/
async function generatorMenus(
routes: RouteRecordRaw[],
router: Router,
): Promise<MenuRecordRaw[]> {
// 将路由列表转换为一个以 name 为键的对象映射
// 获取所有router最终的path及name
const finalRoutesMap: { [key: string]: string } = Object.fromEntries(
router.getRoutes().map(({ name, path }) => [name, path]),
);
let menus = mapTree<ExRouteRecordRaw, MenuRecordRaw>(routes, (route) => {
// 路由表的路径写法有多种这里从router获取到最终的path并赋值
const path = finalRoutesMap[route.name as string] ?? route.path;
// 转换为菜单结构
// const path = matchRoute?.path ?? route.path;
const { meta, name: routeName, redirect, children } = route;
const {
badge,
badgeType,
badgeVariants,
hideChildrenInMenu = false,
icon,
link,
order,
title = '',
} = meta || {};
const name = (title || routeName || '') as string;
// 隐藏子菜单
const resultChildren = hideChildrenInMenu
? []
: (children as MenuRecordRaw[]);
// 将菜单的所有父级和父级菜单记录到菜单项内
if (resultChildren && resultChildren.length > 0) {
resultChildren.forEach((child) => {
child.parents = [...(route.parents || []), path];
child.parent = path;
});
}
// 隐藏子菜单
const resultPath = hideChildrenInMenu ? redirect || path : link || path;
return {
badge,
badgeType,
badgeVariants,
icon,
name,
order,
parent: route.parent,
parents: route.parents,
path: resultPath as string,
children: resultChildren || [],
};
});
// 对菜单进行排序
menus = menus.sort((a, b) => (a.order || 999) - (b.order || 999));
return menus;
}
export { generatorMenus };

View File

@@ -0,0 +1,128 @@
import type { RouteRecordRaw } from 'vue-router';
import { describe, expect, it } from 'vitest';
import { generatorRoutes, hasAuthority, hasVisible } from './generator-routes';
// Mock 路由数据
const mockRoutes = [
{
meta: {
authority: ['admin', 'user'],
hideInMenu: false,
},
path: '/dashboard',
children: [
{
path: '/dashboard/overview',
meta: { authority: ['admin'], hideInMenu: false },
},
{
path: '/dashboard/stats',
meta: { authority: ['user'], hideInMenu: true },
},
],
},
{
meta: { authority: ['admin'], hideInMenu: false },
path: '/settings',
},
{
meta: { hideInMenu: false },
path: '/profile',
},
] as RouteRecordRaw[];
describe('hasAuthority', () => {
it('should return true if there is no authority defined', () => {
expect(hasAuthority(mockRoutes[2], ['admin'])).toBe(true);
});
it('should return true if the user has the required authority', () => {
expect(hasAuthority(mockRoutes[0], ['admin'])).toBe(true);
});
it('should return false if the user does not have the required authority', () => {
expect(hasAuthority(mockRoutes[1], ['user'])).toBe(false);
});
});
describe('hasVisible', () => {
it('should return true if hideInMenu is not set or false', () => {
expect(hasVisible(mockRoutes[0])).toBe(true);
expect(hasVisible(mockRoutes[2])).toBe(true);
});
it('should return false if hideInMenu is true', () => {
expect(hasVisible(mockRoutes[0].children?.[1])).toBe(false);
});
});
describe('generatorRoutes', () => {
it('should filter routes based on authority and visibility', async () => {
const generatedRoutes = await generatorRoutes(mockRoutes, ['user']);
// The user should have access to /dashboard/stats, but it should be filtered out because it's not visible
expect(generatedRoutes).toEqual([
{
meta: { authority: ['admin', 'user'], hideInMenu: false },
path: '/dashboard',
children: [],
},
// Note: We expect /settings to be filtered out because the user does not have 'admin' authority
{
meta: { hideInMenu: false },
path: '/profile',
},
]);
});
it('should handle routes without children', async () => {
const generatedRoutes = await generatorRoutes(mockRoutes, ['user']);
expect(generatedRoutes).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: '/profile', // This route has no children and should be included
}),
]),
);
});
it('should handle empty roles array', async () => {
const generatedRoutes = await generatorRoutes(mockRoutes, []);
expect(generatedRoutes).toEqual(
expect.arrayContaining([
// Only routes without authority should be included
expect.objectContaining({
path: '/profile',
}),
]),
);
expect(generatedRoutes).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
path: '/dashboard',
}),
expect.objectContaining({
path: '/settings',
}),
]),
);
});
it('should handle missing meta fields', async () => {
const routesWithMissingMeta = [
{ path: '/path1' }, // No meta
{ meta: {}, path: '/path2' }, // Empty meta
{ meta: { authority: ['admin'] }, path: '/path3' }, // Only authority
];
const generatedRoutes = await generatorRoutes(
routesWithMissingMeta as RouteRecordRaw[],
['admin'],
);
expect(generatedRoutes).toEqual([
{ path: '/path1' },
{ meta: {}, path: '/path2' },
{ meta: { authority: ['admin'] }, path: '/path3' },
]);
});
});

View File

@@ -0,0 +1,63 @@
import type { RouteRecordRaw } from 'vue-router';
import { filterTree, mapTree } from '@vben-core/toolkit';
/**
* 动态生成路由
*/
async function generatorRoutes(
routes: RouteRecordRaw[],
roles: string[],
forbiddenPage?: RouteRecordRaw['component'],
): Promise<RouteRecordRaw[]> {
// 根据角色标识过滤路由表,判断当前用户是否拥有指定权限
const finalRoutes = filterTree(routes, (route) => {
return hasVisible(route) && hasAuthority(route, roles);
});
if (!forbiddenPage) {
return finalRoutes;
}
// 如果有禁止访问的页面将禁止访问的页面替换为403页面
return mapTree(finalRoutes, (route) => {
if (menuHasVisibleWithForbidden(route)) {
route.component = forbiddenPage;
}
return route;
});
}
/**
* 判断路由是否有权限访问
* @param route
* @param access
*/
function hasAuthority(route: RouteRecordRaw, access: string[]) {
const authority = route.meta?.authority;
if (!authority) {
return true;
}
return (
access.some((value) => authority.includes(value)) ||
menuHasVisibleWithForbidden(route)
);
}
/**
* 判断路由是否需要在菜单中显示
* @param route
*/
function hasVisible(route?: RouteRecordRaw) {
return !route?.meta?.hideInMenu;
}
/**
* 判断路由是否在菜单中显示但是访问会被重定向到403
* @param route
*/
function menuHasVisibleWithForbidden(route: RouteRecordRaw) {
return !!route.meta?.menuVisibleWithForbidden;
}
export { generatorRoutes, hasAuthority, hasVisible };

View File

@@ -0,0 +1,5 @@
export * from './flatten-object';
export * from './generator-menus';
export * from './generator-routes';
export * from './merge-route-modules';
export * from './nested-object';

View File

@@ -0,0 +1,68 @@
import type { RouteRecordRaw } from 'vue-router';
import type { RouteModuleType } from './merge-route-modules';
import { describe, expect, it } from 'vitest';
import { mergeRouteModules } from './merge-route-modules';
describe('mergeRouteModules', () => {
it('should merge route modules correctly', () => {
const routeModules: Record<string, RouteModuleType> = {
'./dynamic-routes/about.ts': {
default: [
{
component: () => Promise.resolve({ template: '<div>About</div>' }),
name: 'About',
path: '/about',
},
],
},
'./dynamic-routes/home.ts': {
default: [
{
component: () => Promise.resolve({ template: '<div>Home</div>' }),
name: 'Home',
path: '/',
},
],
},
};
const expectedRoutes: RouteRecordRaw[] = [
{
component: expect.any(Function),
name: 'About',
path: '/about',
},
{
component: expect.any(Function),
name: 'Home',
path: '/',
},
];
const mergedRoutes = mergeRouteModules(routeModules);
expect(mergedRoutes).toEqual(expectedRoutes);
});
it('should handle empty modules', () => {
const routeModules: Record<string, RouteModuleType> = {};
const expectedRoutes: RouteRecordRaw[] = [];
const mergedRoutes = mergeRouteModules(routeModules);
expect(mergedRoutes).toEqual(expectedRoutes);
});
it('should handle modules with no default export', () => {
const routeModules: Record<string, RouteModuleType> = {
'./dynamic-routes/empty.ts': {
default: [],
},
};
const expectedRoutes: RouteRecordRaw[] = [];
const mergedRoutes = mergeRouteModules(routeModules);
expect(mergedRoutes).toEqual(expectedRoutes);
});
});

View File

@@ -0,0 +1,28 @@
import type { RouteRecordRaw } from 'vue-router';
// 定义模块类型
interface RouteModuleType {
default: RouteRecordRaw[];
}
/**
* 合并动态路由模块的默认导出
* @param routeModules 动态导入的路由模块对象
* @returns 合并后的路由配置数组
*/
function mergeRouteModules(
routeModules: Record<string, unknown>,
): RouteRecordRaw[] {
const mergedRoutes: RouteRecordRaw[] = [];
for (const routeModule of Object.values(routeModules)) {
const moduleRoutes = (routeModule as RouteModuleType)?.default ?? [];
mergedRoutes.push(...moduleRoutes);
}
return mergedRoutes;
}
export { mergeRouteModules };
export type { RouteModuleType };

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import { nestedObject } from './nested-object';
describe('nestedObject', () => {
it('should convert flat object to nested object with level 1', () => {
const flatObject = {
anotherKeyExample: 2,
commonAppName: 1,
someOtherKey: 3,
};
const expectedNestedObject = {
anotherKeyExample: 2,
commonAppName: 1,
someOtherKey: 3,
};
expect(nestedObject(flatObject, 1)).toEqual(expectedNestedObject);
});
it('should convert flat object to nested object with level 2', () => {
const flatObject = {
appAnotherKeyExample: 2,
appCommonName: 1,
appSomeOtherKey: 3,
};
const expectedNestedObject = {
app: {
anotherKeyExample: 2,
commonName: 1,
someOtherKey: 3,
},
};
expect(nestedObject(flatObject, 2)).toEqual(expectedNestedObject);
});
it('should convert flat object to nested object with level 3', () => {
const flatObject = {
appAnotherKeyExampleValue: 2,
appCommonNameKey: 1,
appSomeOtherKeyItem: 3,
};
const expectedNestedObject = {
app: {
another: {
keyExampleValue: 2,
},
common: {
nameKey: 1,
},
some: {
otherKeyItem: 3,
},
},
};
expect(nestedObject(flatObject, 3)).toEqual(expectedNestedObject);
});
it('should handle empty object', () => {
const flatObject = {};
const expectedNestedObject = {};
expect(nestedObject(flatObject, 1)).toEqual(expectedNestedObject);
});
it('should handle single key object', () => {
const flatObject = {
singleKey: 1,
};
const expectedNestedObject = {
singleKey: 1,
};
expect(nestedObject(flatObject, 1)).toEqual(expectedNestedObject);
});
it('should handle complex keys', () => {
const flatObject = {
anotherComplexKeyWithParts: 2,
complexKeyWithMultipleParts: 1,
};
const expectedNestedObject = {
anotherComplexKeyWithParts: 2,
complexKeyWithMultipleParts: 1,
};
expect(nestedObject(flatObject, 1)).toEqual(expectedNestedObject);
});
it('should correctly nest an object based on the specified level', () => {
const obj = {
oneFiveSix: 'Value156',
oneTwoFour: 'Value124',
oneTwoThree: 'Value123',
};
const nested = nestedObject(obj, 2);
expect(nested).toEqual({
one: {
fiveSix: 'Value156',
twoFour: 'Value124',
twoThree: 'Value123',
},
});
});
});

View File

@@ -0,0 +1,70 @@
import { toLowerCaseFirstLetter } from '@vben-core/toolkit';
/**
* 将扁平对象转换为嵌套对象。
*
* @template T - 输入对象值的类型
* @param {Record<string, T>} obj - 要转换的扁平对象
* @param {number} level - 嵌套的层级
* @returns {T} 嵌套对象
*
* @example
* 将扁平对象转换为嵌套对象,嵌套层级为 1
* const flatObject = {
* 'commonAppName': 1,
* 'anotherKeyExample': 2,
* 'someOtherKey': 3
* };
* const nestedObject = nestedObject(flatObject, 1);
* console.log(nestedObject);
* 输出:
* {
* commonAppName: 1,
* anotherKeyExample: 2,
* someOtherKey: 3
* }
*
* @example
* 将扁平对象转换为嵌套对象,嵌套层级为 2
* const flatObject = {
* 'appCommonName': 1,
* 'appAnotherKeyExample': 2,
* 'appSomeOtherKey': 3
* };
* const nestedObject = nestedObject(flatObject, 2);
* console.log(nestedObject);
* 输出:
* {
* app: {
* commonName: 1,
* anotherKeyExample: 2,
* someOtherKey: 3
* }
* }
*/
function nestedObject<T>(obj: Record<string, T>, level: number): T {
const result: any = {};
for (const key in obj) {
const keys = key.split(/(?=[A-Z])/);
// 将驼峰式分割为数组;
let current = result;
for (let i = 0; i < keys.length; i++) {
const lowerKey = keys[i].toLowerCase();
if (i === level - 1) {
const remainingKeys = keys.slice(i).join(''); // 保留后续部分作为键的一部分
current[toLowerCaseFirstLetter(remainingKeys)] = obj[key];
break;
} else {
current[lowerKey] = current[lowerKey] || {};
current = current[lowerKey];
}
}
}
return result as T;
}
export { nestedObject };

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"compilerOptions": {
"types": ["@vben-core/typings/vue-router"]
},
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,7 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@@ -0,0 +1,42 @@
{
"name": "@vben-core/preferences",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/forward/preferences"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
"build": "pnpm unbuild",
"stub": "pnpm unbuild --stub"
},
"files": [
"dist",
"src"
],
"sideEffects": [
"**/*.css"
],
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"dependencies": {
"@vben-core/cache": "workspace:*",
"@vben-core/helpers": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "^10.10.0",
"vue": "3.4.27"
}
}

View File

@@ -0,0 +1,73 @@
import type { Preferences } from './types';
const defaultPreferences: Preferences = {
app: {
authPageLayout: 'panel-right',
colorGrayMode: false,
colorWeakMode: false,
compact: false,
contentCompact: 'wide',
copyright: 'Copyright © 2024 Vben Admin PRO',
defaultAvatar:
'https://cdn.jsdelivr.net/npm/@vbenjs/static-source@0.1.0/source/avatar-v1.webp',
dynamicTitle: true,
isMobile: false,
layout: 'side-nav',
locale: 'zh-CN',
name: 'Vben Admin Pro',
semiDarkMenu: true,
showPreference: true,
themeMode: 'dark',
},
breadcrumb: {
enable: true,
hideOnlyOne: false,
showHome: false,
showIcon: true,
styleType: 'normal',
},
footer: {
enable: true,
fixed: true,
},
header: {
enable: true,
hidden: false,
mode: 'fixed',
},
logo: {
enable: true,
source:
'https://cdn.jsdelivr.net/npm/@vbenjs/static-source@0.1.0/source/logo-v1.webp',
},
navigation: {
accordion: true,
split: true,
styleType: 'rounded',
},
shortcutKeys: { enable: true },
sidebar: {
collapse: false,
collapseShowTitle: true,
enable: true,
expandOnHover: true,
extraCollapse: true,
hidden: false,
width: 240,
},
tabbar: {
enable: true,
keepAlive: true,
showIcon: true,
},
theme: {
colorPrimary: 'hsl(211 91% 39%)',
},
transition: {
enable: true,
name: 'fade-slide',
progress: true,
},
};
export { defaultPreferences };

View File

@@ -0,0 +1,26 @@
import type { SupportedLanguagesType } from './types';
interface Language {
key: SupportedLanguagesType;
text: string;
}
export const COLOR_PRIMARY_RESETS = [
'hsl(211 91% 39%)',
'hsl(212 100% 45%)',
'hsl(181 84% 32%)',
'hsl(230 99% 66%)',
'hsl(245 82% 67%)',
'hsl(340 100% 68%)',
];
export const SUPPORT_LANGUAGES: Language[] = [
{
key: 'zh-CN',
text: '简体中文',
},
{
key: 'en-US',
text: 'English',
},
];

View File

@@ -0,0 +1,32 @@
import type { Flatten } from '@vben-core/typings';
import type { Preferences } from './types';
import { preferencesManager } from './preferences';
// 偏好设置(带有层级关系)
const preferences: Preferences = preferencesManager.getPreferences();
// 扁平化后的偏好设置
const flatPreferences: Flatten<Preferences> =
preferencesManager.getFlatPreferences();
// 更新偏好设置
const updatePreferences =
preferencesManager.updatePreferences.bind(preferencesManager);
// 重置偏好设置
const resetPreferences =
preferencesManager.resetPreferences.bind(preferencesManager);
export {
flatPreferences,
preferences,
preferencesManager,
resetPreferences,
updatePreferences,
};
export * from './constants';
export type * from './types';
export * from './use-preferences';

View File

@@ -0,0 +1,268 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { defaultPreferences } from './config';
import { PreferenceManager, isDarkTheme } from './preferences';
describe('preferences', () => {
let preferenceManager: PreferenceManager;
vi.mock('@vben-core/cache', () => {
return {
StorageManager: vi.fn().mockImplementation(() => {
return {
getItem: vi.fn(),
removeItem: vi.fn(),
setItem: vi.fn(),
};
}),
};
});
// 模拟 window.matchMedia 方法
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query) => ({
addEventListener: vi.fn(),
addListener: vi.fn(), // Deprecated
dispatchEvent: vi.fn(),
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
removeEventListener: vi.fn(),
removeListener: vi.fn(), // Deprecated
})),
);
beforeEach(() => {
preferenceManager = new PreferenceManager();
});
it('initPreferences should initialize preferences with overrides and namespace', async () => {
const overrides = { theme: { colorPrimary: 'hsl(211 91% 39%)' } };
const namespace = 'testNamespace';
await preferenceManager.initPreferences({ namespace, overrides });
expect(preferenceManager.getPreferences().theme.colorPrimary).toBe(
overrides.theme.colorPrimary,
);
});
it('loads default preferences if no saved preferences found', () => {
const preferences = preferenceManager.getPreferences();
expect(preferences).toEqual(defaultPreferences);
});
it('initializes preferences with overrides', async () => {
const overrides: any = {
app: {
locale: 'en-US',
themeMode: 'light',
},
};
await preferenceManager.initPreferences({
namespace: 'testNamespace',
overrides,
});
// 等待防抖动操作完成
// await new Promise((resolve) => setTimeout(resolve, 300)); // 等待100毫秒
const expected = {
...defaultPreferences,
app: {
...defaultPreferences.app,
...overrides.app,
},
};
expect(preferenceManager.getPreferences()).toEqual(expected);
});
it('updates theme mode correctly', () => {
preferenceManager.updatePreferences({
app: { themeMode: 'light' },
});
expect(preferenceManager.getPreferences().app.themeMode).toBe('light');
});
it('updates color modes correctly', () => {
preferenceManager.updatePreferences({
app: { colorGrayMode: true, colorWeakMode: true },
});
expect(preferenceManager.getPreferences().app.colorGrayMode).toBe(true);
expect(preferenceManager.getPreferences().app.colorWeakMode).toBe(true);
});
it('resets preferences to default', () => {
// 先更新一些偏好设置
preferenceManager.updatePreferences({
app: { themeMode: 'light' },
});
// 然后重置偏好设置
preferenceManager.resetPreferences();
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
});
it('updates isMobile correctly', () => {
// 模拟移动端状态
vi.stubGlobal(
'matchMedia',
vi.fn().mockImplementation((query) => ({
addEventListener: vi.fn(),
addListener: vi.fn(),
dispatchEvent: vi.fn(),
matches: query === '(max-width: 768px)',
media: query,
onchange: null,
removeEventListener: vi.fn(),
removeListener: vi.fn(),
})),
);
preferenceManager.updatePreferences({
app: { isMobile: true },
});
expect(preferenceManager.getPreferences().app.isMobile).toBe(true);
});
it('updates the locale preference correctly', () => {
preferenceManager.updatePreferences({
app: { locale: 'en-US' },
});
expect(preferenceManager.getPreferences().app.locale).toBe('en-US');
});
it('updates the sidebar width correctly', () => {
preferenceManager.updatePreferences({
sidebar: { width: 200 },
});
expect(preferenceManager.getPreferences().sidebar.width).toBe(200);
});
it('updates the sidebar collapse state correctly', () => {
preferenceManager.updatePreferences({
sidebar: { collapse: true },
});
expect(preferenceManager.getPreferences().sidebar.collapse).toBe(true);
});
it('updates the navigation style type correctly', () => {
preferenceManager.updatePreferences({
navigation: { styleType: 'flat' },
} as any);
expect(preferenceManager.getPreferences().navigation.styleType).toBe(
'flat',
);
});
it('resets preferences to default correctly', () => {
// 先更新一些偏好设置
preferenceManager.updatePreferences({
app: { locale: 'en-US', themeMode: 'light' },
sidebar: { collapse: true, width: 200 },
});
// 然后重置偏好设置
preferenceManager.resetPreferences();
expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
});
it('does not update undefined preferences', () => {
const originalPreferences = preferenceManager.getPreferences();
preferenceManager.updatePreferences({
app: { nonexistentField: 'value' },
} as any);
expect(preferenceManager.getPreferences()).toEqual(originalPreferences);
});
it('reverts to default when a preference field is deleted', () => {
preferenceManager.updatePreferences({
app: { locale: 'en-US' },
});
preferenceManager.updatePreferences({
app: { locale: undefined },
});
expect(preferenceManager.getPreferences().app.locale).toBe('en-US');
});
it('ignores updates with invalid preference value types', () => {
const originalPreferences = preferenceManager.getPreferences();
preferenceManager.updatePreferences({
app: { isMobile: 'true' as unknown as boolean }, // 错误类型
});
expect(preferenceManager.getPreferences()).toEqual(originalPreferences);
});
it('merges nested preference objects correctly', () => {
preferenceManager.updatePreferences({
app: { name: 'New App Name' },
});
const expected = {
...defaultPreferences,
app: {
...defaultPreferences.app,
name: 'New App Name',
},
};
expect(preferenceManager.getPreferences()).toEqual(expected);
});
it('applies updates immediately after initialization', async () => {
const overrides: any = {
app: {
locale: 'en-US',
},
};
await preferenceManager.initPreferences(overrides);
preferenceManager.updatePreferences({
app: { themeMode: 'light' },
});
expect(preferenceManager.getPreferences().app.themeMode).toBe('light');
});
});
describe('isDarkTheme', () => {
it('should return true for dark theme', () => {
expect(isDarkTheme('dark')).toBe(true);
});
it('should return false for light theme', () => {
expect(isDarkTheme('light')).toBe(false);
});
it('should return system preference for auto theme', () => {
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
addEventListener: vi.fn(),
addListener: vi.fn(), // Deprecated
dispatchEvent: vi.fn(),
matches: query === '(prefers-color-scheme: dark)',
media: query,
onchange: null,
removeEventListener: vi.fn(),
removeListener: vi.fn(), // Deprecated
}));
expect(isDarkTheme('auto')).toBe(true);
expect(window.matchMedia).toHaveBeenCalledWith(
'(prefers-color-scheme: dark)',
);
});
});

View File

@@ -0,0 +1,303 @@
import type {
DeepPartial,
Flatten,
FlattenObjectKeys,
} from '@vben-core/typings';
import type { Preferences } from './types';
import { markRaw, reactive, watch } from 'vue';
import { StorageManager } from '@vben-core/cache';
import { flattenObject, nestedObject } from '@vben-core/helpers';
import { convertToHslCssVar, merge } from '@vben-core/toolkit';
import {
breakpointsTailwind,
useBreakpoints,
useCssVar,
useDebounceFn,
} from '@vueuse/core';
import { defaultPreferences } from './config';
const STORAGE_KEY = 'preferences';
const STORAGE_KEY_LOCALE = `${STORAGE_KEY}-locale`;
const STORAGE_KEY_THEME = `${STORAGE_KEY}-theme`;
interface initialOptions {
namespace: string;
overrides?: DeepPartial<Preferences>;
}
function isDarkTheme(theme: string) {
let dark = theme === 'dark';
if (theme === 'auto') {
dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return dark;
}
class PreferenceManager {
private cache: StorageManager | null = null;
private flattenedState: Flatten<Preferences>;
private initialPreferences: Preferences = defaultPreferences;
private isInitialized: boolean = false;
private savePreferences: (preference: Preferences) => void;
private state: Preferences = reactive<Preferences>({
...this.loadPreferences(),
});
constructor() {
this.cache = new StorageManager();
this.flattenedState = reactive(flattenObject(this.state));
this.savePreferences = useDebounceFn(
(preference: Preferences) => this._savePreferences(preference),
100,
);
}
/**
* 保存偏好设置
* @param {Preferences} preference - 需要保存的偏好设置
*/
private _savePreferences(preference: Preferences) {
this.cache?.setItem(STORAGE_KEY, preference);
this.cache?.setItem(STORAGE_KEY_LOCALE, preference.app.locale);
this.cache?.setItem(STORAGE_KEY_THEME, preference.app.themeMode);
}
/**
* 处理更新的键值
* 根据更新的键值执行相应的操作。
*
* @param {DeepPartial<Preferences>} updates - 部分更新的偏好设置
*/
private handleUpdates(updates: DeepPartial<Preferences>) {
const themeUpdates = updates.theme || {};
const appUpdates = updates.app || {};
if (themeUpdates.colorPrimary) {
this.updateCssVar(this.state);
}
if (appUpdates.themeMode) {
this.updateTheme(this.state);
}
if (appUpdates.colorGrayMode || appUpdates.colorWeakMode) {
this.updateColorMode(this.state);
}
}
/**
* 从缓存中加载偏好设置。如果缓存中没有找到对应的偏好设置,则返回默认偏好设置。
*/
private loadCachedPreferences() {
return this.cache?.getItem<Preferences>(STORAGE_KEY);
}
/**
* 加载偏好设置
* @returns {Preferences} 加载的偏好设置
*/
private loadPreferences(): Preferences {
return this.loadCachedPreferences() || { ...defaultPreferences };
}
/**
* 监听状态和系统偏好设置的变化。
*/
private setupWatcher() {
if (this.isInitialized) {
return;
}
const debounceWaterState = useDebounceFn(() => {
const newFlattenedState = flattenObject(this.state);
for (const k in newFlattenedState) {
const key = k as FlattenObjectKeys<Preferences>;
this.flattenedState[key] = newFlattenedState[key];
}
this.savePreferences(this.state);
}, 16);
const debounceWaterFlattenedState = useDebounceFn(
(val: Flatten<Preferences>) => {
this.updateState(val);
this.savePreferences(this.state);
},
16,
);
// 监听 state 的变化
watch(this.state, debounceWaterState, { deep: true });
// 监听 flattenedState 的变化并触发 set 方法
watch(this.flattenedState, debounceWaterFlattenedState, { deep: true });
// 监听断点,判断是否移动端
const breakpoints = useBreakpoints(breakpointsTailwind);
const isMobile = breakpoints.smaller('md');
watch(
() => isMobile.value,
(val) => {
this.updatePreferences({
app: { isMobile: val },
});
},
{ immediate: true },
);
// 监听系统主题偏好设置变化
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', ({ matches: isDark }) => {
this.updatePreferences({
app: { themeMode: isDark ? 'dark' : 'light' },
});
this.updateTheme(this.state);
});
}
/**
* 更新页面颜色模式(灰色、色弱)
* @param preference
*/
private updateColorMode(preference: Preferences) {
if (preference.app) {
const { colorGrayMode, colorWeakMode } = preference.app;
const body = document.body;
const COLOR_WEAK = 'invert-mode';
const COLOR_GRAY = 'grayscale-mode';
colorWeakMode
? body.classList.add(COLOR_WEAK)
: body.classList.remove(COLOR_WEAK);
colorGrayMode
? body.classList.add(COLOR_GRAY)
: body.classList.remove(COLOR_GRAY);
}
}
/**
* 更新 CSS 变量
* @param preference - 当前偏好设置对象,它的颜色值将被转换成 HSL 格式并设置为 CSS 变量。
*/
private updateCssVar(preference: Preferences) {
if (preference.theme) {
for (const [key, value] of Object.entries(preference.theme)) {
if (['colorPrimary'].includes(key)) {
const cssVarKey = key.replaceAll(/([A-Z])/g, '-$1').toLowerCase();
const cssVarValue = useCssVar(`--${cssVarKey}`);
cssVarValue.value = convertToHslCssVar(value);
}
}
}
}
/**
* 更新状态
* 将新的扁平对象转换为嵌套对象,并与当前状态合并。
* @param {FlattenObject<Preferences>} newValue - 新的扁平对象
*/
private updateState(newValue: Flatten<Preferences>) {
const nestObj = nestedObject(newValue, 2);
Object.assign(this.state, merge(nestObj, this.state));
}
/**
* 更新主题
* @param preferences - 当前偏好设置对象,它的主题值将被用来设置文档的主题。
*/
private updateTheme(preferences: Preferences) {
// 当修改到颜色变量时,更新 css 变量
const root = document.documentElement;
if (root) {
const themeMode = preferences?.app?.themeMode;
if (!themeMode) {
return;
}
const dark = isDarkTheme(themeMode);
root.classList.toggle('dark', dark);
}
}
public getFlatPreferences() {
return this.flattenedState;
}
public getInitialPreferences() {
return this.initialPreferences;
}
public getPreferences() {
return this.state;
}
/**
* 覆盖偏好设置
* overrides 要覆盖的偏好设置
* namespace 命名空间
*/
public async initPreferences({ namespace, overrides }: initialOptions) {
// 是否初始化过
if (this.isInitialized) {
return;
}
// 初始化存储管理器
this.cache = new StorageManager({ prefix: namespace });
// 合并初始偏好设置
this.initialPreferences = merge({}, overrides, defaultPreferences);
// 加载并合并当前存储的偏好设置
const mergedPreference = merge({}, this.loadCachedPreferences(), overrides);
// 更新偏好设置
this.updatePreferences(mergedPreference);
this.setupWatcher();
// 标记为已初始化
this.isInitialized = true;
}
/**
* 重置偏好设置
* 偏好设置将被重置为初始值,并从 localStorage 中移除。
*
* @example
* 假设 initialPreferences 为 { theme: 'light', language: 'en' }
* 当前 state 为 { theme: 'dark', language: 'fr' }
* this.resetPreferences();
* 调用后state 将被重置为 { theme: 'light', language: 'en' }
* 并且 localStorage 中的对应项将被移除
*/
resetPreferences() {
// 将状态重置为初始偏好设置
Object.assign(this.state, this.initialPreferences);
// 保存重置后的偏好设置
this.savePreferences(this.state);
// 从存储中移除偏好设置项
this.cache?.removeItem(STORAGE_KEY);
this.cache?.removeItem(STORAGE_KEY_THEME);
this.cache?.removeItem(STORAGE_KEY_LOCALE);
}
/**
* 更新偏好设置
* @param updates - 要更新的偏好设置
*/
public updatePreferences(updates: DeepPartial<Preferences>) {
const mergedState = merge({}, updates, markRaw(this.state));
Object.assign(this.state, mergedState);
Object.assign(this.flattenedState, flattenObject(this.state));
// 根据更新的键值执行相应的操作
this.handleUpdates(updates);
this.savePreferences(this.state);
}
}
const preferencesManager = new PreferenceManager();
export { PreferenceManager, isDarkTheme, preferencesManager };

View File

@@ -0,0 +1,189 @@
import type {
ContentCompactType,
LayoutHeaderModeType,
LayoutType,
SupportedLanguagesType,
ThemeModeType,
} from '@vben-core/typings';
type BreadcrumbStyleType = 'background' | 'normal';
type NavigationStyleType = 'plain' | 'rounded';
type PageTransitionType = 'fade-slide';
type AuthPageLayoutType = 'panel-center' | 'panel-left' | 'panel-right';
interface AppPreferences {
/** 登录注册页面布局 */
authPageLayout: AuthPageLayoutType;
/** 是否开启灰色模式 */
colorGrayMode: boolean;
/** 是否开启色弱模式 */
colorWeakMode: boolean;
/** 是否开启紧凑模式 */
compact: boolean;
/** 是否开启内容紧凑模式 */
contentCompact: ContentCompactType;
/** 页脚Copyright */
copyright: string;
// /** 应用默认头像 */
defaultAvatar: string;
// /** 开启动态标题 */
dynamicTitle: boolean;
/** 是否移动端 */
isMobile: boolean;
/** 布局方式 */
layout: LayoutType;
/** 支持的语言 */
locale: SupportedLanguagesType;
/** 应用名 */
name: string;
/** 是否开启半深色菜单只在theme='light'时生效) */
semiDarkMenu: boolean;
/** 是否显示偏好设置 */
showPreference: boolean;
/** 当前主题 */
themeMode: ThemeModeType;
}
interface BreadcrumbPreferences {
/** 面包屑是否启用 */
enable: boolean;
/** 面包屑是否只有一个时隐藏 */
hideOnlyOne: boolean;
/** 面包屑首页图标是否可见 */
showHome: boolean;
/** 面包屑图标是否可见 */
showIcon: boolean;
/** 面包屑风格 */
styleType: BreadcrumbStyleType;
}
interface FooterPreferences {
/** 底栏是否可见 */
enable: boolean;
/** 底栏是否固定 */
fixed: boolean;
}
interface HeaderPreferences {
/** 顶栏是否启用 */
enable: boolean;
/** 顶栏是否隐藏,css-隐藏 */
hidden: boolean;
/** header显示模式 */
mode: LayoutHeaderModeType;
}
interface LogoPreferences {
/** logo是否可见 */
enable: boolean;
/** logo地址 */
source: string;
}
interface NavigationPreferences {
/** 导航菜单手风琴模式 */
accordion: boolean;
/** 导航菜单是否切割,只在 layout=mixed-nav 生效 */
split: boolean;
/** 导航菜单风格 */
styleType: NavigationStyleType;
}
interface SidebarPreferences {
/** 侧边栏是否折叠 */
collapse: boolean;
/** 侧边栏折叠时是否显示title */
collapseShowTitle: boolean;
/** 侧边栏是否可见 */
enable: boolean;
/** 菜单自动展开状态 */
expandOnHover: boolean;
/** 侧边栏扩展区域是否折叠 */
extraCollapse: boolean;
/** 侧边栏是否隐藏 - css */
hidden: boolean;
/** 侧边栏宽度 */
width: number;
}
interface ShortcutKeyPreferences {
/** 是否启用快捷键-全局 */
enable: boolean;
}
interface TabbarPreferences {
/** 是否开启多标签页 */
enable: boolean;
/** 开启标签页缓存功能 */
keepAlive: boolean;
/** 是否开启多标签页图标 */
showIcon: boolean;
}
interface ThemePreferences {
/** 主题色 */
colorPrimary: string;
}
interface TransitionPreferences {
/** 页面切换动画是否启用 */
enable: boolean;
/** 页面切换动画 */
name: PageTransitionType;
/** 是否开启页面加载进度动画 */
progress: boolean;
}
interface Preferences {
/** 全局配置 */
app: AppPreferences;
/** 顶栏配置 */
breadcrumb: BreadcrumbPreferences;
/** 底栏配置 */
footer: FooterPreferences;
/** 面包屑配置 */
header: HeaderPreferences;
/** logo配置 */
logo: LogoPreferences;
/** 导航配置 */
navigation: NavigationPreferences;
/** 快捷键配置 */
shortcutKeys: ShortcutKeyPreferences;
/** 侧边栏配置 */
sidebar: SidebarPreferences;
/** 标签页配置 */
tabbar: TabbarPreferences;
/** 主题配置 */
theme: ThemePreferences;
/** 动画配置 */
transition: TransitionPreferences;
}
type PreferencesKeys = keyof Preferences;
export type {
AppPreferences,
AuthPageLayoutType,
BreadcrumbPreferences,
BreadcrumbStyleType,
ContentCompactType,
FooterPreferences,
HeaderPreferences,
LayoutHeaderModeType,
LayoutType,
LogoPreferences,
NavigationPreferences,
PageTransitionType,
Preferences,
PreferencesKeys,
ShortcutKeyPreferences,
SidebarPreferences,
SupportedLanguagesType,
TabbarPreferences,
ThemeModeType,
ThemePreferences,
TransitionPreferences,
};

View File

@@ -0,0 +1,123 @@
import { computed } from 'vue';
import { diff } from '@vben-core/toolkit';
import { isDarkTheme, preferencesManager } from './preferences';
function usePreferences() {
const preferences = preferencesManager.getPreferences();
const flatPreferences = preferencesManager.getFlatPreferences();
const initialPreferences = preferencesManager.getInitialPreferences();
/**
* @zh_CN 计算偏好设置的变化
*/
const diffPreference = computed(() => {
return diff(initialPreferences, preferences);
});
/**
* @zh_CN 判断是否为暗黑模式
* @param preferences - 当前偏好设置对象,它的主题值将被用来判断是否为暗黑模式。
* @returns 如果主题为暗黑模式,返回 true否则返回 false。
*/
const isDark = computed(() => {
return isDarkTheme(flatPreferences.appThemeMode);
});
const theme = computed(() => {
return isDark.value ? 'dark' : 'light';
});
/**
* @zh_CN 布局方式
*/
const layout = computed(() =>
flatPreferences.appIsMobile ? 'side-nav' : flatPreferences.appLayout,
);
/**
* @zh_CN 是否全屏显示content不需要侧边、底部、顶部、tab区域
*/
const isFullContent = computed(
() => flatPreferences.appLayout === 'full-content',
);
/**
* @zh_CN 是否侧边导航模式
*/
const isSideNav = computed(() => flatPreferences.appLayout === 'side-nav');
/**
* @zh_CN 是否侧边混合模式
*/
const isSideMixedNav = computed(
() => flatPreferences.appLayout === 'side-mixed-nav',
);
/**
* @zh_CN 是否为头部导航模式
*/
const isHeaderNav = computed(
() => flatPreferences.appLayout === 'header-nav',
);
/**
* @zh_CN 是否为混合导航模式
*/
const isMixedNav = computed(() => flatPreferences.appLayout === 'mixed-nav');
/**
* @zh_CN 是否包含侧边导航模式
*/
const isSideMode = computed(() => {
return isMixedNav.value || isSideMixedNav.value || isSideNav.value;
});
/**
* @zh_CN 是否开启keep-alive
* 在tabs可见以及开启keep-alive的情况下才开启
*/
const keepAlive = computed(
() => flatPreferences.tabbarKeepAlive && flatPreferences.tabbarEnable,
);
/**
* @zh_CN 登录注册页面布局是否为左侧
*/
const authPanelLeft = computed(() => {
return flatPreferences.appAuthPageLayout === 'panel-left';
});
/**
* @zh_CN 登录注册页面布局是否为左侧
*/
const authPanelRight = computed(() => {
return flatPreferences.appAuthPageLayout === 'panel-right';
});
/**
* @zh_CN 登录注册页面布局是否为中间
*/
const authPanelCenter = computed(() => {
return flatPreferences.appAuthPageLayout === 'panel-center';
});
return {
authPanelCenter,
authPanelLeft,
authPanelRight,
diffPreference,
isDark,
isFullContent,
isHeaderNav,
isMixedNav,
isSideMixedNav,
isSideMode,
isSideNav,
keepAlive,
layout,
theme,
};
}
export { usePreferences };

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,7 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@@ -0,0 +1,51 @@
{
"name": "@vben-core/request",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/forward/request"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
"build": "pnpm unbuild",
"stub": "pnpm unbuild --stub"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@vben-core/toolkit": "workspace:*",
"axios": "^1.7.2",
"vue-request": "^2.0.4"
},
"devDependencies": {
"axios-mock-adapter": "^1.22.0"
}
}

View File

@@ -0,0 +1,3 @@
export * from './request-client';
export * from './use-request';
export * from 'axios';

View File

@@ -0,0 +1,3 @@
export * from './request-client';
export type * from './types';
export * from './util';

View File

@@ -0,0 +1,127 @@
import type { AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AxiosCanceler } from './canceler';
describe('axiosCanceler', () => {
let axiosCanceler: AxiosCanceler;
beforeEach(() => {
axiosCanceler = new AxiosCanceler();
});
it('should generate a unique request key', () => {
const config: AxiosRequestConfig = {
data: { name: 'test' },
method: 'get',
params: { id: 1 },
url: '/test',
};
const requestKey = axiosCanceler.getRequestKey(config);
expect(requestKey).toBe('get:/test:{"id":1}:{"name":"test"}');
});
it('should add a request and create an AbortController', () => {
const config: InternalAxiosRequestConfig = {
data: { name: 'test' },
method: 'get',
params: { id: 1 },
url: '/test',
} as InternalAxiosRequestConfig;
const updatedConfig = axiosCanceler.addRequest(config);
expect(updatedConfig.signal).toBeInstanceOf(AbortSignal);
});
it('should cancel an existing request if a duplicate is added', () => {
const config: InternalAxiosRequestConfig = {
data: { name: 'test' },
method: 'get',
params: { id: 1 },
url: '/test',
} as InternalAxiosRequestConfig;
axiosCanceler.addRequest(config);
const controller = axiosCanceler.pending.get(
'get:/test:{"id":1}:{"name":"test"}',
);
expect(controller).toBeDefined();
if (controller) {
const spy = vi.spyOn(controller, 'abort');
axiosCanceler.addRequest(config);
expect(spy).toHaveBeenCalled();
}
});
it('should remove a request', () => {
const config: AxiosRequestConfig = {
data: { name: 'test' },
method: 'get',
params: { id: 1 },
url: '/test',
};
axiosCanceler.addRequest(config as InternalAxiosRequestConfig);
axiosCanceler.removeRequest(config);
expect(axiosCanceler.pending.size).toBe(0);
});
it('should remove all pending requests', () => {
const config1: InternalAxiosRequestConfig = {
data: { name: 'test1' },
method: 'get',
params: { id: 1 },
url: '/test1',
} as InternalAxiosRequestConfig;
const config2: InternalAxiosRequestConfig = {
data: { name: 'test2' },
method: 'get',
params: { id: 2 },
url: '/test2',
} as InternalAxiosRequestConfig;
axiosCanceler.addRequest(config1);
axiosCanceler.addRequest(config2);
axiosCanceler.removeAllPending();
expect(axiosCanceler.pending.size).toBe(0);
});
it('should handle empty config gracefully', () => {
const config = {} as InternalAxiosRequestConfig;
const updatedConfig = axiosCanceler.addRequest(config);
expect(updatedConfig.signal).toBeInstanceOf(AbortSignal);
});
it('should handle undefined params and data gracefully', () => {
const config: InternalAxiosRequestConfig = {
method: 'get',
url: '/test',
} as InternalAxiosRequestConfig;
const requestKey = axiosCanceler.getRequestKey(config);
expect(requestKey).toBe('get:/test:{}:{}');
});
it('should not abort if no controller exists for the request key', () => {
const config: InternalAxiosRequestConfig = {
data: { name: 'test' },
method: 'get',
params: { id: 1 },
url: '/test',
} as InternalAxiosRequestConfig;
const requestKey = axiosCanceler.getRequestKey(config);
const spy = vi.spyOn(AbortController.prototype, 'abort');
axiosCanceler.addRequest(config);
axiosCanceler.pending.delete(requestKey);
axiosCanceler.addRequest(config);
expect(spy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,52 @@
import type {
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
class AxiosCanceler {
public pending: Map<string, AbortController> = new Map();
// 添加请求
public addRequest(
config: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig {
const requestKey = this.getRequestKey(config);
if (this.pending.has(requestKey)) {
// 如果存在相同的请求,取消前一个请求
const controller = this.pending.get(requestKey);
controller?.abort();
}
// 创建新的AbortController并添加到pending中
const controller = new AbortController();
config.signal = controller.signal;
this.pending.set(requestKey, controller);
return config;
}
// 生成请求的唯一标识
public getRequestKey(config: AxiosRequestConfig): string {
const { data = {}, method, params = {}, url } = config;
return `${method}:${url}:${JSON.stringify(params)}:${JSON.stringify(data)}`;
}
/**
* 清除所有等待中的请求
*/
public removeAllPending(): void {
for (const [, abortController] of this.pending) {
abortController?.abort();
}
this.pending.clear();
}
// 移除请求
public removeRequest(config: AxiosRequestConfig | AxiosResponse): void {
const requestKey = this.getRequestKey(config);
this.pending.delete(requestKey);
}
}
export { AxiosCanceler };

View File

@@ -0,0 +1,84 @@
import type { AxiosRequestConfig } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileDownloader } from './downloader';
describe('fileDownloader', () => {
let fileDownloader: FileDownloader;
const mockAxiosInstance = {
get: vi.fn(),
} as any;
beforeEach(() => {
fileDownloader = new FileDownloader(mockAxiosInstance);
});
it('should create an instance of FileDownloader', () => {
expect(fileDownloader).toBeInstanceOf(FileDownloader);
});
it('should download a file and return a Blob', async () => {
const url = 'https://example.com/file';
const mockBlob = new Blob(['file content'], { type: 'text/plain' });
const mockResponse: Blob = mockBlob;
mockAxiosInstance.get.mockResolvedValueOnce(mockResponse);
const result = await fileDownloader.download(url);
expect(result).toBeInstanceOf(Blob);
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
responseType: 'blob',
});
});
it('should merge provided config with default config', async () => {
const url = 'https://example.com/file';
const mockBlob = new Blob(['file content'], { type: 'text/plain' });
const mockResponse: Blob = mockBlob;
mockAxiosInstance.get.mockResolvedValueOnce(mockResponse);
const customConfig: AxiosRequestConfig = {
headers: { 'Custom-Header': 'value' },
};
const result = await fileDownloader.download(url, customConfig);
expect(result).toBeInstanceOf(Blob);
expect(result).toEqual(mockBlob);
expect(mockAxiosInstance.get).toHaveBeenCalledWith(url, {
...customConfig,
responseType: 'blob',
});
});
it('should handle errors gracefully', async () => {
const url = 'https://example.com/file';
mockAxiosInstance.get.mockRejectedValueOnce(new Error('Network Error'));
await expect(fileDownloader.download(url)).rejects.toThrow('Network Error');
});
it('should handle empty URL gracefully', async () => {
const url = '';
mockAxiosInstance.get.mockRejectedValueOnce(
new Error('Request failed with status code 404'),
);
await expect(fileDownloader.download(url)).rejects.toThrow(
'Request failed with status code 404',
);
});
it('should handle null URL gracefully', async () => {
const url = null as unknown as string;
mockAxiosInstance.get.mockRejectedValueOnce(
new Error('Request failed with status code 404'),
);
await expect(fileDownloader.download(url)).rejects.toThrow(
'Request failed with status code 404',
);
});
});

View File

@@ -0,0 +1,30 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import type { RequestClient } from '../request-client';
class FileDownloader {
private client: RequestClient;
constructor(client: RequestClient) {
this.client = client;
}
public async download(
url: string,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<Blob>> {
const finalConfig: AxiosRequestConfig = {
...config,
responseType: 'blob',
};
const response = await this.client.get<AxiosResponse<Blob>>(
url,
finalConfig,
);
return response;
}
}
export { FileDownloader };

View File

@@ -0,0 +1,33 @@
import {
AxiosInstance,
AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios';
class InterceptorManager {
private axiosInstance: AxiosInstance;
constructor(instance: AxiosInstance) {
this.axiosInstance = instance;
}
addRequestInterceptor(
fulfilled: (
config: InternalAxiosRequestConfig,
) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>,
rejected?: (error: any) => any,
) {
this.axiosInstance.interceptors.request.use(fulfilled, rejected);
}
addResponseInterceptor(
fulfilled: (
response: AxiosResponse,
) => AxiosResponse | Promise<AxiosResponse>,
rejected?: (error: any) => any,
) {
this.axiosInstance.interceptors.response.use(fulfilled, rejected);
}
}
export { InterceptorManager };

View File

@@ -0,0 +1,118 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FileUploader } from './uploader';
describe('fileUploader', () => {
let fileUploader: FileUploader;
// Mock the AxiosInstance
const mockAxiosInstance = {
post: vi.fn(),
} as any;
beforeEach(() => {
fileUploader = new FileUploader(mockAxiosInstance);
});
it('should create an instance of FileUploader', () => {
expect(fileUploader).toBeInstanceOf(FileUploader);
});
it('should upload a file and return the response', async () => {
const url = 'https://example.com/upload';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
const mockResponse: AxiosResponse = {
config: {} as any,
data: { success: true },
headers: {},
status: 200,
statusText: 'OK',
};
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockResolvedValueOnce(mockResponse);
const result = await fileUploader.upload(url, file);
expect(result).toEqual(mockResponse);
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
url,
expect.any(FormData),
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
});
it('should merge provided config with default config', async () => {
const url = 'https://example.com/upload';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
const mockResponse: AxiosResponse = {
config: {} as any,
data: { success: true },
headers: {},
status: 200,
statusText: 'OK',
};
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockResolvedValueOnce(mockResponse);
const customConfig: AxiosRequestConfig = {
headers: { 'Custom-Header': 'value' },
};
const result = await fileUploader.upload(url, file, customConfig);
expect(result).toEqual(mockResponse);
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
url,
expect.any(FormData),
{
headers: {
'Content-Type': 'multipart/form-data',
'Custom-Header': 'value',
},
},
);
});
it('should handle errors gracefully', async () => {
const url = 'https://example.com/upload';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockRejectedValueOnce(new Error('Network Error'));
await expect(fileUploader.upload(url, file)).rejects.toThrow(
'Network Error',
);
});
it('should handle empty URL gracefully', async () => {
const url = '';
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockRejectedValueOnce(new Error('Request failed with status code 404'));
await expect(fileUploader.upload(url, file)).rejects.toThrow(
'Request failed with status code 404',
);
});
it('should handle null URL gracefully', async () => {
const url = null as unknown as string;
const file = new File(['file content'], 'test.txt', { type: 'text/plain' });
(
mockAxiosInstance.post as unknown as ReturnType<typeof vi.fn>
).mockRejectedValueOnce(new Error('Request failed with status code 404'));
await expect(fileUploader.upload(url, file)).rejects.toThrow(
'Request failed with status code 404',
);
});
});

View File

@@ -0,0 +1,32 @@
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import type { RequestClient } from '../request-client';
class FileUploader {
private client: RequestClient;
constructor(client: RequestClient) {
this.client = client;
}
public async upload(
url: string,
file: Blob | File,
config?: AxiosRequestConfig,
): Promise<AxiosResponse> {
const formData = new FormData();
formData.append('file', file);
const finalConfig: AxiosRequestConfig = {
...config,
headers: {
'Content-Type': 'multipart/form-data',
...config?.headers,
},
};
return this.client.post(url, formData, finalConfig);
}
}
export { FileUploader };

View File

@@ -0,0 +1,97 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RequestClient } from './request-client';
describe('requestClient', () => {
let mock: MockAdapter;
let requestClient: RequestClient;
beforeEach(() => {
mock = new MockAdapter(axios);
requestClient = new RequestClient();
});
afterEach(() => {
mock.reset();
});
it('should successfully make a GET request', async () => {
mock.onGet('test/url').reply(200, { data: 'response' });
const response = await requestClient.get('test/url');
expect(response.data).toEqual({ data: 'response' });
});
it('should successfully make a POST request', async () => {
const postData = { key: 'value' };
const mockData = { data: 'response' };
mock.onPost('/test/post', postData).reply(200, mockData);
const response = await requestClient.post('/test/post', postData);
expect(response.data).toEqual(mockData);
});
it('should successfully make a PUT request', async () => {
const putData = { key: 'updatedValue' };
const mockData = { data: 'updated response' };
mock.onPut('/test/put', putData).reply(200, mockData);
const response = await requestClient.put('/test/put', putData);
expect(response.data).toEqual(mockData);
});
it('should successfully make a DELETE request', async () => {
const mockData = { data: 'delete response' };
mock.onDelete('/test/delete').reply(200, mockData);
const response = await requestClient.delete('/test/delete');
expect(response.data).toEqual(mockData);
});
it('should handle network errors', async () => {
mock.onGet('/test/error').networkError();
try {
await requestClient.get('/test/error');
expect(true).toBe(false);
} catch (error: any) {
expect(error.isAxiosError).toBe(true);
expect(error.message).toBe('Network Error');
}
});
it('should handle timeout', async () => {
mock.onGet('/test/timeout').timeout();
try {
await requestClient.get('/test/timeout');
expect(true).toBe(false);
} catch (error: any) {
expect(error.isAxiosError).toBe(true);
expect(error.code).toBe('ECONNABORTED');
}
});
it('should successfully upload a file', async () => {
const fileData = new Blob(['file contents'], { type: 'text/plain' });
mock.onPost('/test/upload').reply((config) => {
return config.data instanceof FormData && config.data.has('file')
? [200, { data: 'file uploaded' }]
: [400, { error: 'Bad Request' }];
});
const response = await requestClient.upload('/test/upload', fileData);
expect(response.data).toEqual({ data: 'file uploaded' });
});
it('should successfully download a file as a blob', async () => {
const mockFileContent = new Blob(['mock file content'], {
type: 'text/plain',
});
mock.onGet('/test/download').reply(200, mockFileContent);
const res = await requestClient.download('/test/download');
expect(res.data).toBeInstanceOf(Blob);
});
});

View File

@@ -0,0 +1,179 @@
import type {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
CreateAxiosDefaults,
InternalAxiosRequestConfig,
} from 'axios';
import type { MakeAuthorizationFn, RequestClientOptions } from './types';
import { merge } from '@vben-core/toolkit';
import axios from 'axios';
import { AxiosCanceler } from './modules/canceler';
import { FileDownloader } from './modules/downloader';
import { InterceptorManager } from './modules/interceptor';
import { FileUploader } from './modules/uploader';
class RequestClient {
private instance: AxiosInstance;
private makeAuthorization: MakeAuthorizationFn | undefined;
public addRequestInterceptor: InterceptorManager['addRequestInterceptor'];
public addResponseInterceptor: InterceptorManager['addResponseInterceptor'];
public download: FileDownloader['download'];
public upload: FileUploader['upload'];
/**
* 构造函数用于创建Axios实例
* @param options - Axios请求配置可选
*/
constructor(options: RequestClientOptions = {}) {
// 合并默认配置和传入的配置
const defaultConfig: CreateAxiosDefaults = {
headers: {
'Content-Type': 'application/json;charset=utf-8',
},
// 默认超时时间
timeout: 10_000,
withCredentials: true,
};
const { makeAuthorization, ...axiosConfig } = options;
const requestConfig = merge(axiosConfig, defaultConfig);
this.instance = axios.create(requestConfig);
this.makeAuthorization = makeAuthorization;
// 实例化拦截器管理器
const interceptorManager = new InterceptorManager(this.instance);
this.addRequestInterceptor =
interceptorManager.addRequestInterceptor.bind(interceptorManager);
this.addResponseInterceptor =
interceptorManager.addResponseInterceptor.bind(interceptorManager);
// 实例化文件上传器
const fileUploader = new FileUploader(this);
this.upload = fileUploader.upload.bind(fileUploader);
// 实例化文件下载器
const fileDownloader = new FileDownloader(this);
this.download = fileDownloader.download.bind(fileDownloader);
// 设置默认的拦截器
this.setupInterceptors();
}
private errorHandler(error: any) {
return Promise.reject(error);
}
private setupAuthorizationInterceptor() {
this.addRequestInterceptor((config: InternalAxiosRequestConfig) => {
const authorization = this.makeAuthorization?.(config);
if (authorization) {
config.headers[authorization.key || 'Authorization'] =
authorization.handle?.();
}
return config;
}, this.errorHandler);
}
private setupInterceptors() {
// 默认拦截器
this.setupAuthorizationInterceptor();
// 设置取消请求的拦截器
this.setupCancelerInterceptor();
}
/**
* DELETE请求方法
* @param {string} url - 请求的URL
* @param {AxiosRequestConfig} config - 请求配置(可选)
* @returns 返回Promise
*/
public delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>(url, { ...config, method: 'DELETE' });
}
/**
* GET请求方法
* @param {string} url - 请求URL
* @param {AxiosRequestConfig} config - 请求配置,可选
* @returns {Promise<AxiosResponse<T>>} 返回Axios响应Promise
*/
public get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>(url, { ...config, method: 'GET' });
}
/**
* POST请求方法
* @param {string} url - 请求URL
* @param {any} data - 请求体数据
* @param {AxiosRequestConfig} config - 请求配置,可选
* @returns {Promise<AxiosResponse<T>>} 返回Axios响应Promise
*/
public post<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
return this.request<T>(url, { ...config, data, method: 'POST' });
}
/**
* PUT请求方法
* @param {string} url - 请求的URL
* @param {any} data - 请求体数据
* @param {AxiosRequestConfig} config - 请求配置(可选)
* @returns 返回Promise
*/
public put<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
return this.request<T>(url, { ...config, data, method: 'PUT' });
}
/**
* 通用的请求方法
* @param {string} url - 请求的URL
* @param {AxiosRequestConfig} config - 请求配置对象
* @returns {Promise<AxiosResponse<T>>} 返回Axios响应Promise
*/
public async request<T>(url: string, config: AxiosRequestConfig): Promise<T> {
try {
const response: AxiosResponse<T> = await this.instance({
url,
...config,
});
return response as T;
} catch (error: any) {
throw error.response ? error.response.data : error;
}
}
public setupCancelerInterceptor() {
const axiosCanceler = new AxiosCanceler();
// 注册取消重复请求的请求拦截器
this.addRequestInterceptor((config: InternalAxiosRequestConfig) => {
return axiosCanceler.addRequest(config);
}, this.errorHandler);
// 注册移除请求的响应拦截器
this.addResponseInterceptor(
(response: AxiosResponse) => {
axiosCanceler.removeRequest(response);
return response;
},
(error) => {
if (error.config) {
axiosCanceler.removeRequest(error.config);
}
return Promise.reject(error);
},
);
}
}
export { RequestClient };

View File

@@ -0,0 +1,24 @@
import type { CreateAxiosDefaults, InternalAxiosRequestConfig } from 'axios';
type RequestContentType =
| 'application/json;charset=utf-8'
| 'application/octet-stream;charset=utf-8'
| 'application/x-www-form-urlencoded;charset=utf-8'
| 'multipart/form-data;charset=utf-8';
interface MakeAuthorization {
handle: () => null | string;
key?: string;
}
type MakeAuthorizationFn = (
config?: InternalAxiosRequestConfig,
) => MakeAuthorization;
interface RequestClientOptions extends CreateAxiosDefaults {
/**
* 用于生成Authorization
*/
makeAuthorization?: MakeAuthorizationFn;
}
export type { MakeAuthorizationFn, RequestClientOptions, RequestContentType };

View File

@@ -0,0 +1,25 @@
import axios from 'axios';
import { describe, expect, it } from 'vitest';
import { isCancelError } from './util';
describe('isCancelError', () => {
const source = axios.CancelToken.source();
source.cancel('Operation canceled by the user.');
it('should detect cancellation', () => {
const error = new axios.Cancel('Operation canceled by the user.');
const result = isCancelError(error);
expect(result).toBe(true);
});
it('should not detect cancellation on regular errors', () => {
const error = new Error('Regular error');
const result = isCancelError(error);
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,7 @@
import axios from 'axios';
function isCancelError(error: any) {
return axios.isCancel(error);
}
export { isCancelError };

View File

@@ -0,0 +1,11 @@
// import { setGlobalOptions, } from 'vue-request';
// setGlobalOptions({
// manual: true,
// // ...
// });
/**
* @see https://www.attojs.com/guide/documentation/globalOptions.html
*/
export * from 'vue-request';

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,7 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@@ -0,0 +1,51 @@
{
"name": "@vben-core/stores",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/forward/stores"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
"build": "pnpm unbuild",
"stub": "pnpm unbuild --stub"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"pinia": "2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"vue": "3.4.27",
"vue-router": "^4.3.2"
}
}

View File

@@ -0,0 +1,9 @@
// TODO: https://github.com/vuejs/pinia/issues/2098
declare module 'pinia' {
export function acceptHMRUpdate(
initialUseStore: StoreDefinition | any,
hot: any,
): (newModule: any) => any;
}
export {};

View File

@@ -0,0 +1,3 @@
export * from './modules';
export * from './setup';
export { storeToRefs } from 'pinia';

View File

@@ -0,0 +1,85 @@
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it } from 'vitest';
import { useAccessStore } from './access';
describe('useAccessStore', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('updates accessMenus state', () => {
const store = useAccessStore();
expect(store.accessMenus).toEqual([]);
store.setAccessMenus([{ name: 'Dashboard', path: '/dashboard' }]);
expect(store.accessMenus).toEqual([
{ name: 'Dashboard', path: '/dashboard' },
]);
});
it('updates userInfo and userRoles state', () => {
const store = useAccessStore();
expect(store.userInfo).toBeNull();
expect(store.userRoles).toEqual([]);
const userInfo: any = { name: 'John Doe', roles: [{ value: 'admin' }] };
store.setUserInfo(userInfo);
expect(store.userInfo).toEqual(userInfo);
expect(store.userRoles).toEqual(['admin']);
});
it('returns correct userInfo', () => {
const store = useAccessStore();
const userInfo: any = { name: 'Jane Doe', roles: [{ value: 'user' }] };
store.setUserInfo(userInfo);
expect(store.getUserInfo).toEqual(userInfo);
});
it('updates accessToken state correctly', () => {
const store = useAccessStore();
expect(store.accessToken).toBeNull(); // 初始状态
store.setAccessToken('abc123');
expect(store.accessToken).toBe('abc123');
});
// 测试重置用户信息时的行为
it('clears userInfo and userRoles when setting null userInfo', () => {
const store = useAccessStore();
store.setUserInfo({
roles: [{ roleName: 'User', value: 'user' }],
} as any);
expect(store.userInfo).not.toBeNull();
expect(store.userRoles.length).toBeGreaterThan(0);
store.setUserInfo(null as any); // 重置用户信息
expect(store.userInfo).toBeNull();
expect(store.userRoles).toEqual([]);
});
it('returns the correct accessToken', () => {
const store = useAccessStore();
store.setAccessToken('xyz789');
expect(store.getAccessToken).toBe('xyz789');
});
// 测试在没有用户角色时返回空数组
it('returns an empty array for userRoles if not set', () => {
const store = useAccessStore();
expect(store.getUserRoles).toEqual([]);
});
// 测试设置空的访问菜单列表
it('handles empty accessMenus correctly', () => {
const store = useAccessStore();
store.setAccessMenus([]);
expect(store.accessMenus).toEqual([]);
});
// 测试设置空的访问路由列表
it('handles empty accessRoutes correctly', () => {
const store = useAccessStore();
store.setAccessRoutes([]);
expect(store.accessRoutes).toEqual([]);
});
});

View File

@@ -0,0 +1,119 @@
import type { MenuRecordRaw } from '@vben-core/typings';
import type { RouteRecordRaw } from 'vue-router';
import { acceptHMRUpdate, defineStore } from 'pinia';
type AccessToken = null | string;
interface BasicUserInfo {
[key: string]: any;
/**
* 头像
*/
avatar: string;
/**
* 用户昵称
*/
realName: string;
/**
* 用户id
*/
userId: string;
/**
* 用户名
*/
username: string;
}
interface AccessState {
/**
* 可访问的菜单列表
*/
accessMenus: MenuRecordRaw[];
/**
* 可访问的路由列表
*/
accessRoutes: RouteRecordRaw[];
/**
* 登录 accessToken
*/
accessToken: AccessToken;
/**
* 用户信息
*/
userInfo: BasicUserInfo | null;
/**
* 用户角色
*/
userRoles: string[];
}
/**
* @zh_CN 访问权限相关
*/
const useAccessStore = defineStore('access', {
actions: {
setAccessMenus(menus: MenuRecordRaw[]) {
this.accessMenus = menus;
},
setAccessRoutes(routes: RouteRecordRaw[]) {
this.accessRoutes = routes;
},
setAccessToken(token: AccessToken) {
this.accessToken = token;
},
setUserInfo(userInfo: BasicUserInfo) {
// 设置用户信息
this.userInfo = userInfo;
// 设置角色信息
const roles = userInfo?.roles ?? [];
const roleValues =
typeof roles[0] === 'string'
? roles
: roles.map((item: Record<string, any>) => item.value);
this.setUserRoles(roleValues);
},
setUserRoles(roles: string[]) {
this.userRoles = roles;
},
},
getters: {
getAccessMenus(): MenuRecordRaw[] {
return this.accessMenus;
},
getAccessRoutes(): RouteRecordRaw[] {
return this.accessRoutes;
},
getAccessToken(): AccessToken {
return this.accessToken;
},
getUserInfo(): BasicUserInfo | null {
return this.userInfo;
},
getUserRoles(): string[] {
return this.userRoles;
},
},
persist: {
// 持久化
// TODO: accessToken 过期时间
paths: ['accessToken', 'userRoles', 'userInfo'],
},
state: (): AccessState => ({
accessMenus: [],
accessRoutes: [],
accessToken: null,
userInfo: null,
userRoles: [],
}),
});
// 解决热更新问题
const hot = import.meta.hot;
if (hot) {
hot.accept(acceptHMRUpdate(useAccessStore, hot));
}
export { useAccessStore };

View File

@@ -0,0 +1,2 @@
export * from './access';
export * from './tabs';

View File

@@ -0,0 +1,310 @@
import { createRouter, createWebHistory } from 'vue-router';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useTabsStore } from './tabs';
describe('useAccessStore', () => {
const router = createRouter({
history: createWebHistory(),
routes: [],
});
router.push = vi.fn();
router.replace = vi.fn();
beforeEach(() => {
setActivePinia(createPinia());
vi.clearAllMocks();
});
it('adds a new tab', () => {
const store = useTabsStore();
const tab: any = {
fullPath: '/home',
meta: {},
name: 'Home',
path: '/home',
};
store.addTab(tab);
expect(store.tabs.length).toBe(1);
expect(store.tabs[0]).toEqual(tab);
});
it('adds a new tab if it does not exist', () => {
const store = useTabsStore();
const newTab: any = {
fullPath: '/new',
meta: {},
name: 'New',
path: '/new',
};
store.addTab(newTab);
expect(store.tabs).toContainEqual(newTab);
});
it('updates an existing tab instead of adding a new one', () => {
const store = useTabsStore();
const initialTab: any = {
fullPath: '/existing',
meta: {},
name: 'Existing',
path: '/existing',
query: {},
};
store.tabs.push(initialTab);
const updatedTab = { ...initialTab, query: { id: '1' } };
store.addTab(updatedTab);
expect(store.tabs.length).toBe(1);
expect(store.tabs[0].query).toEqual({ id: '1' });
});
it('closes all tabs', async () => {
const store = useTabsStore();
store.tabs = [
{ fullPath: '/home', meta: {}, name: 'Home', path: '/home' },
] as any;
router.replace = vi.fn(); // 使用 vitest 的 mock 函数
await store.closeAllTabs(router);
expect(store.tabs.length).toBe(0); // 假设没有固定的标签页
// expect(router.replace).toHaveBeenCalled();
});
it('returns all tabs including affix tabs', () => {
const store = useTabsStore();
store.tabs = [
{ fullPath: '/home', meta: {}, name: 'Home', path: '/home' },
] as any;
store.affixTabs = [
{ meta: { hideInTab: false }, path: '/dashboard' },
] as any;
const result = store.getTabs;
expect(result.length).toBe(2);
expect(result.find((tab) => tab.path === '/dashboard')).toBeDefined();
});
it('closes a non-affix tab', () => {
const store = useTabsStore();
const tab: any = {
fullPath: '/closable',
meta: {},
name: 'Closable',
path: '/closable',
};
store.tabs.push(tab);
store._close(tab);
expect(store.tabs.length).toBe(0);
});
it('does not close an affix tab', () => {
const store = useTabsStore();
const affixTab: any = {
fullPath: '/affix',
meta: { affixTab: true },
name: 'Affix',
path: '/affix',
};
store.tabs.push(affixTab);
store._close(affixTab);
expect(store.tabs.length).toBe(1); // Affix tab should not be closed
});
it('returns all cache tabs', () => {
const store = useTabsStore();
store.cacheTabs.add('Home');
store.cacheTabs.add('About');
expect(store.getCacheTabs).toEqual(['Home', 'About']);
});
it('returns all tabs, including affix tabs', () => {
const store = useTabsStore();
const normalTab: any = {
fullPath: '/normal',
meta: {},
name: 'Normal',
path: '/normal',
};
const affixTab: any = {
fullPath: '/affix',
meta: { affixTab: true },
name: 'Affix',
path: '/affix',
};
store.tabs.push(normalTab);
store.affixTabs.push(affixTab);
expect(store.getTabs).toContainEqual(normalTab);
// expect(store.getTabs).toContainEqual(affixTab);
});
it('navigates to a specific tab', async () => {
const store = useTabsStore();
const tab: any = { meta: {}, name: 'Dashboard', path: '/dashboard' };
await store._goToTab(tab, router);
expect(router.replace).toHaveBeenCalledWith({
params: {},
path: '/dashboard',
query: {},
});
});
it('closes multiple tabs by paths', async () => {
const store = useTabsStore();
store.addTab({
fullPath: '/home',
meta: {},
name: 'Home',
path: '/home',
} as any);
store.addTab({
fullPath: '/about',
meta: {},
name: 'About',
path: '/about',
} as any);
store.addTab({
fullPath: '/contact',
meta: {},
name: 'Contact',
path: '/contact',
} as any);
await store._bulkCloseByPaths(['/home', '/contact']);
expect(store.tabs).toHaveLength(1);
expect(store.tabs[0].name).toBe('About');
});
it('closes all tabs to the left of the specified tab', async () => {
const store = useTabsStore();
store.addTab({
fullPath: '/home',
meta: {},
name: 'Home',
path: '/home',
} as any);
store.addTab({
fullPath: '/about',
meta: {},
name: 'About',
path: '/about',
} as any);
const targetTab: any = {
fullPath: '/contact',
meta: {},
name: 'Contact',
path: '/contact',
};
store.addTab(targetTab);
await store.closeLeftTabs(targetTab);
expect(store.tabs).toHaveLength(1);
expect(store.tabs[0].name).toBe('Contact');
});
it('closes all tabs except the specified tab', async () => {
const store = useTabsStore();
store.addTab({
fullPath: '/home',
meta: {},
name: 'Home',
path: '/home',
} as any);
const targetTab: any = {
fullPath: '/about',
meta: {},
name: 'About',
path: '/about',
};
store.addTab(targetTab);
store.addTab({
fullPath: '/contact',
meta: {},
name: 'Contact',
path: '/contact',
} as any);
await store.closeOtherTabs(targetTab);
expect(store.tabs).toHaveLength(1);
expect(store.tabs[0].name).toBe('About');
});
it('closes all tabs to the right of the specified tab', async () => {
const store = useTabsStore();
const targetTab: any = {
fullPath: '/home',
meta: {},
name: 'Home',
path: '/home',
};
store.addTab(targetTab);
store.addTab({
fullPath: '/about',
meta: {},
name: 'About',
path: '/about',
} as any);
store.addTab({
fullPath: '/contact',
meta: {},
name: 'Contact',
path: '/contact',
} as any);
await store.closeRightTabs(targetTab);
expect(store.tabs).toHaveLength(1);
expect(store.tabs[0].name).toBe('Home');
});
it('closes the tab with the specified key', async () => {
const store = useTabsStore();
const keyToClose = '/about';
store.addTab({
fullPath: '/home',
meta: {},
name: 'Home',
path: '/home',
} as any);
store.addTab({
fullPath: keyToClose,
meta: {},
name: 'About',
path: '/about',
} as any);
store.addTab({
fullPath: '/contact',
meta: {},
name: 'Contact',
path: '/contact',
} as any);
await store.closeTabByKey(keyToClose, router);
expect(store.tabs).toHaveLength(2);
expect(
store.tabs.find((tab) => tab.fullPath === keyToClose),
).toBeUndefined();
});
it('refreshes the current tab', async () => {
const store = useTabsStore();
const currentTab: any = {
fullPath: '/dashboard',
meta: { name: 'Dashboard' },
name: 'Dashboard',
path: '/dashboard',
};
router.currentRoute.value = currentTab;
await store.refreshTab(router);
expect(store.excludeCacheTabs.has('Dashboard')).toBe(false);
expect(store.renderRouteView).toBe(true);
});
});

View File

@@ -0,0 +1,401 @@
import type { RouteRecordNormalized, Router } from 'vue-router';
import { toRaw } from 'vue';
import { startProgress, stopProgress } from '@vben-core/toolkit';
import { TabItem } from '@vben-core/typings';
import { acceptHMRUpdate, defineStore } from 'pinia';
/**
* @zh_CN 克隆路由,防止路由被修改
* @param route
*/
function cloneTab(route: TabItem): TabItem {
if (!route) {
return route;
}
const { matched, ...opt } = route;
return {
...opt,
matched: (matched
? matched.map((item) => ({
meta: item.meta,
name: item.name,
path: item.path,
}))
: undefined) as RouteRecordNormalized[],
};
}
function routeToTab(route: RouteRecordNormalized) {
return {
meta: route.meta,
name: route.name,
path: route.path,
} as unknown as TabItem;
}
interface TabsState {
/**
* @zh_CN 固定的标签页列表
*/
affixTabs: RouteRecordNormalized[];
/**
* @zh_CN 当前打开的标签页列表缓存
*/
cacheTabs: Set<string>;
/**
* @zh_CN 需要排除缓存的标签页
*/
excludeCacheTabs: Set<string>;
/**
* @zh_CN 是否刷新
*/
renderRouteView?: boolean;
/**
* @zh_CN 当前打开的标签页列表
*/
tabs: TabItem[];
}
/**
* @zh_CN 访问权限相关
*/
const useTabsStore = defineStore('tabs', {
actions: {
/**
* Close tabs in bulk
*/
async _bulkCloseByPaths(paths: string[]) {
this.tabs = this.tabs.filter((item) => {
return !paths.includes(this.getTabPath(item));
});
this.updateCacheTab();
},
/**
* @zh_CN 关闭标签页
* @param tab
*/
_close(tab: TabItem) {
const { fullPath } = tab;
if (this._isAffixTab(tab)) {
return;
}
const index = this.tabs.findIndex((item) => item.fullPath === fullPath);
index !== -1 && this.tabs.splice(index, 1);
},
/**
* @zh_CN 跳转到默认标签页
*/
async _goToDefaultTab(router: Router) {
if (this.getTabs.length <= 0) {
// TODO: 跳转首页
return;
}
const firstTab = this.getTabs[0];
await this._goToTab(firstTab, router);
},
/**
* @zh_CN 跳转到标签页
* @param tab
*/
async _goToTab(tab: TabItem, router: Router) {
const { params, path, query } = tab;
const toParams = {
params: params || {},
path,
query: query || {},
};
await router.replace(toParams);
},
/**
* @zh_CN 是否是固定标签页
* @param tab
*/
_isAffixTab(tab: TabItem) {
return tab?.meta?.affixTab ?? false;
},
/**
* @zh_CN 添加标签页
* @param routeTab
*/
addTab(routeTab: TabItem) {
const tab = cloneTab(routeTab);
const { fullPath, meta, params, query } = tab;
if (meta?.hideInTab) {
return;
}
const tabIndex = this.tabs.findIndex((tab) => {
return this.getTabPath(tab) === this.getTabPath(routeTab);
});
if (tabIndex === -1) {
this.tabs.push(tab);
} else {
// 页面已经存在,不重复添加选项卡,只更新选项卡参数
const currentTab = toRaw(this.tabs)[tabIndex];
if (!currentTab) {
return;
}
currentTab.params = params || currentTab.params;
currentTab.query = query || currentTab.query;
currentTab.fullPath = fullPath || currentTab.fullPath;
this.tabs.splice(tabIndex, 1, currentTab);
}
this.updateCacheTab();
},
/**
* @zh_CN 关闭所有标签页
*/
async closeAllTabs(router: Router) {
this.tabs = this.tabs.filter((tab) => this._isAffixTab(tab));
await this._goToDefaultTab(router);
this.updateCacheTab();
},
/**
* @zh_CN 关闭左侧标签页
* @param tab
*/
async closeLeftTabs(tab: TabItem) {
const index = this.tabs.findIndex(
(item) => this.getTabPath(item) === this.getTabPath(tab),
);
if (index > 0) {
const leftTabs = this.tabs.slice(0, index);
const paths: string[] = [];
for (const item of leftTabs) {
if (!this._isAffixTab(tab)) {
paths.push(this.getTabPath(item));
}
}
await this._bulkCloseByPaths(paths);
}
},
/**
* @zh_CN 关闭其他标签页
* @param tab
*/
async closeOtherTabs(tab: TabItem) {
const closePaths = this.tabs.map((item) => this.getTabPath(item));
const paths: string[] = [];
for (const path of closePaths) {
if (path !== tab.fullPath) {
const closeTab = this.tabs.find(
(item) => this.getTabPath(item) === path,
);
if (!closeTab) {
continue;
}
if (!this._isAffixTab(tab)) {
paths.push(this.getTabPath(closeTab));
}
}
}
await this._bulkCloseByPaths(paths);
},
/**
* @zh_CN 关闭右侧标签页
* @param tab
*/
async closeRightTabs(tab: TabItem) {
const index = this.tabs.findIndex(
(item) => this.getTabPath(item) === this.getTabPath(tab),
);
if (index >= 0 && index < this.tabs.length - 1) {
const rightTabs = this.tabs.slice(index + 1, this.tabs.length);
const paths: string[] = [];
for (const item of rightTabs) {
if (!this._isAffixTab(tab)) {
paths.push(this.getTabPath(item));
}
}
await this._bulkCloseByPaths(paths);
}
},
/**
* @zh_CN 关闭标签页
* @param tab
* @param router
*/
async closeTab(tab: TabItem, router: Router) {
const { currentRoute } = router;
// 关闭不是激活选项卡
if (this.getTabPath(currentRoute.value) !== this.getTabPath(tab)) {
this._close(tab);
this.updateCacheTab();
return;
}
const index = this.getTabs.findIndex(
(item) => this.getTabPath(item) === this.getTabPath(currentRoute.value),
);
const before = this.getTabs[index - 1];
const after = this.getTabs[index + 1];
// 下一个tab存在跳转到下一个
if (after) {
this._close(currentRoute.value);
await this._goToTab(after, router);
// 上一个tab存在跳转到上一个
} else if (before) {
this._close(currentRoute.value);
await this._goToTab(before, router);
} else {
console.error('Failed to close the tab; only one tab remains open.');
}
},
/**
* @zh_CN 通过key关闭标签页
* @param key
*/
async closeTabByKey(key: string, router: Router) {
const index = this.tabs.findIndex(
(item) => this.getTabPath(item) === key,
);
if (index === -1) {
return;
}
await this.closeTab(this.tabs[index], router);
},
getTabPath(tab: RouteRecordNormalized | TabItem) {
return decodeURIComponent((tab as TabItem).fullPath || tab.path);
},
/**
* @zh_CN 固定标签页
* @param tab
*/
async pushPinTab(tab: TabItem) {
const index = this.tabs.findIndex(
(item) => this.getTabPath(item) === this.getTabPath(tab),
);
if (index !== -1) {
this.tabs[index].meta.affixTab = true;
}
// TODO: 这里应该把tab从tbs中移除
this.affixTabs.push(tab as unknown as RouteRecordNormalized);
},
/**
* 刷新标签页
*/
async refreshTab(router: Router) {
const { currentRoute } = router;
const { name } = currentRoute.value;
this.excludeCacheTabs.add(name as string);
this.renderRouteView = false;
startProgress();
await new Promise((resolve) => setTimeout(resolve, 200));
this.excludeCacheTabs.delete(name as string);
this.renderRouteView = true;
stopProgress();
},
/**
* 设置固定标签页
* @param tabs
*/
setAffixTabs(tabs: RouteRecordNormalized[]) {
for (const tab of tabs) {
this.addTab(routeToTab(tab));
}
this.affixTabs = tabs;
},
/**
* @zh_CN 取消固定标签页
* @param tab
*/
async unPushPinTab(tab: TabItem) {
const index = this.affixTabs.findIndex(
(item) => this.getTabPath(item) === this.getTabPath(tab),
);
if (index !== -1) {
this.affixTabs[index].meta.affixTab = false;
this.affixTabs.splice(index, 1);
this.addTab(tab);
}
},
/**
* 根据当前打开的选项卡更新缓存
*/
async updateCacheTab() {
const cacheMap = new Set<string>();
for (const tab of this.tabs) {
// 跳过不需要持久化的标签页
const keepAlive = tab.meta?.keepAlive;
if (!keepAlive) {
continue;
}
tab.matched.forEach((t, i) => {
if (i > 0) {
cacheMap.add(t.name as string);
}
});
const name = tab.name as string;
cacheMap.add(name);
}
this.cacheTabs = cacheMap;
},
},
getters: {
getCacheTabs(): string[] {
return [...this.cacheTabs];
},
getExcludeTabs(): string[] {
return [...this.excludeCacheTabs];
},
getTabs(): TabItem[] {
const tabs: TabItem[] = [];
const affixTabPaths = new Set<string>();
for (const tab of this.affixTabs) {
if (!tab.meta.hideInTab) {
tabs.push(routeToTab(tab));
affixTabPaths.add(tab.path);
}
}
for (const tab of this.tabs) {
if (!affixTabPaths.has(tab.path) && !tab.meta.hideInTab) {
tabs.push(tab);
}
}
return tabs;
},
},
persist: {
// 持久化
paths: [],
},
state: (): TabsState => ({
affixTabs: [],
cacheTabs: new Set(),
excludeCacheTabs: new Set(),
renderRouteView: true,
tabs: [],
}),
});
// 解决热更新问题
const hot = import.meta.hot;
if (hot) {
hot.accept(acceptHMRUpdate(useTabsStore, hot));
}
export { useTabsStore };

View File

@@ -0,0 +1,30 @@
import { createPinia } from 'pinia';
interface InitStoreOptions {
/**
* @zh_CN 应用名,由于 @vben-core/stores 是公用的后续可能有多个app为了防止多个app缓存冲突可在这里配置应用名
* 应用名将被用于持久化的前缀
*/
namespace: string;
}
/**
* @zh_CN 初始化pinia
*/
async function initStore(options: InitStoreOptions) {
const { createPersistedState } = await import('pinia-plugin-persistedstate');
const pinia = createPinia();
const { namespace } = options;
pinia.use(
createPersistedState({
// key $appName-$store.id
key: (storeKey) => `${namespace}-${storeKey}`,
storage: localStorage,
}),
);
return pinia;
}
export { initStore };
export type { InitStoreOptions };

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src", "shim-pinia.d.ts"]
}

View File

@@ -0,0 +1,6 @@
# shared
全局共享包,请勿引入 workspace 依赖
- typings 共享类型
- toolkit 共享工具类

View File

@@ -0,0 +1,7 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@@ -0,0 +1,43 @@
{
"name": "@vben-core/cache",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/shared/cache"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
"build": "pnpm unbuild",
"stub": "pnpm unbuild --stub"
},
"files": [
"dist"
],
"sideEffects": false,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {},
"devDependencies": {}
}

View File

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

View File

@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { StorageManager } from './storage-manager';
describe('storageManager', () => {
let storageManager: StorageManager<{ age: number; name: string }>;
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
storageManager = new StorageManager<{ age: number; name: string }>({
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,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;
}
/**
* 获取完整的存储键
* @param key 原始键
* @returns 带前缀的完整键
*/
private getFullKey(key: string): string {
return `${this.prefix}-${key}`;
}
/**
* 清除所有带前缀的存储项
*/
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: T | null = null): T | null {
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);
}
}
}
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): T | null;
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,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/library.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,3 @@
# @vben-core/design-tokens
用于维护全局所有的 css 变量,它由 vite 插件在全局注入,不需要手动引入

View File

@@ -0,0 +1,43 @@
{
"name": "@vben-core/design-tokens",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/shared/design-tokens"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
"build": "pnpm vite build",
"dts": "vue-tsc --declaration --emitDeclarationOnly --declarationDir dist",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.css",
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.css"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
}
}

View File

@@ -0,0 +1,81 @@
:root.dark {
/* 基础背景颜色颜色 */
/* --color-background: 240 6% 18%; */
// --color-body: 220deg 13.04% 8%;
// --color-body: hsl(240deg 11% 4%);
--color-background: 220deg 13.04% 8%;
/* --color-background: 219 42% 11%; */
/* 基础文本颜色 */
--color-foreground: 220 13% 91%;
/* 主题颜色 */
--color-primary: 211 91% 39%;
/* 前景色,如按钮文本颜色 */
--color-primary-foreground: 0 0 98%;
/* 颜色次要 */
--color-secondary: 240 5% 17%;
/* 前景色,如按钮文本颜色 */
--color-secondary-foreground: 0 0 98%;
/* 次要文本颜色 */
--color-secondary-desc: 210 12.16% 70.98%;
/* 普通颜色 */
/* --color-accent: 240 3.7% 15.9%; */
/* --color-accent: 220deg 7.32% 16.08%; */
--color-accent: 0deg 0% 100% / 8%;
--color-accent-hover: 0deg 0% 100% / 12%;
/* 普通颜色前景色,如按钮文本颜色 */
--color-accent-foreground: 0 0 98%;
/* 破坏性颜色 */
--color-destructive: 0 63% 31%;
/* 破坏性颜色 */
--color-destructive-foreground: 0 86% 97%;
--color-muted: 220deg 6.82% 17.25%;
--color-muted-foreground: 215 20.2% 65.1%;
--color-heavy: 0deg 0% 100% / 12%;
--color-heavy-foreground: var(--color-accent-foreground);
/* 基础边框色 */
--color-border: 0deg 0% 100% / 10%;
/* --color-popover: 240 4% 29%; */
--color-popover: 222.86deg 8.43% 16.27%;
--color-popover-foreground: 210 40% 98%;
--color-card: 222.2 84% 4.9%;
--color-card-foreground: 210 40% 98%;
/* 基础文本边框色 */
--color-input: 0deg 0% 100% / 10%;
/* input placeholder 颜色 */
--color-input-placeholder: 218deg 11% 65%;
/* 基础文本背景色 */
/* --color-input-background: 216deg 5.38% 18.24%; */
--color-input-background: 0deg 0% 100% / 5%;
/* 遮罩颜色 */
--color-overlay: 0deg 0% 0% / 40%;
--color-ring: 222.2 84% 4.9%;
/* 基本文字大小 */
--font-size-base: 16px;
/* 基本圆角大小 */
--radius-base: 0.5rem;
color-scheme: dark;
}

View File

@@ -0,0 +1,96 @@
/* https://gavin-yyc.github.io/colorconvert/ */
:root {
/* 基础背景颜色颜色 */
/* --color-background: 210deg 25% 96.86%; */
// --color-main: 210deg 25% 96.86%;
--color-background: 0 0 100%;
// --color-darken-background: 220deg 13.04% 8%;
/* --color-background: 220 14% 95%; */
/* 基础文本颜色 */
--color-foreground: 210 6% 21%;
/* 主题颜色 */
--color-primary: 211 91% 39%;
/* 前景色,如按钮文本颜色 */
--color-primary-foreground: 0 0 98%;
/* 颜色次要 */
--color-secondary: 240 5% 96%;
/* 前景色,如按钮文本颜色 */
--color-secondary-foreground: 240 6% 10%;
/* 次要文本颜色 */
--color-secondary-desc: 216.4 16.09% 34.12%;
/* 普通颜色 */
--color-accent: 240 5% 96%;
--color-accent-hover: 200deg 10% 90%;
/* 普通颜色前景色,如按钮文本颜色 */
--color-accent-foreground: 240 6% 10%;
/* 破坏性颜色 */
--color-destructive: 0 77.78% 68.24%;
/* 破坏性颜色 */
--color-destructive-foreground: 0 0 98%;
--color-muted: 210 40% 96.1%;
--color-muted-foreground: 215.4 16.3% 46.9%;
--color-heavy: 192deg 9.43% 89.61%;
--color-heavy-foreground: var(--color-accent-foreground);
--color-popover: 0 0% 100%;
--color-popover-foreground: 222.2 84% 4.9%;
--color-card: 0 0% 100%;
--color-card-foreground: 222.2 84% 4.9%;
/* 基础边框色 */
--color-border: 240 6% 90%;
/* 基础文本边框色 */
--color-input: 240deg 5.88% 90%;
/* input placeholder 颜色 */
--color-input-placeholder: 217 10.6% 65%;
/* 基础文本背景色 */
--color-input-background: 0 0 100%;
--color-ring: 222.2 84% 4.9%;
/* 遮罩颜色 */
--color-overlay: 0deg 0% 0% / 40%;
/* dark */
--color-dark-foreground: 220 13% 91%;
--color-dark-border: 0deg 0% 100% / 10%;
--color-dark-accent: 0deg 0% 100% / 8%;
--color-dark-accent-hover: 0deg 0% 100% / 12%;
/* 基本文字大小 */
--font-size-base: 16px;
/* 基本圆角大小 */
--radius-base: 0.5rem;
/* ======================================== */
/* =============component & UI============= */
/* ======================================== */
/* menu */
--color-menu-dark: 225deg 12% 13%;
--color-menu-dark-darken: 223deg 11% 10%;
// --color-menu-darken: var(--color-background);
// --color-menu-opened-dark: 225deg 12.12% 11%;
--color-menu: 0deg 0% 100%;
--color-menu-darken: 0deg 0% 95%;
accent-color: var(--color-primary);
color-scheme: light;
// --color-menu-opened: 0deg 0% 100%;
}

View File

@@ -0,0 +1,4 @@
import './default/index.scss';
import './dark/index.scss';
export {};

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/library.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,3 @@
import { defineConfig } from '@vben/vite-config';
export default defineConfig();

View File

@@ -0,0 +1,22 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
loaders: ['sass'],
outDir: './dist',
pattern: ['index.scss'],
},
{
builder: 'mkdist',
input: './src',
loaders: ['postcss'],
outDir: './dist',
pattern: ['tailwind.css'],
},
],
});

View File

@@ -0,0 +1,41 @@
{
"name": "@vben-core/design",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/shared/design"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
"build": "pnpm unbuild",
"prepublishOnly": "npm run build",
"stub": "pnpm unbuild --stub"
},
"files": [
"dist",
"src"
],
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"development": "./src/scss/index.scss",
"default": "./dist/index.css"
},
"./tailwind": {
"development": "./src/tailwind.css",
"default": "./dist/tailwind.css"
},
"./global": {
"default": "./src/scss/global.scss"
}
},
"dependencies": {
"modern-normalize": "^2.0.0"
}
}

View File

@@ -0,0 +1,327 @@
@charset "UTF-8";
/** css 样式重置 */
@import 'modern-normalize/modern-normalize.css';
#app,
.ant-app,
body,
html {
width: 100%;
height: 100%;
overscroll-behavior: none;
}
*,
::after,
::before {
@apply border-border;
box-sizing: border-box;
border-style: solid;
border-width: 0;
}
body.invert-mode {
@apply invert;
}
body.grayscale-mode {
@apply grayscale;
}
html {
@apply text-foreground bg-background;
font-variation-settings: normal;
text-size-adjust: 100%;
font-synthesis-weight: none;
scroll-behavior: smooth;
text-rendering: optimizelegibility;
-webkit-tap-highlight-color: transparent;
}
a,
a:active,
a:hover,
a:link,
a:visited {
color: inherit;
text-decoration: none;
}
::view-transition-new(root),
::view-transition-old(root) {
mix-blend-mode: normal;
animation: none;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2147483646;
}
html.dark::view-transition-old(root) {
z-index: 2147483646;
}
html.dark::view-transition-new(root) {
z-index: 1;
}
input::placeholder,
textarea::placeholder {
opacity: 1;
}
input:-webkit-autofill {
border: none;
box-shadow: 0 0 0 1000px transparent inset;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.slide-up-move {
transition: transform 0.3s;
}
.slide-up-enter-from,
.slide-up-leave-to {
opacity: 0;
transform: translateY(-15px);
}
.slide-down-enter-active,
.slide-down-leave-active {
transition: 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.slide-down-move {
transition: transform 0.3s;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
transform: translateY(15px);
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.slide-left-move {
transition: transform 0.3s;
}
.slide-left-enter-from,
.slide-left-leave-to {
opacity: 0;
transform: translateX(-15px);
}
.slide-right-enter-active,
.slide-right-leave-active {
transition: 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.slide-right-move {
transition: transform 0.3s;
}
.slide-right-enter-from,
.slide-right-leave-to {
opacity: 0;
transform: translateX(15px);
}
.fade-transition-enter-active,
.fade-transition-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-transition-enter-from,
.fade-transition-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(30px);
}
.fade-down-enter-active,
.fade-down-leave-active {
transition:
opacity 0.25s,
transform 0.3s;
}
.fade-down-enter-from {
opacity: 0;
transform: translateY(-10%);
}
.fade-down-leave-to {
opacity: 0;
transform: translateY(10%);
}
.fade-scale-leave-active,
.fade-scale-enter-active {
transition: all 0.28s;
}
.fade-scale-enter-from {
opacity: 0;
transform: scale(1.2);
}
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.8);
}
.fade-up-enter-active,
.fade-up-leave-active {
transition:
opacity 0.2s,
transform 0.25s;
}
.fade-up-enter-from {
opacity: 0;
transform: translateY(10%);
}
.fade-up-leave-to {
opacity: 0;
transform: translateY(-10%);
}
@keyframes fade-slide {
0% {
opacity: 0;
transform: translateX(-30px);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateX(30px);
}
}
@keyframes fade {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes fade-up {
0% {
opacity: 0;
transform: translateY(10%);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(-10%);
}
}
@keyframes fade-down {
0% {
opacity: 0;
transform: translateY(-10%);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(10%);
}
}
.fade-slow {
animation: fade 3s infinite;
}
.fade-slide-slow {
animation: fade-slide 3s infinite;
}
.fade-up-slow {
animation: fade-up 3s infinite;
}
.fade-down-slow {
animation: fade-down 3s infinite;
}
.collapse-transition {
transition:
0.2s height ease-in-out,
0.2s padding-top ease-in-out,
0.2s padding-bottom ease-in-out;
}
.collapse-transition-leave-active,
.collapse-transition-enter-active {
transition:
0.2s max-height ease-in-out,
0.2s padding-top ease-in-out,
0.2s margin-top ease-in-out;
}

View File

@@ -0,0 +1 @@
@import './scss/index';

View File

@@ -0,0 +1,86 @@
#app,
.ant-app,
body,
html {
width: 100%;
height: 100%;
overscroll-behavior: none;
}
*,
::after,
::before {
@apply border-border;
box-sizing: border-box;
border-style: solid;
border-width: 0;
}
body.invert-mode {
@apply invert;
}
body.grayscale-mode {
@apply grayscale;
}
html {
@apply text-foreground bg-background;
// font-size: 62.5%;
font-variation-settings: normal;
text-size-adjust: 100%;
font-synthesis-weight: none;
scroll-behavior: smooth;
text-rendering: optimizelegibility;
-webkit-tap-highlight-color: transparent;
}
a,
a:active,
a:hover,
a:link,
a:visited {
color: inherit;
text-decoration: none;
}
::view-transition-new(root),
::view-transition-old(root) {
mix-blend-mode: normal;
animation: none;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2147483646;
}
html.dark::view-transition-old(root) {
z-index: 2147483646;
}
html.dark::view-transition-new(root) {
z-index: 1;
}
input::placeholder,
textarea::placeholder {
// color: hsl(var(--color-input-placeholder)) !important;
opacity: 1;
}
input:-webkit-autofill {
border: none;
box-shadow: 0 0 0 1000px transparent inset;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}

View File

@@ -0,0 +1,5 @@
$namespace: 'vben' !default;
$common-separator: '-' !default;
$element-separator: '__' !default;
$modifier-separator: '--' !default;
$state-prefix: 'is' !default;

View File

@@ -0,0 +1,34 @@
@forward './common/constants.scss';
@mixin b($block) {
$B: $namespace + '-' + $block !global;
.#{$B} {
@content;
}
}
@mixin e($name) {
@at-root {
&#{$element-separator}#{$name} {
@content;
}
}
}
@mixin m($name) {
@at-root {
&#{$modifier-separator}#{$name} {
@content;
}
}
}
// block__element.is-active {}
@mixin is($state, $prefix: $state-prefix) {
@at-root {
&.#{$prefix}-#{$state} {
@content;
}
}
}

View File

@@ -0,0 +1,4 @@
/** css 样式重置 */
@import 'modern-normalize/modern-normalize.css';
@import './common/base';
@import './transition';

View File

@@ -0,0 +1,81 @@
@keyframes fade-slide {
0% {
opacity: 0;
transform: translateX(-30px);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateX(30px);
}
}
@keyframes fade {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
@keyframes fade-up {
0% {
opacity: 0;
transform: translateY(10%);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(-10%);
}
}
@keyframes fade-down {
0% {
opacity: 0;
transform: translateY(-10%);
}
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(10%);
}
}
.fade-slow {
animation: fade 3s infinite;
}
// .fade-slide-fast {
// animation: fade-slide 0.3s infinite;
// }
.fade-slide-slow {
animation: fade-slide 3s infinite;
}
.fade-up-slow {
animation: fade-up 3s infinite;
}
.fade-down-slow {
animation: fade-down 3s infinite;
}

View File

@@ -0,0 +1,14 @@
.collapse-transition {
transition:
0.2s height ease-in-out,
0.2s padding-top ease-in-out,
0.2s padding-bottom ease-in-out;
}
.collapse-transition-leave-active,
.collapse-transition-enter-active {
transition:
0.2s max-height ease-in-out,
0.2s padding-top ease-in-out,
0.2s margin-top ease-in-out;
}

View File

@@ -0,0 +1,97 @@
.fade-transition {
&-enter-active,
&-leave-active {
transition: opacity 0.2s ease-in-out;
}
&-enter-from,
&-leave-to {
opacity: 0;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(30px);
}
// ///////////////////////////////////////////////
// Fade down
// ///////////////////////////////////////////////
// Speed: 1x
.fade-down-enter-active,
.fade-down-leave-active {
transition:
opacity 0.25s,
transform 0.3s;
}
.fade-down-enter-from {
opacity: 0;
transform: translateY(-10%);
}
.fade-down-leave-to {
opacity: 0;
transform: translateY(10%);
}
// fade-scale
.fade-scale-leave-active,
.fade-scale-enter-active {
transition: all 0.28s;
}
.fade-scale-enter-from {
opacity: 0;
transform: scale(1.2);
}
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.8);
}
// ///////////////////////////////////////////////
// Fade Top
// ///////////////////////////////////////////////
// Speed: 1x
.fade-up-enter-active,
.fade-up-leave-active {
transition:
opacity 0.2s,
transform 0.25s;
}
.fade-up-enter-from {
opacity: 0;
transform: translateY(10%);
}
.fade-up-leave-to {
opacity: 0;
transform: translateY(-10%);
}

View File

@@ -0,0 +1,4 @@
@import './slide';
@import './fade';
@import './animation';
@import './collapse';

View File

@@ -0,0 +1,10 @@
@mixin transition() {
&-enter-active,
&-leave-active {
transition: 0.25s cubic-bezier(0.25, 0.8, 0.5, 1);
}
&-move {
transition: transform 0.3s;
}
}

View File

@@ -0,0 +1,41 @@
@use 'mixin.scss' as *;
.slide-up {
@include transition;
&-enter-from,
&-leave-to {
opacity: 0;
transform: translateY(-15px);
}
}
.slide-down {
@include transition;
&-enter-from,
&-leave-to {
opacity: 0;
transform: translateY(15px);
}
}
.slide-left {
@include transition;
&-enter-from,
&-leave-to {
opacity: 0;
transform: translateX(-15px);
}
}
.slide-right {
@include transition;
&-enter-from,
&-leave-to {
opacity: 0;
transform: translateX(15px);
}
}

View File

@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply font-sans;
}
}
@layer components {
.flex-center {
@apply flex items-center justify-center;
}
.flex-col-center {
@apply flex flex-col items-center justify-center;
}
.outline-box {
@apply outline-border relative cursor-pointer rounded-md p-1 outline outline-1;
}
.outline-box::after {
@apply absolute left-1/2 top-1/2 z-20 h-0 w-[1px] rounded-sm opacity-0 outline outline-2 outline-transparent transition-all duration-300 content-[""];
}
.outline-box.outline-box-active {
@apply outline-primary outline outline-2;
}
.outline-box.outline-box-active::after {
display: none;
}
.outline-box:not(.outline-box-active):hover::after {
@apply outline-primary left-0 top-0 h-full w-full p-1 opacity-100;
}
}

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,30 @@
{
"name": "@vben-core/iconify",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/shared/iconify"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"files": [
"dist"
],
"main": "./src/index.ts",
"module": "./src/index.ts",
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"default": "./src/index.ts"
}
},
"dependencies": {
"@iconify/vue": "^4.1.2",
"vue": "3.4.27"
}
}

View File

@@ -0,0 +1,13 @@
import { defineComponent, h } from 'vue';
import { Icon } from '@iconify/vue';
function createIcon(name: string) {
return defineComponent({
setup(props, { attrs }) {
return () => h(Icon, { icon: name, ...props, ...attrs });
},
});
}
export { createIcon };

View File

@@ -0,0 +1,5 @@
export * from './factory';
export * from './material';
export * from './mdi';
export * from '@iconify/vue';

View File

@@ -0,0 +1,77 @@
import { createIcon } from './factory';
export const IconDefault = createIcon('ic:round-auto-awesome');
export const IcRoundKeyboardArrowDown = createIcon(
'ic:round-keyboard-arrow-down',
);
export const IcRoundChevronRight = createIcon('ic:round-chevron-right');
export const IcRoundKeyboard = createIcon('ic:round-keyboard');
// export const IcRoundMenuOpen = createIcon('ic:round-menu-open');
export const IcRoundMenu = createIcon('ic:round-menu');
export const IcRoundMoreHoriz = createIcon('ic:round-more-horiz');
export const IcRoundFitScreen = createIcon('ic:round-fit-screen');
export const IcTwotoneFitScreen = createIcon('ic:twotone-fit-screen');
export const IcRoundColorLens = createIcon('ic:round-color-lens');
export const IcRoundMoreVert = createIcon('ic:round-more-vert');
export const IcRoundFullscreen = createIcon('ic:round-fullscreen');
export const IcRoundFullscreenExit = createIcon('ic:round-fullscreen-exit');
export const IcRoundAutoAwesome = createIcon('ic:round-auto-awesome');
export const IcRoundClose = createIcon('ic:round-close');
export const IcRoundRestartAlt = createIcon('ic:round-restart-alt');
export const IcRoundLogout = createIcon('ic:round-logout');
export const IcOutlineVisibility = createIcon('ic:outline-visibility');
export const IcOutlineVisibilityOff = createIcon('ic:outline-visibility-off');
export const IcRoundSearch = createIcon('ic:round-search');
export const IcRoundFolderCopy = createIcon('ic:round-folder-copy');
export const IcRoundSubdirectoryArrowLeft = createIcon(
'ic:round-subdirectory-arrow-left',
);
export const IcRoundArrowUpward = createIcon('ic:round-arrow-upward');
export const IcRoundArrowDownward = createIcon('ic:round-arrow-downward');
export const IcBaselineLanguage = createIcon('ic:baseline-language');
export const IcRoundSearchOff = createIcon('ic:round-search-off');
export const IcRoundNotificationsNone = createIcon(
'ic:round-notifications-none',
);
export const IcRoundMarkEmailRead = createIcon('ic:round-mark-email-read');
export const IcRoundWbSunny = createIcon('ic:round-wb-sunny');
export const IcRoundMotionPhotosAuto = createIcon(
'ic:round-motion-photos-auto',
);
export const IcRoundSettingsSuggest = createIcon('ic:round-settings-suggest');
export const IcRoundArrowBackIosNew = createIcon('ic:round-arrow-back-ios-new');
export const IcRoundMultipleStop = createIcon('ic:round-multiple-stop');
export const IcRoundRefresh = createIcon('ic:round-refresh');
export const IcRoundCreditScore = createIcon('ic:round-credit-score');

View File

@@ -0,0 +1,49 @@
import { createIcon } from './factory';
export const MdiKeyboardEsc = createIcon('mdi:keyboard-esc');
export const MdiLoading = createIcon('mdi:loading');
export const MdiWechat = createIcon('mdi:wechat');
export const MdiGithub = createIcon('mdi:github');
export const MdiGoogle = createIcon('mdi:google');
export const MdiQqchat = createIcon('mdi:qqchat');
export const MdiPin = createIcon('mdi:pin');
export const MdiPinOff = createIcon('mdi:pin-off');
export const MdiFormatHorizontalAlignLeft = createIcon(
'mdi:format-horizontal-align-left',
);
export const MdiFormatHorizontalAlignRight = createIcon(
'mdi:format-horizontal-align-right',
);
export const MdiArrowExpandHorizontal = createIcon(
'mdi:arrow-expand-horizontal',
);
export const MdiMenuClose = createIcon('mdi:menu-close');
export const MdiMenuOpen = createIcon('mdi:menu-open');
export const MdiDockLeft = createIcon('mdi:dock-left');
export const MdiDockRight = createIcon('mdi:dock-right');
export const MdiDockBottom = createIcon('mdi:dock-bottom');
export const MdiDriveDocument = createIcon('mdi:drive-document');
export const MdiMoonAndStars = createIcon('mdi:moon-and-stars');
export const MdiEditBoxOutline = createIcon('mdi:edit-box-outline');
export const MdiQuestionMarkCircleOutline = createIcon(
'mdi:question-mark-circle-outline',
);

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/web.json",
"include": ["src"],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1,7 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: ['src/index'],
});

View File

@@ -0,0 +1,51 @@
{
"name": "@vben-core/toolkit",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"repository": {
"type": "git",
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
"directory": "packages/@vben-core/shared/toolkit"
},
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"scripts": {
"build": "pnpm unbuild",
"stub": "pnpm unbuild --stub"
},
"files": [
"dist"
],
"sideEffects": false,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"imports": {
"#*": "./src/*"
},
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@ctrl/tinycolor": "4.1.0",
"@vue/shared": "^3.4.27",
"dayjs": "^1.11.11",
"defu": "^6.1.4",
"nprogress": "^0.2.0"
},
"devDependencies": {
"@types/nprogress": "^0.2.3"
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { convertToHsl, convertToHslCssVar, isValidColor } from './color';
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);
});
});
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,44 @@
import { TinyColor } from '@ctrl/tinycolor';
/**
* 将颜色转换为HSL格式。
*
* HSL是一种颜色模型包括色相(Hue)、饱和度(Saturation)和亮度(Lightness)三个部分。
* 这个函数使用TinyColor库将输入的颜色转换为HSL格式并返回一个字符串。
*
* @param {string} color 输入的颜色可以是任何TinyColor支持的颜色格式。
* @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 输入的颜色可以是任何TinyColor支持的颜色格式。
* @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;
}
/**
* 检查颜色是否有效
* @param {string} color - 待检查的颜色
* 如果颜色有效返回true否则返回false
*/
function isValidColor(color?: string) {
if (!color) {
return false;
}
return new TinyColor(color).isValid;
}
export { TinyColor, convertToHsl, convertToHslCssVar, isValidColor };

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { formatDate, formatDateTime } from './date';
describe('formatDate', () => {
it('should return "2023-01-01" when passed new Date("2023-01-01T00:00:00.000Z")', () => {
const date = new Date('2023-01-01T00:00:00.000Z');
const actual = formatDate(date);
expect(actual).toBe('2023-01-01');
});
it('should return "2023-01-01" when passed new Date("2023-01-01T00:00:00")', () => {
const date = new Date('2023-01-01T00:00:00.000Z');
const actual = formatDate(date, 'YYYY-MM-DD');
expect(actual).toBe('2023-01-01');
});
it('should throw an error when passed an invalid date', () => {
const date = '2018-10-10-10-10-10';
expect(formatDate(date)).toBe('Invalid Date');
});
});
describe('formatDateTime', () => {
it('should return "2023-01-01" when passed new Date("2023-01-01T00:00:00.000Z")', () => {
const date = new Date('2023-01-01T00:00:00.000Z');
const actual = formatDateTime(date);
expect(actual).toBe('2023-01-01 08:00:00');
});
it('should throw an error when passed an invalid date', () => {
const date = '2018-10-10-10-10-10';
expect(formatDateTime(date)).toBe('Invalid Date');
});
});

View File

@@ -0,0 +1,25 @@
import dateFunc, { type ConfigType } from 'dayjs';
const DATE_TIME_TEMPLATE = 'YYYY-MM-DD HH:mm:ss';
const DATE_TEMPLATE = 'YYYY-MM-DD';
/**
* @zh_CN 格式化日期时间
* @param date 待格式化的日期时间
* @param format 格式化的方式
* @returns 格式化后的日期字符串默认YYYY-MM-DD HH:mm:ss
*/
function formatDate(date?: ConfigType, format = DATE_TEMPLATE): string {
return dateFunc(date).format(format);
}
/**
* @zh_CN 格式化日期时间
* @param date 待格式化的日期时间
* @returns 格式化后的日期字符串
*/
function formatDateTime(date?: ConfigType): string {
return formatDate(date, DATE_TIME_TEMPLATE);
}
export { formatDate, formatDateTime };

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import { diff } from './diff';
describe('diff function', () => {
it('should correctly find differences in flat objects', () => {
const oldObj = { a: 1, b: 2, c: 3 };
const newObj = { a: 1, b: 3, c: 3 };
expect(diff(oldObj, newObj)).toEqual({ b: 3 });
});
it('should correctly handle nested objects', () => {
const oldObj = { a: { b: 1, c: 2 }, d: 3 };
const newObj = { a: { b: 1, c: 3 }, d: 3 };
expect(diff(oldObj, newObj)).toEqual({ a: { b: 1, c: 3 } });
});
it('should correctly handle arrays`', () => {
const oldObj = { a: [1, 2, 3] };
const newObj = { a: [1, 2, 4] };
expect(diff(oldObj, newObj)).toEqual({ a: [1, 2, 4] });
});
it('should correctly handle nested arrays', () => {
const oldObj = {
a: [
[1, 2],
[3, 4],
],
};
const newObj = {
a: [
[1, 2],
[3, 5],
],
};
expect(diff(oldObj, newObj)).toEqual({
a: [
[1, 2],
[3, 5],
],
});
});
it('should return null if objects are identical', () => {
const oldObj = { a: 1, b: 2, c: 3 };
const newObj = { a: 1, b: 2, c: 3 };
expect(diff(oldObj, newObj)).toBeNull();
});
it('should return differences between two objects excluding ignored fields', () => {
const oldObj = { a: 1, b: 2, c: 3, d: 6 };
const newObj = { a: 2, b: 2, c: 4, d: 5 };
const ignoreFields: (keyof typeof newObj)[] = ['a', 'd'];
const result = diff(oldObj, newObj, ignoreFields);
expect(result).toEqual({ c: 4 });
});
});

View File

@@ -0,0 +1,58 @@
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;
}
export { arraysEqual, diff };

Some files were not shown because too many files have changed in this diff Show More