refactor(project): re-adjust the overall folder
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
# @vben-core/forward
|
||||
|
||||
该目录内的包,可直接被app所引用,其是项目基础功能的一层抽象。允许轻微的副作用耦合,如`locales`的集成。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `forward` 内的包不允许相互引用,有相互引用的情况请考虑是否放到`packages/effects`下
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"name": "@vben-core/helpers",
|
||||
"version": "5.0.0",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/forward/helpers"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm unbuild --stub"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": false,
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"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.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { findMenuByPath, findRootMenuByPath } from './find-menu-by-path';
|
||||
|
||||
// 示例菜单数据
|
||||
const menus: any[] = [
|
||||
{ path: '/', children: [] },
|
||||
{ path: '/about', children: [] },
|
||||
{
|
||||
path: '/contact',
|
||||
children: [
|
||||
{ path: '/contact/email', children: [] },
|
||||
{ path: '/contact/phone', children: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/services',
|
||||
children: [
|
||||
{ path: '/services/design', children: [] },
|
||||
{
|
||||
path: '/services/development',
|
||||
children: [{ path: '/services/development/web', children: [] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('menu Finder Tests', () => {
|
||||
it('finds a top-level menu', () => {
|
||||
const menu = findMenuByPath(menus, '/about');
|
||||
expect(menu).toBeDefined();
|
||||
expect(menu?.path).toBe('/about');
|
||||
});
|
||||
|
||||
it('finds a nested menu', () => {
|
||||
const menu = findMenuByPath(menus, '/services/development/web');
|
||||
expect(menu).toBeDefined();
|
||||
expect(menu?.path).toBe('/services/development/web');
|
||||
});
|
||||
|
||||
it('returns null for a non-existent path', () => {
|
||||
const menu = findMenuByPath(menus, '/non-existent');
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
|
||||
it('handles empty menus list', () => {
|
||||
const menu = findMenuByPath([], '/about');
|
||||
expect(menu).toBeNull();
|
||||
});
|
||||
|
||||
it('handles menu items without children', () => {
|
||||
const menu = findMenuByPath(
|
||||
[{ path: '/only', children: undefined }] as any[],
|
||||
'/only',
|
||||
);
|
||||
expect(menu).toBeDefined();
|
||||
expect(menu?.path).toBe('/only');
|
||||
});
|
||||
|
||||
it('finds root menu by path', () => {
|
||||
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
|
||||
menus,
|
||||
'/services/development/web',
|
||||
);
|
||||
|
||||
expect(findMenu).toBeDefined();
|
||||
expect(rootMenu).toBeUndefined();
|
||||
expect(rootMenuPath).toBeUndefined();
|
||||
expect(findMenu?.path).toBe('/services/development/web');
|
||||
});
|
||||
|
||||
it('returns null for undefined or empty path', () => {
|
||||
const menuUndefinedPath = findMenuByPath(menus);
|
||||
const menuEmptyPath = findMenuByPath(menus, '');
|
||||
expect(menuUndefinedPath).toBeNull();
|
||||
expect(menuEmptyPath).toBeNull();
|
||||
});
|
||||
|
||||
it('checks for root menu when path does not exist', () => {
|
||||
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
|
||||
menus,
|
||||
'/non-existent',
|
||||
);
|
||||
expect(findMenu).toBeNull();
|
||||
expect(rootMenu).toBeUndefined();
|
||||
expect(rootMenuPath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
|
||||
function findMenuByPath(
|
||||
list: MenuRecordRaw[],
|
||||
path?: string,
|
||||
): MenuRecordRaw | null {
|
||||
for (const menu of list) {
|
||||
if (menu.path === path) {
|
||||
return menu;
|
||||
}
|
||||
const findMenu = menu.children && findMenuByPath(menu.children, path);
|
||||
if (findMenu) {
|
||||
return findMenu;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找根菜单
|
||||
* @param menus
|
||||
* @param path
|
||||
*/
|
||||
function findRootMenuByPath(menus: MenuRecordRaw[], path?: string) {
|
||||
const findMenu = findMenuByPath(menus, path);
|
||||
const rootMenuPath = findMenu?.parents?.[0];
|
||||
const rootMenu = rootMenuPath
|
||||
? menus.find((item) => item.path === rootMenuPath)
|
||||
: undefined;
|
||||
return {
|
||||
findMenu,
|
||||
rootMenu,
|
||||
rootMenuPath,
|
||||
};
|
||||
}
|
||||
|
||||
export { findMenuByPath, findRootMenuByPath };
|
||||
@@ -1,238 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { generateMenus } from './generate-menus'; // 替换为您的实际路径
|
||||
import {
|
||||
type RouteRecordRaw,
|
||||
type Router,
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
} from 'vue-router';
|
||||
|
||||
// Nested route setup to test child inclusion and hideChildrenInMenu functionality
|
||||
|
||||
describe('generateMenus', () => {
|
||||
// 模拟路由数据
|
||||
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',
|
||||
show: true,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
badge: undefined,
|
||||
badgeType: undefined,
|
||||
badgeVariants: undefined,
|
||||
icon: 'about-icon',
|
||||
name: '关于',
|
||||
order: undefined,
|
||||
parent: undefined,
|
||||
parents: undefined,
|
||||
path: '/about',
|
||||
show: true,
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
const menus = await generateMenus(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 generateMenus(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',
|
||||
show: true,
|
||||
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 generateMenus(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',
|
||||
show: true,
|
||||
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 generateMenus(
|
||||
mockRoutesWithRedirect,
|
||||
mockRouter as any,
|
||||
);
|
||||
expect(menus).toEqual([
|
||||
// Assuming your generateMenus 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',
|
||||
show: true,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
badge: undefined,
|
||||
badgeType: undefined,
|
||||
badgeVariants: undefined,
|
||||
icon: 'path-icon',
|
||||
name: 'New Path',
|
||||
order: undefined,
|
||||
parent: undefined,
|
||||
parents: undefined,
|
||||
path: '/new-path',
|
||||
show: true,
|
||||
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 generateMenus(routes, router);
|
||||
const expectedMenus = [
|
||||
{
|
||||
badge: undefined,
|
||||
badgeType: undefined,
|
||||
badgeVariants: undefined,
|
||||
icon: undefined,
|
||||
name: 'About',
|
||||
order: 1,
|
||||
parent: undefined,
|
||||
parents: undefined,
|
||||
path: '/about',
|
||||
show: true,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
badge: undefined,
|
||||
badgeType: undefined,
|
||||
badgeVariants: undefined,
|
||||
icon: undefined,
|
||||
name: 'Home',
|
||||
order: 2,
|
||||
parent: undefined,
|
||||
parents: undefined,
|
||||
path: '/',
|
||||
show: true,
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
expect(menus).toEqual(expectedMenus);
|
||||
});
|
||||
|
||||
it('should handle empty routes', async () => {
|
||||
const emptyRoutes: any[] = [];
|
||||
const menus = await generateMenus(emptyRoutes, router);
|
||||
expect(menus).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { ExRouteRecordRaw, MenuRecordRaw } from '@vben-core/typings';
|
||||
import type { RouteRecordRaw, Router } from 'vue-router';
|
||||
|
||||
import { filterTree, mapTree } from '@vben-core/toolkit';
|
||||
|
||||
/**
|
||||
* 根据 routes 生成菜单列表
|
||||
* @param routes
|
||||
*/
|
||||
async function generateMenus(
|
||||
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,
|
||||
show: !route?.meta?.hideInMenu,
|
||||
children: resultChildren || [],
|
||||
};
|
||||
});
|
||||
|
||||
// 对菜单进行排序
|
||||
menus = menus.sort((a, b) => (a.order || 999) - (b.order || 999));
|
||||
|
||||
const finalMenus = filterTree(menus, (menu) => {
|
||||
return !!menu.show;
|
||||
});
|
||||
return finalMenus;
|
||||
}
|
||||
|
||||
export { generateMenus };
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { mapTree } from '@vben-core/toolkit';
|
||||
import {
|
||||
ComponentRecordType,
|
||||
GenerateMenuAndRoutesOptions,
|
||||
RouteRecordStringComponent,
|
||||
} from '@vben-core/typings';
|
||||
|
||||
/**
|
||||
* 动态生成路由 - 后端方式
|
||||
*/
|
||||
async function generateRoutesByBackend(
|
||||
options: GenerateMenuAndRoutesOptions,
|
||||
): Promise<RouteRecordRaw[]> {
|
||||
const { fetchMenuListAsync, layoutMap = {}, pageMap = {} } = options;
|
||||
|
||||
try {
|
||||
const menuRoutes = await fetchMenuListAsync?.();
|
||||
if (!menuRoutes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizePageMap: ComponentRecordType = {};
|
||||
|
||||
for (const [key, value] of Object.entries(pageMap)) {
|
||||
normalizePageMap[normalizeViewPath(key)] = value;
|
||||
}
|
||||
|
||||
const routes = convertRoutes(menuRoutes, layoutMap, normalizePageMap);
|
||||
|
||||
return routes;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function convertRoutes(
|
||||
routes: RouteRecordStringComponent[],
|
||||
layoutMap: ComponentRecordType,
|
||||
pageMap: ComponentRecordType,
|
||||
): RouteRecordRaw[] {
|
||||
return mapTree(routes, (node) => {
|
||||
const route = node as unknown as RouteRecordRaw;
|
||||
const { component, name } = node;
|
||||
|
||||
if (!name) {
|
||||
console.error('route name is required', route);
|
||||
}
|
||||
|
||||
// layout转换
|
||||
if (component && layoutMap[component]) {
|
||||
route.component = layoutMap[component];
|
||||
// 页面组件转换
|
||||
} else if (component) {
|
||||
const normalizePath = normalizeViewPath(component);
|
||||
route.component =
|
||||
pageMap[
|
||||
normalizePath.endsWith('.vue')
|
||||
? normalizePath
|
||||
: `${normalizePath}.vue`
|
||||
];
|
||||
}
|
||||
|
||||
return route;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeViewPath(path: string): string {
|
||||
// 去除相对路径前缀
|
||||
const normalizedPath = path.replace(/^(\.\/|\.\.\/)+/, '');
|
||||
|
||||
// 确保路径以 '/' 开头
|
||||
const viewPath = normalizedPath.startsWith('/')
|
||||
? normalizedPath
|
||||
: `/${normalizedPath}`;
|
||||
|
||||
// TODO: 这里耦合了vben-admin的目录结构
|
||||
return viewPath.replace(/^\/views/, '');
|
||||
}
|
||||
export { generateRoutesByBackend };
|
||||
@@ -1,105 +0,0 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
generateRoutesByFrontend,
|
||||
hasAuthority,
|
||||
} from './generate-routes-frontend';
|
||||
|
||||
// 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('generateRoutesByFrontend', () => {
|
||||
it('should handle routes without children', async () => {
|
||||
const generatedRoutes = await generateRoutesByFrontend(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 generateRoutesByFrontend(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 generateRoutesByFrontend(
|
||||
routesWithMissingMeta as RouteRecordRaw[],
|
||||
['admin'],
|
||||
);
|
||||
expect(generatedRoutes).toEqual([
|
||||
{ path: '/path1' },
|
||||
{ meta: {}, path: '/path2' },
|
||||
{ meta: { authority: ['admin'] }, path: '/path3' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { filterTree, mapTree } from '@vben-core/toolkit';
|
||||
|
||||
/**
|
||||
* 动态生成路由 - 前端方式
|
||||
*/
|
||||
async function generateRoutesByFrontend(
|
||||
routes: RouteRecordRaw[],
|
||||
roles: string[],
|
||||
forbiddenComponent?: RouteRecordRaw['component'],
|
||||
): Promise<RouteRecordRaw[]> {
|
||||
// 根据角色标识过滤路由表,判断当前用户是否拥有指定权限
|
||||
const finalRoutes = filterTree(routes, (route) => {
|
||||
return hasAuthority(route, roles);
|
||||
});
|
||||
|
||||
if (!forbiddenComponent) {
|
||||
return finalRoutes;
|
||||
}
|
||||
|
||||
// 如果有禁止访问的页面,将禁止访问的页面替换为403页面
|
||||
return mapTree(finalRoutes, (route) => {
|
||||
if (menuHasVisibleWithForbidden(route)) {
|
||||
route.component = forbiddenComponent;
|
||||
}
|
||||
return route;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断路由是否有权限访问
|
||||
* @param route
|
||||
* @param access
|
||||
*/
|
||||
function hasAuthority(route: RouteRecordRaw, access: string[]) {
|
||||
const authority = route.meta?.authority;
|
||||
if (!authority) {
|
||||
return true;
|
||||
}
|
||||
const canAccess = access.some((value) => authority.includes(value));
|
||||
|
||||
return canAccess || (!canAccess && menuHasVisibleWithForbidden(route));
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断路由是否在菜单中显示,但是访问会被重定向到403
|
||||
* @param route
|
||||
*/
|
||||
function menuHasVisibleWithForbidden(route: RouteRecordRaw) {
|
||||
return (
|
||||
!!route.meta?.authority &&
|
||||
Reflect.has(route.meta || {}, 'menuVisibleWithForbidden') &&
|
||||
!!route.meta?.menuVisibleWithForbidden
|
||||
);
|
||||
}
|
||||
|
||||
export { generateRoutesByFrontend, hasAuthority };
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './find-menu-by-path';
|
||||
export * from './generate-menus';
|
||||
export * from './generate-routes-backend';
|
||||
export * from './generate-routes-frontend';
|
||||
export * from './merge-route-modules';
|
||||
@@ -1,68 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
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 };
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"compilerOptions": {
|
||||
"types": ["@vben-core/typings/vue-router"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: ['src/index'],
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: ['src/index'],
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"name": "@vben-core/request",
|
||||
"version": "5.0.0",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/forward/request"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm unbuild --stub"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"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/locales": "workspace:*",
|
||||
"@vben-core/toolkit": "workspace:*",
|
||||
"axios": "^1.7.2",
|
||||
"vue-request": "^2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"axios-mock-adapter": "^1.22.0"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './request-client';
|
||||
export * from './use-request';
|
||||
export * from 'axios';
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './request-client';
|
||||
export type * from './types';
|
||||
@@ -1,84 +0,0 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
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 };
|
||||
@@ -1,41 +0,0 @@
|
||||
import {
|
||||
AxiosInstance,
|
||||
AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
|
||||
const errorHandler = (res: Error) => Promise.reject(res);
|
||||
|
||||
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 || errorHandler,
|
||||
);
|
||||
}
|
||||
|
||||
addResponseInterceptor<T = any>(
|
||||
fulfilled: (
|
||||
response: AxiosResponse<T>,
|
||||
) => AxiosResponse | Promise<AxiosResponse>,
|
||||
rejected?: (error: any) => any,
|
||||
) {
|
||||
this.axiosInstance.interceptors.response.use(
|
||||
fulfilled,
|
||||
rejected || errorHandler,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export { InterceptorManager };
|
||||
@@ -1,118 +0,0 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
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 };
|
||||
@@ -1,97 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
import type {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
CreateAxiosDefaults,
|
||||
InternalAxiosRequestConfig,
|
||||
} from 'axios';
|
||||
|
||||
import type {
|
||||
MakeAuthorizationFn,
|
||||
MakeErrorMessageFn,
|
||||
RequestClientOptions,
|
||||
} from './types';
|
||||
|
||||
import { $t } from '@vben-core/locales';
|
||||
import { merge } from '@vben-core/toolkit';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
import { FileDownloader } from './modules/downloader';
|
||||
import { InterceptorManager } from './modules/interceptor';
|
||||
import { FileUploader } from './modules/uploader';
|
||||
|
||||
class RequestClient {
|
||||
private instance: AxiosInstance;
|
||||
private makeAuthorization: MakeAuthorizationFn | undefined;
|
||||
private makeErrorMessage: MakeErrorMessageFn | undefined;
|
||||
|
||||
public addRequestInterceptor: InterceptorManager['addRequestInterceptor'];
|
||||
public addResponseInterceptor: InterceptorManager['addResponseInterceptor'];
|
||||
public download: FileDownloader['download'];
|
||||
public upload: FileUploader['upload'];
|
||||
|
||||
/**
|
||||
* 构造函数,用于创建Axios实例
|
||||
* @param options - Axios请求配置,可选
|
||||
*/
|
||||
constructor(options: RequestClientOptions = {}) {
|
||||
this.bindMethods();
|
||||
// 合并默认配置和传入的配置
|
||||
const defaultConfig: CreateAxiosDefaults = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=utf-8',
|
||||
},
|
||||
// 默认超时时间
|
||||
timeout: 10_000,
|
||||
};
|
||||
const { makeAuthorization, makeErrorMessage, ...axiosConfig } = options;
|
||||
const requestConfig = merge(axiosConfig, defaultConfig);
|
||||
|
||||
this.instance = axios.create(requestConfig);
|
||||
this.makeAuthorization = makeAuthorization;
|
||||
this.makeErrorMessage = makeErrorMessage;
|
||||
|
||||
// 实例化拦截器管理器
|
||||
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 bindMethods() {
|
||||
const propertyNames = Object.getOwnPropertyNames(
|
||||
Object.getPrototypeOf(this),
|
||||
);
|
||||
propertyNames.forEach((propertyName) => {
|
||||
const propertyValue = (this as any)[propertyName];
|
||||
if (
|
||||
typeof propertyValue === 'function' &&
|
||||
propertyName !== 'constructor'
|
||||
) {
|
||||
(this as any)[propertyName] = propertyValue.bind(this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private setupAuthorizationInterceptor() {
|
||||
this.addRequestInterceptor(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const authorization = this.makeAuthorization?.(config);
|
||||
if (authorization) {
|
||||
const { token } = authorization.tokenHandler?.() ?? {};
|
||||
config.headers[authorization.key || 'Authorization'] = token;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error: any) => Promise.reject(error),
|
||||
);
|
||||
}
|
||||
|
||||
private setupDefaultResponseInterceptor() {
|
||||
this.addResponseInterceptor(
|
||||
(response: AxiosResponse) => {
|
||||
return response;
|
||||
},
|
||||
(error: any) => {
|
||||
if (axios.isCancel(error)) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const err: string = error?.toString?.() ?? '';
|
||||
let errMsg = '';
|
||||
if (err?.includes('Network Error')) {
|
||||
errMsg = $t('fallback.http.networkError');
|
||||
} else if (error?.message?.includes?.('timeout')) {
|
||||
errMsg = $t('fallback.http.requestTimeout');
|
||||
}
|
||||
if (errMsg) {
|
||||
this.makeErrorMessage?.(errMsg);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
let errorMessage = error?.response?.data?.error?.message ?? '';
|
||||
const status = error?.response?.status;
|
||||
|
||||
switch (status) {
|
||||
case 400: {
|
||||
errorMessage = $t('fallback.http.badRequest');
|
||||
break;
|
||||
}
|
||||
|
||||
case 401: {
|
||||
errorMessage = $t('fallback.http.unauthorized');
|
||||
this.makeAuthorization?.().unAuthorizedHandler?.();
|
||||
break;
|
||||
}
|
||||
case 403: {
|
||||
errorMessage = $t('fallback.http.forbidden');
|
||||
break;
|
||||
}
|
||||
// 404请求不存在
|
||||
case 404: {
|
||||
errorMessage = $t('fallback.http.notFound');
|
||||
break;
|
||||
}
|
||||
case 408: {
|
||||
errorMessage = $t('fallback.http.requestTimeout');
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
errorMessage = $t('fallback.http.internalServerError');
|
||||
}
|
||||
}
|
||||
|
||||
this.makeErrorMessage?.(errorMessage);
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private setupInterceptors() {
|
||||
// 默认拦截器
|
||||
this.setupAuthorizationInterceptor();
|
||||
this.setupDefaultResponseInterceptor();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { RequestClient };
|
||||
@@ -1,48 +0,0 @@
|
||||
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 {
|
||||
key?: string;
|
||||
tokenHandler: () => { refreshToken: string; token: string } | null;
|
||||
unAuthorizedHandler?: () => Promise<void>;
|
||||
}
|
||||
|
||||
type MakeAuthorizationFn = (
|
||||
config?: InternalAxiosRequestConfig,
|
||||
) => MakeAuthorization;
|
||||
|
||||
type MakeErrorMessageFn = (message: string) => void;
|
||||
|
||||
interface RequestClientOptions extends CreateAxiosDefaults {
|
||||
/**
|
||||
* 用于生成Authorization
|
||||
*/
|
||||
makeAuthorization?: MakeAuthorizationFn;
|
||||
/**
|
||||
* 用于生成错误消息
|
||||
*/
|
||||
makeErrorMessage?: MakeErrorMessageFn;
|
||||
}
|
||||
|
||||
interface HttpResponse<T = any> {
|
||||
/**
|
||||
* 0 表示成功 其他表示失败
|
||||
* 0 means success, others means fail
|
||||
*/
|
||||
code: number;
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type {
|
||||
HttpResponse,
|
||||
MakeAuthorizationFn,
|
||||
MakeErrorMessageFn,
|
||||
RequestClientOptions,
|
||||
RequestContentType,
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
// import { setGlobalOptions, } from 'vue-request';
|
||||
|
||||
// setGlobalOptions({
|
||||
// manual: true,
|
||||
// // ...
|
||||
// });
|
||||
|
||||
/**
|
||||
* @see https://www.attojs.com/guide/documentation/globalOptions.html
|
||||
*/
|
||||
export * from 'vue-request';
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: ['src/index'],
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"name": "@vben-core/stores",
|
||||
"version": "5.0.0",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/forward/stores"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm unbuild --stub"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"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.33",
|
||||
"vue-router": "^4.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// TODO: https://github.com/vuejs/pinia/issues/2098
|
||||
declare module 'pinia' {
|
||||
export function acceptHMRUpdate(
|
||||
initialUseStore: StoreDefinition | any,
|
||||
hot: any,
|
||||
): (newModule: any) => any;
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './modules';
|
||||
export * from './setup';
|
||||
export { storeToRefs } from 'pinia';
|
||||
@@ -1,85 +0,0 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useCoreAccessStore } from './access';
|
||||
|
||||
describe('useCoreAccessStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('updates accessMenus state', () => {
|
||||
const store = useCoreAccessStore();
|
||||
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 = useCoreAccessStore();
|
||||
expect(store.userInfo).toBeNull();
|
||||
expect(store.userRoles).toEqual([]);
|
||||
|
||||
const userInfo: any = { name: 'John Doe', roles: ['admin'] };
|
||||
store.setUserInfo(userInfo);
|
||||
|
||||
expect(store.userInfo).toEqual(userInfo);
|
||||
expect(store.userRoles).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('returns correct userInfo', () => {
|
||||
const store = useCoreAccessStore();
|
||||
const userInfo: any = { name: 'Jane Doe', roles: [{ value: 'user' }] };
|
||||
store.setUserInfo(userInfo);
|
||||
expect(store.userInfo).toEqual(userInfo);
|
||||
});
|
||||
|
||||
it('updates accessToken state correctly', () => {
|
||||
const store = useCoreAccessStore();
|
||||
expect(store.accessToken).toBeNull(); // 初始状态
|
||||
store.setAccessToken('abc123');
|
||||
expect(store.accessToken).toBe('abc123');
|
||||
});
|
||||
|
||||
// 测试重置用户信息时的行为
|
||||
it('clears userInfo and userRoles when setting null userInfo', () => {
|
||||
const store = useCoreAccessStore();
|
||||
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 = useCoreAccessStore();
|
||||
store.setAccessToken('xyz789');
|
||||
expect(store.accessToken).toBe('xyz789');
|
||||
});
|
||||
|
||||
// 测试在没有用户角色时返回空数组
|
||||
it('returns an empty array for userRoles if not set', () => {
|
||||
const store = useCoreAccessStore();
|
||||
expect(store.userRoles).toEqual([]);
|
||||
});
|
||||
|
||||
// 测试设置空的访问菜单列表
|
||||
it('handles empty accessMenus correctly', () => {
|
||||
const store = useCoreAccessStore();
|
||||
store.setAccessMenus([]);
|
||||
expect(store.accessMenus).toEqual([]);
|
||||
});
|
||||
|
||||
// 测试设置空的访问路由列表
|
||||
it('handles empty accessRoutes correctly', () => {
|
||||
const store = useCoreAccessStore();
|
||||
store.setAccessRoutes([]);
|
||||
expect(store.accessRoutes).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { MenuRecordRaw } from '@vben-core/typings';
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||
|
||||
type AccessToken = null | string;
|
||||
|
||||
interface BasicUserInfo {
|
||||
/**
|
||||
* 头像
|
||||
*/
|
||||
avatar: string;
|
||||
/**
|
||||
* 用户昵称
|
||||
*/
|
||||
realName: string;
|
||||
/**
|
||||
* 用户角色
|
||||
*/
|
||||
roles?: string[];
|
||||
/**
|
||||
* 用户id
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* 用户名
|
||||
*/
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface AccessState {
|
||||
/**
|
||||
* 权限码
|
||||
*/
|
||||
accessCodes: string[];
|
||||
/**
|
||||
* 可访问的菜单列表
|
||||
*/
|
||||
accessMenus: MenuRecordRaw[];
|
||||
/**
|
||||
* 可访问的路由列表
|
||||
*/
|
||||
accessRoutes: RouteRecordRaw[];
|
||||
/**
|
||||
* 登录 accessToken
|
||||
*/
|
||||
accessToken: AccessToken;
|
||||
/**
|
||||
* 登录 accessToken
|
||||
*/
|
||||
refreshToken: AccessToken;
|
||||
/**
|
||||
* 用户信息
|
||||
*/
|
||||
userInfo: BasicUserInfo | null;
|
||||
/**
|
||||
* 用户角色
|
||||
*/
|
||||
userRoles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 访问权限相关
|
||||
*/
|
||||
const useCoreAccessStore = defineStore('core-access', {
|
||||
actions: {
|
||||
setAccessCodes(codes: string[]) {
|
||||
this.accessCodes = codes;
|
||||
},
|
||||
setAccessMenus(menus: MenuRecordRaw[]) {
|
||||
this.accessMenus = menus;
|
||||
},
|
||||
setAccessRoutes(routes: RouteRecordRaw[]) {
|
||||
this.accessRoutes = routes;
|
||||
},
|
||||
setAccessToken(token: AccessToken) {
|
||||
this.accessToken = token;
|
||||
},
|
||||
setRefreshToken(token: AccessToken) {
|
||||
this.refreshToken = token;
|
||||
},
|
||||
setUserInfo(userInfo: BasicUserInfo | null) {
|
||||
// 设置用户信息
|
||||
this.userInfo = userInfo;
|
||||
// 设置角色信息
|
||||
const roles = userInfo?.roles ?? [];
|
||||
this.setUserRoles(roles);
|
||||
},
|
||||
setUserRoles(roles: string[]) {
|
||||
this.userRoles = roles;
|
||||
},
|
||||
},
|
||||
persist: {
|
||||
// 持久化
|
||||
paths: ['accessToken', 'refreshToken', 'accessCodes'],
|
||||
},
|
||||
state: (): AccessState => ({
|
||||
accessCodes: [],
|
||||
accessMenus: [],
|
||||
accessRoutes: [],
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
userInfo: null,
|
||||
userRoles: [],
|
||||
}),
|
||||
});
|
||||
|
||||
// 解决热更新问题
|
||||
const hot = import.meta.hot;
|
||||
if (hot) {
|
||||
hot.accept(acceptHMRUpdate(useCoreAccessStore, hot));
|
||||
}
|
||||
|
||||
export { useCoreAccessStore };
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './access';
|
||||
export * from './tabbar';
|
||||
@@ -1,296 +0,0 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useCoreTabbarStore } from './tabbar';
|
||||
|
||||
describe('useCoreAccessStore', () => {
|
||||
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 = useCoreTabbarStore();
|
||||
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 = useCoreTabbarStore();
|
||||
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 = useCoreTabbarStore();
|
||||
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 = useCoreTabbarStore();
|
||||
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('closes a non-affix tab', () => {
|
||||
const store = useCoreTabbarStore();
|
||||
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 = useCoreTabbarStore();
|
||||
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 = useCoreTabbarStore();
|
||||
store.cachedTabs.add('Home');
|
||||
store.cachedTabs.add('About');
|
||||
expect(store.getCachedTabs).toEqual(['Home', 'About']);
|
||||
});
|
||||
|
||||
it('returns all tabs, including affix tabs', () => {
|
||||
const store = useCoreTabbarStore();
|
||||
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.affixTabs).toContainEqual(affixTab);
|
||||
});
|
||||
|
||||
it('navigates to a specific tab', async () => {
|
||||
const store = useCoreTabbarStore();
|
||||
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 = useCoreTabbarStore();
|
||||
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 = useCoreTabbarStore();
|
||||
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 = useCoreTabbarStore();
|
||||
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 = useCoreTabbarStore();
|
||||
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 = useCoreTabbarStore();
|
||||
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 = useCoreTabbarStore();
|
||||
const currentTab: any = {
|
||||
fullPath: '/dashboard',
|
||||
meta: { name: 'Dashboard' },
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
};
|
||||
router.currentRoute.value = currentTab;
|
||||
|
||||
await store.refresh(router);
|
||||
|
||||
expect(store.excludeCachedTabs.has('Dashboard')).toBe(false);
|
||||
expect(store.renderRouteView).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,492 +0,0 @@
|
||||
import type { TabDefinition } from '@vben-core/typings';
|
||||
import type { RouteRecordNormalized, Router } from 'vue-router';
|
||||
|
||||
import { toRaw } from 'vue';
|
||||
|
||||
import { openWindow, startProgress, stopProgress } from '@vben-core/toolkit';
|
||||
|
||||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||
|
||||
interface TabsState {
|
||||
/**
|
||||
* @zh_CN 当前打开的标签页列表缓存
|
||||
*/
|
||||
cachedTabs: Set<string>;
|
||||
/**
|
||||
* @zh_CN 拖拽结束的索引
|
||||
*/
|
||||
dragEndIndex: number;
|
||||
/**
|
||||
* @zh_CN 需要排除缓存的标签页
|
||||
*/
|
||||
excludeCachedTabs: Set<string>;
|
||||
/**
|
||||
* @zh_CN 是否刷新
|
||||
*/
|
||||
renderRouteView?: boolean;
|
||||
/**
|
||||
* @zh_CN 当前打开的标签页列表
|
||||
*/
|
||||
tabs: TabDefinition[];
|
||||
/**
|
||||
* @zh_CN 更新时间,用于一些更新场景,使用watch深度监听的话,会损耗性能
|
||||
*/
|
||||
updateTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 访问权限相关
|
||||
*/
|
||||
const useCoreTabbarStore = defineStore('core-tabbar', {
|
||||
actions: {
|
||||
/**
|
||||
* Close tabs in bulk
|
||||
*/
|
||||
async _bulkCloseByPaths(paths: string[]) {
|
||||
this.tabs = this.tabs.filter((item) => {
|
||||
return !paths.includes(getTabPath(item));
|
||||
});
|
||||
|
||||
this.updateCacheTab();
|
||||
},
|
||||
/**
|
||||
* @zh_CN 关闭标签页
|
||||
* @param tab
|
||||
*/
|
||||
_close(tab: TabDefinition) {
|
||||
const { fullPath } = tab;
|
||||
if (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: TabDefinition, router: Router) {
|
||||
const { params, path, query } = tab;
|
||||
const toParams = {
|
||||
params: params || {},
|
||||
path,
|
||||
query: query || {},
|
||||
};
|
||||
await router.replace(toParams);
|
||||
},
|
||||
/**
|
||||
* @zh_CN 添加标签页
|
||||
* @param routeTab
|
||||
*/
|
||||
addTab(routeTab: TabDefinition) {
|
||||
const tab = cloneTab(routeTab);
|
||||
if (!isTabShown(tab)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tabIndex = this.tabs.findIndex((tab) => {
|
||||
return getTabPath(tab) === getTabPath(routeTab);
|
||||
});
|
||||
|
||||
if (tabIndex === -1) {
|
||||
// 获取动态路由打开数,超过 0 即代表需要控制打开数
|
||||
const maxNumOfOpenTab = (routeTab?.meta?.maxNumOfOpenTab ??
|
||||
-1) as number;
|
||||
// 如果动态路由层级大于 0 了,那么就要限制该路由的打开数限制了
|
||||
// 获取到已经打开的动态路由数, 判断是否大于某一个值
|
||||
if (
|
||||
maxNumOfOpenTab > 0 &&
|
||||
this.tabs.filter((tab) => tab.name === routeTab.name).length >=
|
||||
maxNumOfOpenTab
|
||||
) {
|
||||
// 关闭第一个
|
||||
const index = this.tabs.findIndex(
|
||||
(item) => item.name === routeTab.name,
|
||||
);
|
||||
index !== -1 && this.tabs.splice(index, 1);
|
||||
}
|
||||
|
||||
this.tabs.push(tab);
|
||||
} else {
|
||||
// 页面已经存在,不重复添加选项卡,只更新选项卡参数
|
||||
const currentTab = toRaw(this.tabs)[tabIndex];
|
||||
this.tabs.splice(tabIndex, 1, { ...currentTab, ...tab });
|
||||
}
|
||||
this.updateCacheTab();
|
||||
},
|
||||
/**
|
||||
* @zh_CN 关闭所有标签页
|
||||
*/
|
||||
async closeAllTabs(router: Router) {
|
||||
this.tabs = this.tabs.filter((tab) => isAffixTab(tab));
|
||||
await this._goToDefaultTab(router);
|
||||
this.updateCacheTab();
|
||||
},
|
||||
/**
|
||||
* @zh_CN 关闭左侧标签页
|
||||
* @param tab
|
||||
*/
|
||||
async closeLeftTabs(tab: TabDefinition) {
|
||||
const index = this.tabs.findIndex(
|
||||
(item) => getTabPath(item) === getTabPath(tab),
|
||||
);
|
||||
|
||||
if (index < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leftTabs = this.tabs.slice(0, index);
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const item of leftTabs) {
|
||||
if (!isAffixTab(item)) {
|
||||
paths.push(getTabPath(item));
|
||||
}
|
||||
}
|
||||
await this._bulkCloseByPaths(paths);
|
||||
},
|
||||
/**
|
||||
* @zh_CN 关闭其他标签页
|
||||
* @param tab
|
||||
*/
|
||||
async closeOtherTabs(tab: TabDefinition) {
|
||||
const closePaths = this.tabs.map((item) => getTabPath(item));
|
||||
|
||||
const paths: string[] = [];
|
||||
|
||||
for (const path of closePaths) {
|
||||
if (path !== tab.fullPath) {
|
||||
const closeTab = this.tabs.find((item) => getTabPath(item) === path);
|
||||
if (!closeTab) {
|
||||
continue;
|
||||
}
|
||||
if (!isAffixTab(closeTab)) {
|
||||
paths.push(getTabPath(closeTab));
|
||||
}
|
||||
}
|
||||
}
|
||||
await this._bulkCloseByPaths(paths);
|
||||
},
|
||||
|
||||
/**
|
||||
* @zh_CN 关闭右侧标签页
|
||||
* @param tab
|
||||
*/
|
||||
async closeRightTabs(tab: TabDefinition) {
|
||||
const index = this.tabs.findIndex(
|
||||
(item) => getTabPath(item) === 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 (!isAffixTab(item)) {
|
||||
paths.push(getTabPath(item));
|
||||
}
|
||||
}
|
||||
await this._bulkCloseByPaths(paths);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @zh_CN 关闭标签页
|
||||
* @param tab
|
||||
* @param router
|
||||
*/
|
||||
async closeTab(tab: TabDefinition, router: Router) {
|
||||
const { currentRoute } = router;
|
||||
|
||||
// 关闭不是激活选项卡
|
||||
if (getTabPath(currentRoute.value) !== getTabPath(tab)) {
|
||||
this._close(tab);
|
||||
this.updateCacheTab();
|
||||
return;
|
||||
}
|
||||
const index = this.getTabs.findIndex(
|
||||
(item) => getTabPath(item) === 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) => getTabPath(item) === key);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.closeTab(this.tabs[index], router);
|
||||
},
|
||||
/**
|
||||
* @zh_CN 新窗口打开标签页
|
||||
* @param tab
|
||||
*/
|
||||
async openTabInNewWindow(tab: TabDefinition) {
|
||||
const { hash, origin } = location;
|
||||
const path = tab.fullPath;
|
||||
const fullPath = path.startsWith('/') ? path : `/${path}`;
|
||||
const url = `${origin}${hash ? '/#' : ''}${fullPath}`;
|
||||
openWindow(url, { target: '_blank' });
|
||||
},
|
||||
|
||||
/**
|
||||
* @zh_CN 固定标签页
|
||||
* @param tab
|
||||
*/
|
||||
async pinTab(tab: TabDefinition) {
|
||||
const index = this.tabs.findIndex(
|
||||
(item) => getTabPath(item) === getTabPath(tab),
|
||||
);
|
||||
if (index !== -1) {
|
||||
tab.meta.affixTab = true;
|
||||
this.addTab(tab);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 刷新标签页
|
||||
*/
|
||||
async refresh(router: Router) {
|
||||
const { currentRoute } = router;
|
||||
const { name } = currentRoute.value;
|
||||
|
||||
this.excludeCachedTabs.add(name as string);
|
||||
this.renderRouteView = false;
|
||||
startProgress();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
this.excludeCachedTabs.delete(name as string);
|
||||
this.renderRouteView = true;
|
||||
stopProgress();
|
||||
},
|
||||
|
||||
/**
|
||||
* @zh_CN 重置标签页标题
|
||||
*/
|
||||
async resetTabTitle(tab: TabDefinition) {
|
||||
if (!tab?.meta?.newTabTitle) {
|
||||
return;
|
||||
}
|
||||
const findTab = this.tabs.find(
|
||||
(item) => getTabPath(item) === getTabPath(tab),
|
||||
);
|
||||
if (findTab) {
|
||||
findTab.meta.newTabTitle = undefined;
|
||||
await this.updateCacheTab();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置固定标签页
|
||||
* @param tabs
|
||||
*/
|
||||
setAffixTabs(tabs: RouteRecordNormalized[]) {
|
||||
for (const tab of tabs) {
|
||||
tab.meta.affixTab = true;
|
||||
this.addTab(routeToTab(tab));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @zh_CN 设置标签页标题
|
||||
* @param tab
|
||||
* @param title
|
||||
*/
|
||||
async setTabTitle(tab: TabDefinition, title: string) {
|
||||
const findTab = this.tabs.find(
|
||||
(item) => getTabPath(item) === getTabPath(tab),
|
||||
);
|
||||
|
||||
if (findTab) {
|
||||
findTab.meta.newTabTitle = title;
|
||||
|
||||
await this.updateCacheTab();
|
||||
}
|
||||
},
|
||||
|
||||
async setUpdateTime() {
|
||||
this.updateTime = Date.now();
|
||||
},
|
||||
/**
|
||||
* @zh_CN 设置标签页顺序
|
||||
* @param oldIndex
|
||||
* @param newIndex
|
||||
*/
|
||||
async sortTabs(oldIndex: number, newIndex: number) {
|
||||
const currentTab = this.tabs[oldIndex];
|
||||
this.tabs.splice(oldIndex, 1);
|
||||
this.tabs.splice(newIndex, 0, currentTab);
|
||||
this.dragEndIndex = this.dragEndIndex + 1;
|
||||
},
|
||||
/**
|
||||
* @zh_CN 切换固定标签页
|
||||
* @param tab
|
||||
*/
|
||||
async toggleTabPin(tab: TabDefinition) {
|
||||
const affixTab = tab?.meta?.affixTab ?? false;
|
||||
await (affixTab ? this.unpinTab(tab) : this.pinTab(tab));
|
||||
},
|
||||
|
||||
/**
|
||||
* @zh_CN 取消固定标签页
|
||||
* @param tab
|
||||
*/
|
||||
async unpinTab(tab: TabDefinition) {
|
||||
const index = this.tabs.findIndex(
|
||||
(item) => getTabPath(item) === getTabPath(tab),
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
tab.meta.affixTab = false;
|
||||
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.cachedTabs = cacheMap;
|
||||
},
|
||||
},
|
||||
getters: {
|
||||
affixTabs(): TabDefinition[] {
|
||||
return this.tabs.filter((tab) => isAffixTab(tab));
|
||||
},
|
||||
getCachedTabs(): string[] {
|
||||
return [...this.cachedTabs];
|
||||
},
|
||||
getExcludeCachedTabs(): string[] {
|
||||
return [...this.excludeCachedTabs];
|
||||
},
|
||||
getTabs(): TabDefinition[] {
|
||||
const affixTabs = this.tabs.filter((tab) => isAffixTab(tab));
|
||||
const normalTabs = this.tabs.filter((tab) => !isAffixTab(tab));
|
||||
return [...affixTabs, ...normalTabs].filter(Boolean);
|
||||
},
|
||||
},
|
||||
persist: [
|
||||
// tabs不需要保存在localStorage
|
||||
{
|
||||
paths: ['tabs'],
|
||||
storage: sessionStorage,
|
||||
},
|
||||
],
|
||||
state: (): TabsState => ({
|
||||
cachedTabs: new Set(),
|
||||
dragEndIndex: 0,
|
||||
excludeCachedTabs: new Set(),
|
||||
renderRouteView: true,
|
||||
tabs: [],
|
||||
updateTime: Date.now(),
|
||||
}),
|
||||
});
|
||||
|
||||
// 解决热更新问题
|
||||
const hot = import.meta.hot;
|
||||
if (hot) {
|
||||
hot.accept(acceptHMRUpdate(useCoreTabbarStore, hot));
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 克隆路由,防止路由被修改
|
||||
* @param route
|
||||
*/
|
||||
function cloneTab(route: TabDefinition): TabDefinition {
|
||||
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[],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 是否是固定标签页
|
||||
* @param tab
|
||||
*/
|
||||
function isAffixTab(tab: TabDefinition) {
|
||||
return tab?.meta?.affixTab ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 是否显示标签
|
||||
* @param tab
|
||||
*/
|
||||
function isTabShown(tab: TabDefinition) {
|
||||
return !tab.meta.hideInTab;
|
||||
}
|
||||
|
||||
/**
|
||||
* @zh_CN 获取标签页路径
|
||||
* @param tab
|
||||
*/
|
||||
function getTabPath(tab: RouteRecordNormalized | TabDefinition) {
|
||||
return decodeURIComponent((tab as TabDefinition).fullPath || tab.path);
|
||||
}
|
||||
|
||||
function routeToTab(route: RouteRecordNormalized) {
|
||||
return {
|
||||
meta: route.meta,
|
||||
name: route.name,
|
||||
path: route.path,
|
||||
} as TabDefinition;
|
||||
}
|
||||
|
||||
export { useCoreTabbarStore };
|
||||
@@ -1,29 +0,0 @@
|
||||
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 };
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src", "shim-pinia.d.ts"]
|
||||
}
|
||||
@@ -6,13 +6,12 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/shared/hooks"
|
||||
"directory": "packages/@core/hooks"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm unbuild --stub"
|
||||
"build": "pnpm unbuild"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
@@ -37,14 +36,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/constants": "workspace:*",
|
||||
"@vben-core/preferences": "workspace:*",
|
||||
"@vben-core/stores": "workspace:*",
|
||||
"@vben-core/toolkit": "workspace:*",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"radix-vue": "^1.9.2",
|
||||
"sortablejs": "^1.15.2",
|
||||
"vue": "^3.4.33",
|
||||
"vue-router": "^4.4.0"
|
||||
"vue": "^3.4.33"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sortablejs": "^1.15.8"
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
export * from './use-content-height';
|
||||
export * from './use-content-maximize';
|
||||
export * from './use-namespace';
|
||||
export * from './use-refresh';
|
||||
export * from './use-sortable';
|
||||
export * from './use-tabs';
|
||||
export {
|
||||
useEmitAsProps,
|
||||
useForwardExpose,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { updatePreferences, usePreferences } from '@vben-core/preferences';
|
||||
/**
|
||||
* 主体区域最大化
|
||||
*/
|
||||
export function useContentMaximize() {
|
||||
const { contentIsMaximize } = usePreferences();
|
||||
|
||||
function toggleMaximize() {
|
||||
const isMaximize = contentIsMaximize.value;
|
||||
|
||||
updatePreferences({
|
||||
header: {
|
||||
hidden: !isMaximize,
|
||||
},
|
||||
sidebar: {
|
||||
hidden: !isMaximize,
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
contentIsMaximize,
|
||||
toggleMaximize,
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useCoreTabbarStore } from '@vben-core/stores';
|
||||
|
||||
export function useRefresh() {
|
||||
const router = useRouter();
|
||||
const coreTabbarStore = useCoreTabbarStore();
|
||||
|
||||
function refresh() {
|
||||
coreTabbarStore.refresh(router);
|
||||
}
|
||||
|
||||
return {
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { type RouteLocationNormalized, useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useCoreTabbarStore } from '@vben-core/stores';
|
||||
|
||||
export function useTabs() {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const coreTabbarStore = useCoreTabbarStore();
|
||||
|
||||
async function closeLeftTabs(tab?: RouteLocationNormalized) {
|
||||
await coreTabbarStore.closeLeftTabs(tab || route);
|
||||
}
|
||||
|
||||
async function closeAllTabs() {
|
||||
await coreTabbarStore.closeAllTabs(router);
|
||||
}
|
||||
|
||||
async function closeRightTabs(tab?: RouteLocationNormalized) {
|
||||
await coreTabbarStore.closeRightTabs(tab || route);
|
||||
}
|
||||
|
||||
async function closeOtherTabs(tab?: RouteLocationNormalized) {
|
||||
await coreTabbarStore.closeOtherTabs(tab || route);
|
||||
}
|
||||
|
||||
async function closeCurrentTab(tab?: RouteLocationNormalized) {
|
||||
await coreTabbarStore.closeTab(tab || route, router);
|
||||
}
|
||||
|
||||
async function pinTab(tab?: RouteLocationNormalized) {
|
||||
await coreTabbarStore.pinTab(tab || route);
|
||||
}
|
||||
|
||||
async function unpinTab(tab?: RouteLocationNormalized) {
|
||||
await coreTabbarStore.unpinTab(tab || route);
|
||||
}
|
||||
|
||||
async function toggleTabPin(tab?: RouteLocationNormalized) {
|
||||
await coreTabbarStore.toggleTabPin(tab || route);
|
||||
}
|
||||
|
||||
async function refreshTab() {
|
||||
await coreTabbarStore.refresh(router);
|
||||
}
|
||||
|
||||
async function openTabInNewWindow(tab?: RouteLocationNormalized) {
|
||||
coreTabbarStore.openTabInNewWindow(tab || route);
|
||||
}
|
||||
|
||||
async function closeTabByKey(key: string) {
|
||||
await coreTabbarStore.closeTabByKey(key, router);
|
||||
}
|
||||
|
||||
async function setTabTitle(title: string) {
|
||||
coreTabbarStore.setUpdateTime();
|
||||
await coreTabbarStore.setTabTitle(route, title);
|
||||
}
|
||||
|
||||
async function resetTabTitle() {
|
||||
coreTabbarStore.setUpdateTime();
|
||||
await coreTabbarStore.resetTabTitle(route);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作是否禁用
|
||||
* @param tab
|
||||
*/
|
||||
function getTabDisableState(tab: RouteLocationNormalized = route) {
|
||||
const tabs = coreTabbarStore.getTabs;
|
||||
const affixTabs = coreTabbarStore.affixTabs;
|
||||
const index = tabs.findIndex((item) => item.path === tab.path);
|
||||
|
||||
const disabled = tabs.length <= 1;
|
||||
|
||||
const { meta } = tab;
|
||||
const affixTab = meta?.affixTab ?? false;
|
||||
const isCurrentTab = route.path === tab.path;
|
||||
|
||||
// 当前处于最左侧或者减去固定标签页的数量等于0
|
||||
const disabledCloseLeft =
|
||||
index === 0 || index - affixTabs.length <= 0 || !isCurrentTab;
|
||||
|
||||
const disabledCloseRight = !isCurrentTab || index === tabs.length - 1;
|
||||
|
||||
const disabledCloseOther =
|
||||
disabled || !isCurrentTab || tabs.length - affixTabs.length <= 1;
|
||||
return {
|
||||
disabledCloseAll: disabled,
|
||||
disabledCloseCurrent: !!affixTab || disabled,
|
||||
disabledCloseLeft,
|
||||
disabledCloseOther,
|
||||
disabledCloseRight,
|
||||
disabledRefresh: !isCurrentTab,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
closeAllTabs,
|
||||
closeCurrentTab,
|
||||
closeLeftTabs,
|
||||
closeOtherTabs,
|
||||
closeRightTabs,
|
||||
closeTabByKey,
|
||||
getTabDisableState,
|
||||
openTabInNewWindow,
|
||||
pinTab,
|
||||
refreshTab,
|
||||
resetTabTitle,
|
||||
setTabTitle,
|
||||
toggleTabPin,
|
||||
unpinTab,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineBuildConfig } from 'unbuild';
|
||||
|
||||
export default defineBuildConfig({
|
||||
clean: true,
|
||||
declaration: true,
|
||||
entries: [
|
||||
'src/index',
|
||||
{
|
||||
builder: 'mkdist',
|
||||
input: './src/langs',
|
||||
// loaders: ['postcss'],
|
||||
outDir: './dist/langs',
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
"name": "@vben-core/locales",
|
||||
"version": "5.0.0",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/forward/locales"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm unbuild --stub"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
},
|
||||
"./langs/*": {
|
||||
"default": "./dist/langs/*"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.mjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "^9.13.1",
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"vue": "^3.4.33",
|
||||
"vue-i18n": "^9.13.1"
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { SupportedLanguagesType } from '@vben-core/typings';
|
||||
import type { Locale } from 'vue-i18n';
|
||||
|
||||
import type { ImportLocaleFn } from './typing';
|
||||
|
||||
import { unref } from 'vue';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
const loadedLanguages = new Set<string>();
|
||||
|
||||
const i18n = createI18n({
|
||||
globalInjection: true,
|
||||
legacy: false,
|
||||
locale: '',
|
||||
messages: {},
|
||||
});
|
||||
|
||||
const modules = {
|
||||
'./langs/en-US.json': async () => import('./langs/en-US.json'),
|
||||
'./langs/zh-CN.json': async () => import('./langs/zh-CN.json'),
|
||||
};
|
||||
|
||||
const localesMap = loadLocalesMap(modules);
|
||||
|
||||
/**
|
||||
* Load locale modules
|
||||
* @param modules
|
||||
*/
|
||||
function loadLocalesMap(modules: Record<string, () => Promise<unknown>>) {
|
||||
const localesMap: Record<Locale, ImportLocaleFn> = {};
|
||||
|
||||
for (const [path, loadLocale] of Object.entries(modules)) {
|
||||
const key = path.match(/([\w-]*)\.(yaml|yml|json)/)?.[1];
|
||||
if (key) {
|
||||
localesMap[key] = loadLocale as ImportLocaleFn;
|
||||
}
|
||||
}
|
||||
|
||||
return localesMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set i18n language
|
||||
* @param locale
|
||||
*/
|
||||
function setI18nLanguage(locale: Locale) {
|
||||
i18n.global.locale.value = locale;
|
||||
|
||||
document?.querySelector('html')?.setAttribute('lang', locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load locale messages
|
||||
* @param lang
|
||||
*/
|
||||
async function loadI18nMessages(lang: SupportedLanguagesType) {
|
||||
if (unref(i18n.global.locale) === lang) {
|
||||
return setI18nLanguage(lang);
|
||||
}
|
||||
|
||||
if (loadedLanguages.has(lang)) {
|
||||
return setI18nLanguage(lang);
|
||||
}
|
||||
|
||||
const message = await localesMap[lang]();
|
||||
|
||||
i18n.global.setLocaleMessage(lang, message.default);
|
||||
loadedLanguages.add(lang);
|
||||
return setI18nLanguage(lang);
|
||||
}
|
||||
|
||||
export { i18n, loadI18nMessages, loadLocalesMap, setI18nLanguage };
|
||||
@@ -1,43 +0,0 @@
|
||||
import type {
|
||||
ImportLocaleFn,
|
||||
LoadMessageFn,
|
||||
LocaleSetupOptions,
|
||||
SupportedLanguagesType,
|
||||
} from './typing';
|
||||
|
||||
import type { App } from 'vue';
|
||||
|
||||
import { i18n, loadI18nMessages, loadLocalesMap } from './i18n';
|
||||
|
||||
const $t = i18n.global.t;
|
||||
|
||||
let loadMessages: LoadMessageFn;
|
||||
|
||||
async function loadLocaleMessages(lang: SupportedLanguagesType) {
|
||||
const mergeMessage = await loadMessages(lang);
|
||||
await loadI18nMessages(lang);
|
||||
i18n.global.mergeLocaleMessage(lang, mergeMessage);
|
||||
}
|
||||
|
||||
async function setupI18n(app: App, options: LocaleSetupOptions = {}) {
|
||||
const { defaultLocale = 'zh-CN' } = options;
|
||||
// app可以自行扩展一些第三方库和组件库的国际化
|
||||
loadMessages = options.loadMessages || (async () => ({}));
|
||||
app.use(i18n);
|
||||
await loadLocaleMessages(defaultLocale);
|
||||
|
||||
// 在控制台打印警告
|
||||
i18n.global.setMissingHandler((locale, key) => {
|
||||
if (options.missingWarn && key.includes('.')) {
|
||||
console.warn(
|
||||
`[intlify] Not found '${key}' key in '${locale}' locale messages.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { $t, i18n, loadLocaleMessages, loadLocalesMap, setupI18n };
|
||||
export type { CompileError } from '@intlify/core-base';
|
||||
export { useI18n } from 'vue-i18n';
|
||||
export type { ImportLocaleFn };
|
||||
export type { Locale } from 'vue-i18n';
|
||||
@@ -1,292 +0,0 @@
|
||||
{
|
||||
"page": {
|
||||
"core": {
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"codeLogin": "Code Login",
|
||||
"qrcodeLogin": "Qr Code Login",
|
||||
"forgetPassword": "Forget Password"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"analytics": "Analytics",
|
||||
"workspace": "Workspace"
|
||||
},
|
||||
"vben": {
|
||||
"title": "Project",
|
||||
"about": "About",
|
||||
"document": "Document"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"back": "Back",
|
||||
"backToHome": "Back To Home",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"prompt": "Prompt",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Comfirm",
|
||||
"noData": "No Data",
|
||||
"refresh": "Refresh",
|
||||
"loadingMenu": "Loading Menu"
|
||||
},
|
||||
"fallback": {
|
||||
"pageNotFound": "Oops! Page Not Found",
|
||||
"pageNotFoundDesc": "Sorry, we couldn't find the page you were looking for.",
|
||||
"forbidden": "Oops! Access Denied",
|
||||
"forbiddenDesc": "Sorry, but you don't have permission to access this page.",
|
||||
"internalError": "Oops! Something Went Wrong",
|
||||
"internalErrorDesc": "Sorry, but the server encountered an error.",
|
||||
"offline": "Offline Page",
|
||||
"offlineError": "Oops! Network Error",
|
||||
"offlineErrorDesc": "Sorry, can't connect to the internet. Check your connection.",
|
||||
"comingSoon": "Coming Soon",
|
||||
"http": {
|
||||
"requestTimeout": "The request timed out. Please try again later.",
|
||||
"networkError": "A network error occurred. Please check your internet connection and try again.",
|
||||
"badRequest": "Bad Request. Please check your input and try again.",
|
||||
"unauthorized": "Unauthorized. Please log in to continue.",
|
||||
"forbidden": "Forbidden. You do not have permission to access this resource.",
|
||||
"notFound": "Not Found. The requested resource could not be found.",
|
||||
"internalServerError": "Internal Server Error. Something went wrong on our end. Please try again later."
|
||||
}
|
||||
},
|
||||
"widgets": {
|
||||
"document": "Document",
|
||||
"qa": "Q&A",
|
||||
"setting": "Settings",
|
||||
"logoutTip": "Do you want to logout?",
|
||||
"viewAll": "View All Messages",
|
||||
"notifications": "Notifications",
|
||||
"markAllAsRead": "Make All as Read",
|
||||
"clearNotifications": "Clear",
|
||||
"search": {
|
||||
"title": "Search",
|
||||
"searchNavigate": "Search Navigation",
|
||||
"select": "Select",
|
||||
"navigate": "Navigate",
|
||||
"close": "Close",
|
||||
"noResults": "No Search Results Found",
|
||||
"noRecent": "No Search History",
|
||||
"recent": "Search History"
|
||||
},
|
||||
"lockScreen": {
|
||||
"title": "Lock Screen",
|
||||
"screenButton": "Locking",
|
||||
"password": "Password",
|
||||
"placeholder": "Please enter password",
|
||||
"unlock": "Click to unlock",
|
||||
"errorPasswordTip": "Password error, please re-enter",
|
||||
"backToLogin": "Back to login",
|
||||
"entry": "Enter the system"
|
||||
}
|
||||
},
|
||||
"authentication": {
|
||||
"welcomeBack": "Welcome Back",
|
||||
"pageTitle": "Plug-and-play Admin system",
|
||||
"pageDesc": "Efficient, versatile frontend template",
|
||||
"loginSuccess": "Login Successful",
|
||||
"loginSuccessDesc": "Welcome Back",
|
||||
"loginSubtitle": "Enter your account details to manage your projects",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"usernameTip": "Please enter username",
|
||||
"passwordTip": "Please enter password",
|
||||
"rememberMe": "Remember Me",
|
||||
"createAnAccount": "Create an Account",
|
||||
"createAccount": "Create Account",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"accountTip": "Don't have an account?",
|
||||
"signUp": "Sign Up",
|
||||
"signUpSubtitle": "Make managing your applications simple and fun",
|
||||
"confirmPassword": "Comfirm Password",
|
||||
"confirmPasswordTip": "The passwords do not match",
|
||||
"agree": "I agree to",
|
||||
"privacyPolicy": "Privacy-policy",
|
||||
"terms": "Terms",
|
||||
"agreeTip": "Please agree to the Privacy Policy and Terms",
|
||||
"goToLogin": "Login instead",
|
||||
"passwordStrength": "Use 8 or more characters with a mix of letters, numbers & symbols",
|
||||
"forgetPassword": "Forget Password?",
|
||||
"forgetPasswordSubtitle": "Enter your email and we'll send you instructions to reset your password",
|
||||
"emailTip": "Please enter email",
|
||||
"sendResetLink": "Send Reset Link",
|
||||
"email": "Email",
|
||||
"qrcodeSubtitle": "Scan the QR code with your phone to login",
|
||||
"qrcodePrompt": "Click 'Confirm' after scanning to complete login",
|
||||
"qrcodeLogin": "QR Code Login",
|
||||
"codeSubtitle": "Enter your phone number to start managing your project",
|
||||
"code": "Security code",
|
||||
"codeTip": "Security code is required",
|
||||
"mobile": "Mobile",
|
||||
"mobileLogin": "Mobile Login",
|
||||
"mobile-tip": "Please enter phone number",
|
||||
"sendCode": "Get Security code",
|
||||
"sendText": "Resend in {0}s",
|
||||
"thirdPartyLogin": "Or continue with",
|
||||
"loginAgainTitle": "Please Log In Again",
|
||||
"loginAgainSubTitle": "Your login session has expired. Please log in again to continue.",
|
||||
"layout": {
|
||||
"center": "Align Center",
|
||||
"alignLeft": "Align Left",
|
||||
"alignRight": "Align Right"
|
||||
}
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"subtitle": "Customize Preferences & Preview in Real Time",
|
||||
"resetTip": "Data has changed, click to reset",
|
||||
"resetTitle": "Reset Preferences",
|
||||
"resetSuccess": "Preferences reset successfully",
|
||||
"appearance": "Appearance",
|
||||
"layout": "Layout",
|
||||
"content": "Content",
|
||||
"other": "Other",
|
||||
"wide": "Wide",
|
||||
"compact": "Fixed",
|
||||
"followSystem": "Follow System",
|
||||
"vertical": "Vertical",
|
||||
"verticalTip": "Side vertical menu mode",
|
||||
"horizontal": "Horizontal",
|
||||
"horizontalTip": "Horizontal menu mode, all menus displayed at the top",
|
||||
"twoColumn": "Two Column",
|
||||
"twoColumnTip": "Vertical Two Column Menu Mode",
|
||||
"mixedMenu": "Mixed Menu",
|
||||
"mixedMenuTip": "Vertical & Horizontal Menu Co-exists",
|
||||
"fullContent": "Full Content",
|
||||
"fullContentTip": "Only display content body, hide all menus",
|
||||
"normal": "Normal",
|
||||
"plain": "Plain",
|
||||
"rounded": "Rounded",
|
||||
"copyPreferences": "Copy Preferences",
|
||||
"copyPreferencesSuccess": "Copy successful, please override in `src/preferences.ts` under app",
|
||||
"clearAndLogout": "Clear Cache & Logout",
|
||||
"mode": "Mode",
|
||||
"general": "General",
|
||||
"language": "Language",
|
||||
"dynamicTitle": "Dynamic Title",
|
||||
"sidebar": {
|
||||
"title": "Sidebar",
|
||||
"width": "Width",
|
||||
"visible": "Show Sidebar",
|
||||
"collapsed": "Collpase Menu",
|
||||
"collapsedShowTitle": "Show Menu Title"
|
||||
},
|
||||
"tabbar": {
|
||||
"title": "Tabbar",
|
||||
"enable": "Enable Tab Bar",
|
||||
"icon": "Show Tabbar Icon",
|
||||
"persist": "Persist Tabs",
|
||||
"dragable": "Enable Dragable Sort",
|
||||
"styleType": {
|
||||
"title": "Tabs Style",
|
||||
"chrome": "Chrome",
|
||||
"card": "Card",
|
||||
"plain": "Plain",
|
||||
"brisk": "Brisk"
|
||||
},
|
||||
"contextMenu": {
|
||||
"reload": "Reload",
|
||||
"close": "Close",
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
"closeLeft": "Close Left Tabs",
|
||||
"closeRight": "Close Right Tabs",
|
||||
"closeOther": "Close Other Tabs",
|
||||
"closeAll": "Close All Tabs",
|
||||
"openInNewWindow": "Open in New Window",
|
||||
"maximize": "Maximize",
|
||||
"restoreMaximize": "Restore"
|
||||
}
|
||||
},
|
||||
"navigationMenu": {
|
||||
"title": "Navigation Menu",
|
||||
"style": "Navigation Menu Style",
|
||||
"accordion": "Sidebar Accordion Menu",
|
||||
"split": "Navigation Menu Separation",
|
||||
"splitTip": "When enabled, the sidebar displays the top bar's submenu"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"title": "Breadcrumb",
|
||||
"home": "Show Home Button",
|
||||
"enable": "Enable Breadcrumb",
|
||||
"icon": "Show Breadcrumb Icon",
|
||||
"background": "background",
|
||||
"style": "Breadcrumb Style",
|
||||
"hideOnlyOne": "Hidden when only one"
|
||||
},
|
||||
"animation": {
|
||||
"title": "Animation",
|
||||
"loading": "Page Loading",
|
||||
"transition": "Page Transition",
|
||||
"progress": "Page Progress"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme",
|
||||
"radius": "Radius",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"darkMenu": "Semi Dark Menu",
|
||||
"weakMode": "Weak Mode",
|
||||
"grayMode": "Gray Mode",
|
||||
"builtin": {
|
||||
"title": "Built-in",
|
||||
"default": "Default",
|
||||
"violet": "Violet",
|
||||
"pink": "Pink",
|
||||
"rose": "Rose",
|
||||
"skyBlue": "Sky Blue",
|
||||
"deepBlue": "Deep Blue",
|
||||
"green": "Green",
|
||||
"deepGreen": "Deep Green",
|
||||
"orange": "Orange",
|
||||
"yellow": "Yellow",
|
||||
"zinc": "Zinc",
|
||||
"neutral": "Neutral",
|
||||
"slate": "Slate",
|
||||
"gray": "Gray",
|
||||
"custom": "Custom"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"title": "Header",
|
||||
"visible": "Show Header",
|
||||
"modeStatic": "Static",
|
||||
"modeFixed": "Fixed",
|
||||
"modeAuto": "Auto hide & Show",
|
||||
"modeAutoScroll": "Scroll to Hide & Show"
|
||||
},
|
||||
"footer": {
|
||||
"title": "Footer",
|
||||
"visible": "Show Footer",
|
||||
"fixed": "Fixed at Bottom"
|
||||
},
|
||||
"copyright": {
|
||||
"title": "Copyright",
|
||||
"enable": "Enable Copyright",
|
||||
"companyName": "Company Name",
|
||||
"companySiteLink": "Company Site Link",
|
||||
"date": "Date",
|
||||
"icp": "ICP License Number",
|
||||
"icpLink": "ICP Site Link"
|
||||
},
|
||||
"shortcutKeys": {
|
||||
"title": "Shortcut Keys",
|
||||
"global": "Global",
|
||||
"search": "Global Search",
|
||||
"logout": "Logout",
|
||||
"preferences": "Preferences"
|
||||
},
|
||||
"widget": {
|
||||
"title": "Widget",
|
||||
"globalSearch": "Enable Global Search",
|
||||
"fullscreen": "Enable Fullscreen",
|
||||
"themeToggle": "Enable Theme Toggle",
|
||||
"languageToggle": "Enable Language Toggle",
|
||||
"notification": "Enable Notification",
|
||||
"sidebarToggle": "Enable Sidebar Toggle",
|
||||
"aiAssistant": "Enable AI Assistant",
|
||||
"lockScreen": "Enable Lock Screen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
{
|
||||
"page": {
|
||||
"core": {
|
||||
"login": "登陆",
|
||||
"register": "注册",
|
||||
"codeLogin": "验证码登陆",
|
||||
"qrcodeLogin": "二维码登陆",
|
||||
"forgetPassword": "忘记密码"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "概览",
|
||||
"analytics": "分析页",
|
||||
"workspace": "工作台"
|
||||
},
|
||||
"vben": {
|
||||
"title": "项目",
|
||||
"about": "关于",
|
||||
"document": "文档"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"back": "返回",
|
||||
"backToHome": "返回首页",
|
||||
"login": "登录",
|
||||
"logout": "退出登录",
|
||||
"prompt": "提示",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"noData": "暂无数据",
|
||||
"refresh": "刷新",
|
||||
"loadingMenu": "加载菜单中"
|
||||
},
|
||||
"fallback": {
|
||||
"pageNotFound": "哎呀!未找到页面",
|
||||
"pageNotFoundDesc": "抱歉,我们无法找到您要找的页面。",
|
||||
"forbidden": "哎呀!访问被拒绝",
|
||||
"forbiddenDesc": "抱歉,您没有权限访问此页面。",
|
||||
"internalError": "哎呀!出错了",
|
||||
"internalErrorDesc": "抱歉,服务器遇到错误。",
|
||||
"offline": "离线页面",
|
||||
"offlineError": "哎呀!网络错误",
|
||||
"offlineErrorDesc": "抱歉,无法连接到互联网,请检查您的网络连接并重试。",
|
||||
"comingSoon": "即将推出",
|
||||
"http": {
|
||||
"requestTimeout": "请求超时,请稍后再试。",
|
||||
"networkError": "网络异常,请检查您的网络连接后重试。",
|
||||
"badRequest": "请求错误。请检查您的输入并重试。",
|
||||
"unauthorized": "登录认证过期。请重新登录后继续。",
|
||||
"forbidden": "禁止访问, 您没有权限访问此资源。",
|
||||
"notFound": "未找到, 请求的资源不存在。",
|
||||
"internalServerError": "内部服务器错误,请稍后再试。"
|
||||
}
|
||||
},
|
||||
"widgets": {
|
||||
"document": "文档",
|
||||
"qa": "问题 & 帮助",
|
||||
"setting": "设置",
|
||||
"logoutTip": "是否退出登录?",
|
||||
"viewAll": "查看所有消息",
|
||||
"notifications": "通知",
|
||||
"markAllAsRead": "全部标记为已读",
|
||||
"clearNotifications": "清空",
|
||||
"search": {
|
||||
"title": "搜索",
|
||||
"searchNavigate": "搜索导航菜单",
|
||||
"select": "选择",
|
||||
"navigate": "导航",
|
||||
"close": "关闭",
|
||||
"noResults": "未找到搜索结果",
|
||||
"noRecent": "没有搜索历史",
|
||||
"recent": "搜索历史"
|
||||
},
|
||||
"lockScreen": {
|
||||
"title": "锁定屏幕",
|
||||
"screenButton": "锁定",
|
||||
"password": "密码",
|
||||
"placeholder": "请输入锁屏密码",
|
||||
"unlock": "点击解锁",
|
||||
"errorPasswordTip": "密码错误,请重新输入",
|
||||
"backToLogin": "返回登录",
|
||||
"entry": "进入系统"
|
||||
}
|
||||
},
|
||||
"authentication": {
|
||||
"welcomeBack": "欢迎回来",
|
||||
"pageTitle": "开箱即用的大型中后台管理系统",
|
||||
"pageDesc": "工程化、高性能、跨组件库的前端模版",
|
||||
"loginSuccess": "登录成功",
|
||||
"loginSuccessDesc": "欢迎回来",
|
||||
"loginSubtitle": "请输入您的帐户信息以开始管理您的项目",
|
||||
"username": "账号",
|
||||
"password": "密码",
|
||||
"usernameTip": "请输入用户名",
|
||||
"passwordTip": "请输入密码",
|
||||
"rememberMe": "记住账号",
|
||||
"createAnAccount": "创建一个账号",
|
||||
"createAccount": "创建账号",
|
||||
"alreadyHaveAccount": "已经有账号了?",
|
||||
"accountTip": "还没有账号?",
|
||||
"signUp": "注册",
|
||||
"signUpSubtitle": "让您的应用程序管理变得简单而有趣",
|
||||
"confirmPassword": "确认密码",
|
||||
"confirmPasswordTip": "两次输入的密码不一致",
|
||||
"agree": "我同意",
|
||||
"privacyPolicy": "隐私政策",
|
||||
"terms": "条款",
|
||||
"agreeTip": "请同意隐私政策和条款",
|
||||
"goToLogin": "去登录",
|
||||
"passwordStrength": "使用 8 个或更多字符,混合字母、数字和符号",
|
||||
"forgetPassword": "忘记密码?",
|
||||
"forgetPasswordSubtitle": "输入您的电子邮件,我们将向您发送重置密码的连接",
|
||||
"emailTip": "请输入邮箱",
|
||||
"sendResetLink": "发送重置链接",
|
||||
"email": "邮箱",
|
||||
"qrcodeSubtitle": "请用手机扫描二维码登录",
|
||||
"qrcodePrompt": "扫码后点击 '确认',即可完成登录",
|
||||
"qrcodeLogin": "扫码登录",
|
||||
"codeSubtitle": "请输入您的手机号码以开始管理您的项目",
|
||||
"code": "验证码",
|
||||
"codeTip": "请输入验证码",
|
||||
"mobile": "手机号码",
|
||||
"mobileLogin": "手机号登录",
|
||||
"mobile-tip": "请输入手机号码",
|
||||
"sendCode": "获取验证码",
|
||||
"sendText": "{0}秒后重新获取",
|
||||
"thirdPartyLogin": "其他登录方式",
|
||||
"loginAgainTitle": "重新登录",
|
||||
"loginAgainSubTitle": "您的登录状态已过期,请重新登录以继续。",
|
||||
"layout": {
|
||||
"center": "居中",
|
||||
"alignLeft": "居左",
|
||||
"alignRight": "居右"
|
||||
}
|
||||
},
|
||||
"preferences": {
|
||||
"title": "偏好设置",
|
||||
"subtitle": "自定义偏好设置 & 实时预览",
|
||||
"resetTitle": "重置偏好设置",
|
||||
"resetTip": "数据有变化,点击可进行重置",
|
||||
"resetSuccess": "重置偏好设置成功",
|
||||
"appearance": "外观",
|
||||
"layout": "布局",
|
||||
"content": "内容",
|
||||
"other": "其它",
|
||||
"wide": "流式",
|
||||
"compact": "定宽",
|
||||
"followSystem": "跟随系统",
|
||||
"vertical": "垂直",
|
||||
"verticalTip": "侧边垂直菜单模式",
|
||||
"horizontal": "水平",
|
||||
"horizontalTip": "水平菜单模式,菜单全部显示在顶部",
|
||||
"twoColumn": "双列菜单",
|
||||
"twoColumnTip": "垂直双列菜单模式",
|
||||
"mixedMenu": "混合菜单",
|
||||
"mixedMenuTip": "垂直水平菜单共存",
|
||||
"fullContent": "内容全屏",
|
||||
"fullContentTip": "不显示任何菜单,只显示内容主体",
|
||||
"normal": "常规",
|
||||
"plain": "朴素",
|
||||
"rounded": "圆润",
|
||||
"copyPreferences": "复制偏好设置",
|
||||
"copyPreferencesSuccess": "复制成功,请在 app 下的 `src/preferences.ts`内进行覆盖",
|
||||
"clearAndLogout": "清空缓存 & 退出登录",
|
||||
"mode": "模式",
|
||||
"general": "通用",
|
||||
"language": "语言",
|
||||
"dynamicTitle": "动态标题",
|
||||
"sidebar": {
|
||||
"title": "侧边栏",
|
||||
"width": "宽度",
|
||||
"visible": "显示侧边栏",
|
||||
"collapsed": "折叠菜单",
|
||||
"collapsedShowTitle": "折叠显示菜单名"
|
||||
},
|
||||
"tabbar": {
|
||||
"title": "标签栏",
|
||||
"enable": "启用标签栏",
|
||||
"icon": "显示标签栏图标",
|
||||
"persist": "持久化标签页",
|
||||
"dragable": "启动拖拽排序",
|
||||
"styleType": {
|
||||
"title": "标签页风格",
|
||||
"chrome": "谷歌",
|
||||
"card": "卡片",
|
||||
"plain": "朴素",
|
||||
"brisk": "轻快"
|
||||
},
|
||||
"contextMenu": {
|
||||
"reload": "重新加载",
|
||||
"close": "关闭",
|
||||
"pin": "固定",
|
||||
"unpin": "取消固定",
|
||||
"closeLeft": "关闭左侧标签页",
|
||||
"closeRight": "关闭右侧标签页",
|
||||
"closeOther": "关闭其它标签页",
|
||||
"closeAll": "关闭全部标签页",
|
||||
"openInNewWindow": "在新窗口打开",
|
||||
"maximize": "最大化",
|
||||
"restoreMaximize": "还原"
|
||||
}
|
||||
},
|
||||
"navigationMenu": {
|
||||
"title": "导航菜单",
|
||||
"style": "导航菜单风格",
|
||||
"accordion": "侧边导航菜单手风琴模式",
|
||||
"split": "导航菜单分离",
|
||||
"splitTip": "开启时,侧边栏显示顶栏对应菜单的子菜单"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"title": "面包屑导航",
|
||||
"enable": "开启面包屑导航",
|
||||
"icon": "显示面包屑图标",
|
||||
"home": "显示首页按钮",
|
||||
"style": "面包屑风格",
|
||||
"hideOnlyOne": "仅有一个时隐藏",
|
||||
"background": "背景"
|
||||
},
|
||||
"animation": {
|
||||
"title": "动画",
|
||||
"loading": "页面切换 Loading",
|
||||
"transition": "页面切换动画",
|
||||
"progress": "页面切换进度条"
|
||||
},
|
||||
"theme": {
|
||||
"title": "主题",
|
||||
"radius": "圆角",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"darkMenu": "深色菜单",
|
||||
"weakMode": "色弱模式",
|
||||
"grayMode": "灰色模式",
|
||||
"builtin": {
|
||||
"title": "内置主题",
|
||||
"default": "默认",
|
||||
"violet": "紫罗兰",
|
||||
"pink": "樱花粉",
|
||||
"rose": "玫瑰红",
|
||||
"skyBlue": "天蓝色",
|
||||
"deepBlue": "深蓝色",
|
||||
"green": "浅绿色",
|
||||
"deepGreen": "深绿色",
|
||||
"orange": "橙黄色",
|
||||
"yellow": "柠檬黄",
|
||||
"zinc": "锌色灰",
|
||||
"neutral": "中性色",
|
||||
"slate": "石板灰",
|
||||
"gray": "中灰色",
|
||||
"custom": "自定义"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
"title": "顶栏",
|
||||
"modeStatic": "静止",
|
||||
"modeFixed": "固定",
|
||||
"modeAuto": "自动隐藏和显示",
|
||||
"modeAutoScroll": "滚动隐藏和显示",
|
||||
"visible": "显示顶栏"
|
||||
},
|
||||
"footer": {
|
||||
"title": "底栏",
|
||||
"visible": "显示底栏",
|
||||
"fixed": "固定在底部"
|
||||
},
|
||||
"copyright": {
|
||||
"title": "版权",
|
||||
"enable": "启用版权",
|
||||
"companyName": "公司名",
|
||||
"companySiteLink": "公司主页",
|
||||
"date": "日期",
|
||||
"icp": "ICP 备案号",
|
||||
"icpLink": "ICP 网站链接"
|
||||
},
|
||||
"shortcutKeys": {
|
||||
"title": "快捷键",
|
||||
"global": "全局",
|
||||
"search": "全局搜索",
|
||||
"logout": "退出登录",
|
||||
"preferences": "偏好设置"
|
||||
},
|
||||
"widget": {
|
||||
"title": "小部件",
|
||||
"globalSearch": "启用全局搜索",
|
||||
"fullscreen": "启用全屏",
|
||||
"themeToggle": "启用主题切换",
|
||||
"languageToggle": "启用语言切换",
|
||||
"notification": "启用通知",
|
||||
"sidebarToggle": "启用侧边栏切换",
|
||||
"aiAssistant": "启用 AI 助手",
|
||||
"lockScreen": "启用锁屏"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { SupportedLanguagesType } from '@vben-core/typings';
|
||||
|
||||
type ImportLocaleFn = () => Promise<{ default: Record<string, string> }>;
|
||||
|
||||
type LoadMessageFn = (
|
||||
lang: SupportedLanguagesType,
|
||||
) => Promise<Record<string, string>>;
|
||||
|
||||
interface LocaleSetupOptions {
|
||||
/**
|
||||
* Default language
|
||||
* @default zh-CN
|
||||
*/
|
||||
defaultLocale?: SupportedLanguagesType;
|
||||
/**
|
||||
* Load message function
|
||||
* @param lang
|
||||
* @returns
|
||||
*/
|
||||
loadMessages?: LoadMessageFn;
|
||||
/**
|
||||
* Whether to warn when the key is not found
|
||||
*/
|
||||
missingWarn?: boolean;
|
||||
}
|
||||
|
||||
export type {
|
||||
ImportLocaleFn,
|
||||
LoadMessageFn,
|
||||
LocaleSetupOptions,
|
||||
SupportedLanguagesType,
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -6,13 +6,12 @@
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/@vben-core/forward/preferences"
|
||||
"directory": "packages/@core/preferences"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm unbuild --stub"
|
||||
"build": "pnpm unbuild"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { DeepPartial } from '@vben-core/typings';
|
||||
|
||||
import type { Preferences } from './types';
|
||||
|
||||
import { preferencesManager } from './preferences';
|
||||
|
||||
// 偏好设置(带有层级关系)
|
||||
const preferences: Preferences = preferencesManager.getPreferences();
|
||||
const preferences: Preferences =
|
||||
preferencesManager.getPreferences.apply(preferencesManager);
|
||||
|
||||
// 更新偏好设置
|
||||
const updatePreferences =
|
||||
@@ -18,13 +17,13 @@ const resetPreferences =
|
||||
const clearPreferencesCache =
|
||||
preferencesManager.clearCache.bind(preferencesManager);
|
||||
|
||||
function defineOverridesPreferences(preferences: DeepPartial<Preferences>) {
|
||||
return preferences;
|
||||
}
|
||||
// 初始化偏好设置
|
||||
const initPreferences =
|
||||
preferencesManager.initPreferences.bind(preferencesManager);
|
||||
|
||||
export {
|
||||
clearPreferencesCache,
|
||||
defineOverridesPreferences,
|
||||
initPreferences,
|
||||
preferences,
|
||||
preferencesManager,
|
||||
resetPreferences,
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { defaultPreferences } from './config';
|
||||
import { PreferenceManager, isDarkTheme } from './preferences';
|
||||
import { isDarkTheme, PreferenceManager } from './preferences';
|
||||
|
||||
describe('preferences', () => {
|
||||
let preferenceManager: PreferenceManager;
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { DeepPartial } from '@vben-core/typings';
|
||||
|
||||
import type { Preferences } from './types';
|
||||
import type { InitialOptions, Preferences } from './types';
|
||||
|
||||
import { markRaw, reactive, readonly, watch } from 'vue';
|
||||
|
||||
import { StorageManager, isMacOs, merge } from '@vben-core/toolkit';
|
||||
import { isMacOs, merge, StorageManager } from '@vben-core/toolkit';
|
||||
|
||||
import {
|
||||
breakpointsTailwind,
|
||||
@@ -19,11 +19,6 @@ 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') {
|
||||
@@ -33,7 +28,7 @@ function isDarkTheme(theme: string) {
|
||||
}
|
||||
|
||||
class PreferenceManager {
|
||||
private cache: StorageManager | null = null;
|
||||
private cache: null | StorageManager = null;
|
||||
// private flattenedState: Flatten<Preferences>;
|
||||
private initialPreferences: Preferences = defaultPreferences;
|
||||
private isInitialized: boolean = false;
|
||||
@@ -171,7 +166,7 @@ class PreferenceManager {
|
||||
* overrides 要覆盖的偏好设置
|
||||
* namespace 命名空间
|
||||
*/
|
||||
public async initPreferences({ namespace, overrides }: initialOptions) {
|
||||
public async initPreferences({ namespace, overrides }: InitialOptions) {
|
||||
// 是否初始化过
|
||||
if (this.isInitialized) {
|
||||
return;
|
||||
@@ -237,4 +232,4 @@ class PreferenceManager {
|
||||
}
|
||||
|
||||
const preferencesManager = new PreferenceManager();
|
||||
export { PreferenceManager, isDarkTheme, preferencesManager };
|
||||
export { isDarkTheme, PreferenceManager, preferencesManager };
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
BreadcrumbStyleType,
|
||||
BuiltinThemeType,
|
||||
ContentCompactType,
|
||||
DeepPartial,
|
||||
LayoutHeaderModeType,
|
||||
LayoutType,
|
||||
LoginExpiredModeType,
|
||||
@@ -232,11 +233,16 @@ interface Preferences {
|
||||
|
||||
type PreferencesKeys = keyof Preferences;
|
||||
|
||||
interface InitialOptions {
|
||||
namespace: string;
|
||||
overrides?: DeepPartial<Preferences>;
|
||||
}
|
||||
export type {
|
||||
AppPreferences,
|
||||
BreadcrumbPreferences,
|
||||
FooterPreferences,
|
||||
HeaderPreferences,
|
||||
InitialOptions,
|
||||
LogoPreferences,
|
||||
NavigationPreferences,
|
||||
Preferences,
|
||||
@@ -11,8 +11,7 @@
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm unbuild --stub"
|
||||
"build": "pnpm unbuild"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm unbuild --stub"
|
||||
"build": "pnpm unbuild"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
@@ -36,7 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify/vue": "^4.1.2",
|
||||
"lucide-vue-next": "^0.411.0",
|
||||
"lucide-vue-next": "^0.414.0",
|
||||
"vue": "^3.4.33"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm unbuild --stub"
|
||||
"build": "pnpm unbuild"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -67,7 +67,7 @@ class StorageManager {
|
||||
* @param defaultValue 当项不存在或已过期时返回的默认值
|
||||
* @returns 值,如果项已过期或解析错误则返回默认值
|
||||
*/
|
||||
getItem<T>(key: string, defaultValue: T | null = null): T | null {
|
||||
getItem<T>(key: string, defaultValue: null | T = null): null | T {
|
||||
const fullKey = this.getFullKey(key);
|
||||
const itemStr = this.storage.getItem(fullKey);
|
||||
if (!itemStr) {
|
||||
|
||||
@@ -7,7 +7,7 @@ interface StorageValue<T> {
|
||||
|
||||
interface IStorageCache {
|
||||
clear(): void;
|
||||
getItem<T>(key: string): T | null;
|
||||
getItem<T>(key: string): null | T;
|
||||
key(index: number): null | string;
|
||||
length(): number;
|
||||
removeItem(key: string): void;
|
||||
|
||||
@@ -41,4 +41,4 @@ function isValidColor(color?: string) {
|
||||
return new TinyColor(color).isValid;
|
||||
}
|
||||
|
||||
export { TinyColor, convertToHsl, convertToHslCssVar, isValidColor };
|
||||
export { convertToHsl, convertToHslCssVar, isValidColor, TinyColor };
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm unbuild",
|
||||
"stub": "pnpm build --stub"
|
||||
"build": "pnpm unbuild"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -39,7 +39,7 @@ type AnyFunction<T extends any[] = any[], R = void> =
|
||||
/**
|
||||
* T | null 包装
|
||||
*/
|
||||
type Nullable<T> = T | null;
|
||||
type Nullable<T> = null | T;
|
||||
|
||||
/**
|
||||
* T | Not null 包装
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RouteRecordRaw, Router } from 'vue-router';
|
||||
import type { Router, RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import type { Component } from 'vue';
|
||||
|
||||
|
||||
@@ -103,11 +103,6 @@ interface VbenLayoutProps {
|
||||
* @default sidebar-nav
|
||||
*/
|
||||
layout?: LayoutType;
|
||||
/**
|
||||
* 侧边菜单折叠宽度
|
||||
* @default 48
|
||||
*/
|
||||
sideCollapseWidth?: number;
|
||||
/**
|
||||
* 侧边菜单折叠状态
|
||||
* @default false
|
||||
@@ -153,6 +148,11 @@ interface VbenLayoutProps {
|
||||
* @default 210
|
||||
*/
|
||||
sidebarWidth?: number;
|
||||
/**
|
||||
* 侧边菜单折叠宽度
|
||||
* @default 48
|
||||
*/
|
||||
sideCollapseWidth?: number;
|
||||
/**
|
||||
* tab是否可见
|
||||
* @default true
|
||||
|
||||
@@ -38,7 +38,6 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
headerVisible: true,
|
||||
isMobile: false,
|
||||
layout: 'sidebar-nav',
|
||||
sideCollapseWidth: 60,
|
||||
sidebarCollapseShowTitle: false,
|
||||
sidebarExtraCollapsedWidth: 60,
|
||||
sidebarHidden: false,
|
||||
@@ -46,6 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
sidebarSemiDark: true,
|
||||
sidebarTheme: 'dark',
|
||||
sidebarWidth: 180,
|
||||
sideCollapseWidth: 60,
|
||||
tabbarEnable: true,
|
||||
tabbarHeight: 36,
|
||||
zIndex: 200,
|
||||
@@ -130,7 +130,7 @@ const headerWrapperHeight = computed(() => {
|
||||
});
|
||||
|
||||
const getSideCollapseWidth = computed(() => {
|
||||
const { sideCollapseWidth, sidebarCollapseShowTitle, sidebarMixedWidth } =
|
||||
const { sidebarCollapseShowTitle, sidebarMixedWidth, sideCollapseWidth } =
|
||||
props;
|
||||
|
||||
return sidebarCollapseShowTitle || isSidebarMixedNav.value
|
||||
|
||||
@@ -7,13 +7,13 @@ import type {
|
||||
} from '../interface';
|
||||
|
||||
import {
|
||||
type VNodeArrayChildren,
|
||||
computed,
|
||||
nextTick,
|
||||
reactive,
|
||||
ref,
|
||||
toRef,
|
||||
useSlots,
|
||||
type VNodeArrayChildren,
|
||||
watch,
|
||||
watchEffect,
|
||||
} from 'vue';
|
||||
@@ -22,7 +22,7 @@ import { useNamespace } from '@vben-core/hooks';
|
||||
import { Ellipsis } from '@vben-core/icons';
|
||||
import { isHttpUrl } from '@vben-core/toolkit';
|
||||
|
||||
import { UseResizeObserverReturn, useResizeObserver } from '@vueuse/core';
|
||||
import { useResizeObserver, UseResizeObserverReturn } from '@vueuse/core';
|
||||
|
||||
import {
|
||||
createMenuContext,
|
||||
@@ -121,8 +121,8 @@ createMenuContext(
|
||||
handleMenuItemClick,
|
||||
handleSubMenuClick,
|
||||
isMenuPopup,
|
||||
openMenu,
|
||||
openedMenus,
|
||||
openMenu,
|
||||
props,
|
||||
removeMenuItem,
|
||||
removeSubMenu,
|
||||
@@ -176,7 +176,7 @@ function calcSliceIndex() {
|
||||
}
|
||||
|
||||
function debounce(fn: () => void, wait = 33.34) {
|
||||
let timer: ReturnType<typeof setTimeout> | null;
|
||||
let timer: null | ReturnType<typeof setTimeout>;
|
||||
return () => {
|
||||
timer && clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
|
||||
@@ -44,7 +44,7 @@ const mouseInChild = ref(false);
|
||||
|
||||
const items = ref<MenuProvider['items']>({});
|
||||
const subMenus = ref<MenuProvider['subMenus']>({});
|
||||
const timer = ref<ReturnType<typeof setTimeout> | null>(null);
|
||||
const timer = ref<null | ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
createSubMenuContext({
|
||||
addSubMenu,
|
||||
|
||||
@@ -105,8 +105,8 @@ interface MenuProvider {
|
||||
isMenuPopup: boolean;
|
||||
items: Record<string, MenuItemRegistered>;
|
||||
|
||||
openMenu: (path: string, parentLinks: string[]) => void;
|
||||
openedMenus: string[];
|
||||
openMenu: (path: string, parentLinks: string[]) => void;
|
||||
props: MenuProps;
|
||||
removeMenuItem: (item: MenuItemRegistered) => void;
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"@vben-core/typings": "workspace:*",
|
||||
"@vueuse/core": "^10.11.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"lucide-vue-next": "^0.411.0",
|
||||
"lucide-vue-next": "^0.414.0",
|
||||
"radix-vue": "^1.9.2",
|
||||
"vue": "^3.4.33"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed, useSlots } from 'vue';
|
||||
import { computed, type HTMLAttributes, useSlots } from 'vue';
|
||||
|
||||
import { VbenTooltip } from '@vben-core/shadcn-ui/components/tooltip';
|
||||
import { ButtonVariants } from '@vben-core/shadcn-ui/components/ui/button';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { HoverCardRootEmits, HoverCardRootProps } from 'radix-vue';
|
||||
|
||||
import { HTMLAttributes, computed } from 'vue';
|
||||
import { computed, HTMLAttributes } from 'vue';
|
||||
|
||||
import {
|
||||
HoverCard,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
PopoverRootProps,
|
||||
} from 'radix-vue';
|
||||
|
||||
import { HTMLAttributes, computed } from 'vue';
|
||||
import { computed, HTMLAttributes } from 'vue';
|
||||
|
||||
import {
|
||||
PopoverContent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { buttonVariants } from '@vben-core/shadcn-ui/components/ui/button';
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { buttonVariants } from '@vben-core/shadcn-ui/components/ui/button';
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from '@vben-core/toolkit';
|
||||
|
||||
import { AvatarRoot } from 'radix-vue';
|
||||
|
||||
import { type AvatarVariants, avatarVariant } from './avatar';
|
||||
import { avatarVariant, type AvatarVariants } from './avatar';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const avatarVariant = cva(
|
||||
'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from './avatar';
|
||||
export { default as Avatar } from './Avatar.vue';
|
||||
export { default as AvatarFallback } from './AvatarFallback.vue';
|
||||
export { default as AvatarImage } from './AvatarImage.vue';
|
||||
export * from './avatar';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border border-border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { default as Badge } from './Badge.vue';
|
||||
|
||||
export * from './badge';
|
||||
|
||||
export { default as Badge } from './Badge.vue';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { default as Button } from './Button.vue';
|
||||
|
||||
export * from './button';
|
||||
|
||||
export { default as Button } from './Button.vue';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from 'radix-vue';
|
||||
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { type HTMLAttributes, computed } from 'vue';
|
||||
import { computed, type HTMLAttributes } from 'vue';
|
||||
|
||||
import { cn } from '@vben-core/toolkit';
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user