Merge remote-tracking branch 'vben/main' into vben-dev
# Conflicts: # .gitignore # .husky/pre-commit # .vscode/extensions.json # .vscode/settings.json # README.ja-JP.md # README.md # README.zh-CN.md # apps/backend-mock/api/auth/codes.ts # apps/backend-mock/api/auth/login.post.ts # apps/backend-mock/api/auth/logout.post.ts # apps/backend-mock/api/auth/refresh.post.ts # apps/backend-mock/api/menu/all.ts # apps/backend-mock/api/status.ts # apps/backend-mock/api/table/list.ts # apps/backend-mock/api/test.get.ts # apps/backend-mock/api/test.post.ts # apps/backend-mock/api/user/info.ts # apps/backend-mock/middleware/1.api.ts # apps/backend-mock/nitro.config.ts # apps/backend-mock/package.json # apps/backend-mock/routes/[...].ts # apps/backend-mock/tsconfig.json # apps/backend-mock/utils/cookie-utils.ts # apps/backend-mock/utils/jwt-utils.ts # apps/backend-mock/utils/mock-data.ts # apps/backend-mock/utils/response.ts # apps/web-antd/.env # apps/web-antd/src/adapter/component/index.ts # apps/web-antd/src/adapter/form.ts # apps/web-antd/src/api/request.ts # apps/web-antd/src/layouts/auth.vue # apps/web-antd/src/layouts/basic.vue # apps/web-antd/src/preferences.ts # apps/web-antd/src/router/guard.ts # apps/web-antd/src/router/routes/modules/demos.ts # apps/web-antd/src/store/auth.ts # apps/web-antd/src/views/demos/antd/index.vue # apps/web-antdv-next/vite.config.ts # apps/web-ele/.env # apps/web-ele/index.html # apps/web-ele/package.json # apps/web-ele/src/adapter/component/index.ts # apps/web-ele/src/adapter/form.ts # apps/web-ele/src/adapter/vxe-table.ts # apps/web-ele/src/api/request.ts # apps/web-ele/src/bootstrap.ts # apps/web-ele/src/layouts/auth.vue # apps/web-ele/src/layouts/basic.vue # apps/web-ele/src/locales/index.ts # apps/web-ele/src/locales/langs/en-US/demos.json # apps/web-ele/src/locales/langs/en-US/page.json # apps/web-ele/src/locales/langs/zh-CN/demos.json # apps/web-ele/src/locales/langs/zh-CN/page.json # apps/web-ele/src/preferences.ts # apps/web-ele/src/router/guard.ts # apps/web-ele/src/router/routes/core.ts # apps/web-ele/src/router/routes/modules/dashboard.ts # apps/web-ele/src/router/routes/modules/demos.ts # apps/web-ele/src/router/routes/modules/vben.ts # apps/web-ele/src/store/auth.ts # apps/web-ele/src/views/_core/authentication/code-login.vue # apps/web-ele/src/views/_core/authentication/forget-password.vue # apps/web-ele/src/views/_core/authentication/register.vue # apps/web-ele/src/views/dashboard/analytics/analytics-trends.vue # apps/web-ele/src/views/dashboard/analytics/analytics-visits-data.vue # apps/web-ele/src/views/dashboard/analytics/analytics-visits-sales.vue # apps/web-ele/src/views/dashboard/analytics/analytics-visits-source.vue # apps/web-ele/src/views/dashboard/analytics/analytics-visits.vue # apps/web-ele/src/views/dashboard/analytics/index.vue # apps/web-ele/src/views/demos/element/index.vue # apps/web-ele/src/views/demos/form/basic.vue # apps/web-ele/tsconfig.json # apps/web-ele/tsconfig.node.json # apps/web-naive/.env # apps/web-naive/index.html # apps/web-naive/package.json # apps/web-naive/src/adapter/component/index.ts # apps/web-naive/src/adapter/form.ts # apps/web-naive/src/adapter/vxe-table.ts # apps/web-naive/src/api/request.ts # apps/web-naive/src/bootstrap.ts # apps/web-naive/src/layouts/auth.vue # apps/web-naive/src/layouts/basic.vue # apps/web-naive/src/locales/index.ts # apps/web-naive/src/locales/langs/en-US/demos.json # apps/web-naive/src/locales/langs/en-US/page.json # apps/web-naive/src/locales/langs/zh-CN/demos.json # apps/web-naive/src/locales/langs/zh-CN/page.json # apps/web-naive/src/preferences.ts # apps/web-naive/src/router/guard.ts # apps/web-naive/src/router/routes/core.ts # apps/web-naive/src/router/routes/modules/dashboard.ts # apps/web-naive/src/router/routes/modules/demos.ts # apps/web-naive/src/router/routes/modules/vben.ts # apps/web-naive/src/store/auth.ts # apps/web-naive/src/views/_core/authentication/code-login.vue # apps/web-naive/src/views/_core/authentication/forget-password.vue # apps/web-naive/src/views/_core/authentication/register.vue # apps/web-naive/src/views/dashboard/analytics/analytics-trends.vue # apps/web-naive/src/views/dashboard/analytics/analytics-visits-data.vue # apps/web-naive/src/views/dashboard/analytics/analytics-visits-sales.vue # apps/web-naive/src/views/dashboard/analytics/analytics-visits-source.vue # apps/web-naive/src/views/dashboard/analytics/analytics-visits.vue # apps/web-naive/src/views/dashboard/analytics/index.vue # apps/web-naive/src/views/demos/form/basic.vue # apps/web-naive/src/views/demos/naive/index.vue # apps/web-naive/tsconfig.json # apps/web-naive/tsconfig.node.json # apps/web-naive/vite.config.ts # docs/.vitepress/components/demo-preview.vue # docs/.vitepress/components/preview-group.vue # docs/.vitepress/config/en.mts # docs/.vitepress/config/index.mts # docs/.vitepress/config/plugins/demo-preview.ts # docs/.vitepress/config/shared.mts # docs/.vitepress/config/zh.mts # docs/.vitepress/theme/components/site-layout.vue # docs/.vitepress/theme/components/vben-contributors.vue # docs/.vitepress/theme/styles/variables.css # docs/package.json # docs/src/_env/adapter/component.ts # docs/src/_env/adapter/form.ts # docs/src/_env/adapter/vxe-table.ts # docs/src/commercial/community.md # docs/src/components/common-ui/vben-count-to-animator.md # docs/src/components/common-ui/vben-drawer.md # docs/src/components/common-ui/vben-form.md # docs/src/components/common-ui/vben-modal.md # docs/src/components/common-ui/vben-vxe-table.md # docs/src/components/layout-ui/page.md # docs/src/demos/vben-drawer/auto-height/drawer.vue # docs/src/demos/vben-drawer/basic/index.vue # docs/src/demos/vben-drawer/dynamic/index.vue # docs/src/demos/vben-drawer/shared-data/drawer.vue # docs/src/demos/vben-drawer/shared-data/index.vue # docs/src/demos/vben-form/api/index.vue # docs/src/demos/vben-form/basic/index.vue # docs/src/demos/vben-form/custom/index.vue # docs/src/demos/vben-form/dynamic/index.vue # docs/src/demos/vben-form/query/index.vue # docs/src/demos/vben-form/rules/index.vue # docs/src/demos/vben-modal/auto-height/modal.vue # docs/src/demos/vben-modal/basic/index.vue # docs/src/demos/vben-modal/dynamic/index.vue # docs/src/demos/vben-modal/shared-data/index.vue # docs/src/demos/vben-modal/shared-data/modal.vue # docs/src/demos/vben-vxe-table/basic/index.vue # docs/src/demos/vben-vxe-table/custom-cell/index.vue # docs/src/demos/vben-vxe-table/edit-row/index.vue # docs/src/demos/vben-vxe-table/fixed/index.vue # docs/src/demos/vben-vxe-table/form/index.vue # docs/src/demos/vben-vxe-table/remote/index.vue # docs/src/demos/vben-vxe-table/tree/index.vue # docs/src/demos/vben-vxe-table/virtual/index.vue # docs/src/en/guide/essentials/development.md # docs/src/en/guide/essentials/external-module.md # docs/src/en/guide/essentials/route.md # docs/src/en/guide/essentials/server.md # docs/src/en/guide/essentials/settings.md # docs/src/en/guide/in-depth/access.md # docs/src/en/guide/in-depth/loading.md # docs/src/en/guide/in-depth/locale.md # docs/src/en/guide/in-depth/theme.md # docs/src/en/guide/introduction/quick-start.md # docs/src/en/guide/introduction/thin.md # docs/src/en/guide/introduction/vben.md # docs/src/en/guide/other/faq.md # docs/src/en/guide/project/dir.md # docs/src/en/guide/project/standard.md # docs/src/en/guide/project/tailwindcss.md # docs/src/en/index.md # docs/src/friend-links/index.md # docs/src/guide/essentials/build.md # docs/src/guide/essentials/development.md # docs/src/guide/essentials/external-module.md # docs/src/guide/essentials/route.md # docs/src/guide/essentials/server.md # docs/src/guide/essentials/settings.md # docs/src/guide/in-depth/access.md # docs/src/guide/in-depth/check-updates.md # docs/src/guide/in-depth/loading.md # docs/src/guide/in-depth/locale.md # docs/src/guide/in-depth/theme.md # docs/src/guide/introduction/quick-start.md # docs/src/guide/introduction/thin.md # docs/src/guide/introduction/vben.md # docs/src/guide/introduction/why.md # docs/src/guide/other/faq.md # docs/src/guide/project/dir.md # docs/src/guide/project/standard.md # docs/src/guide/project/tailwindcss.md # docs/src/index.md # docs/tsconfig.json # package.json # packages/@core/ui-kit/shadcn-ui/src/components/logo/logo.vue # packages/constants/src/core.ts # packages/effects/common-ui/src/components/index.ts # packages/effects/common-ui/src/ui/dashboard/analysis/analysis-overview.vue # packages/effects/common-ui/src/ui/dashboard/workbench/workbench-header.vue # packages/effects/common-ui/src/ui/dashboard/workbench/workbench-project.vue # packages/effects/layouts/src/authentication/authentication.vue # packages/effects/layouts/src/basic/layout.vue # packages/effects/layouts/src/widgets/notification/notification.vue # packages/effects/layouts/src/widgets/notification/types.ts # packages/icons/src/iconify/index.ts # packages/icons/src/svg/index.ts # packages/stores/src/modules/user.ts # playground/src/adapter/component/index.ts # playground/src/store/auth.ts # pnpm-lock.yaml # vben-admin.code-workspace
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/access",
|
||||
"version": "5.5.1",
|
||||
"version": "5.7.0",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
||||
240
packages/effects/access/src/__tests__/accessible.test.ts
Normal file
240
packages/effects/access/src/__tests__/accessible.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { RouteRecordRaw } from '@vben/types';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { generateAccessible } from '../accessible';
|
||||
|
||||
// generateAccessible 会操作传入的 router 实例。这里用最小 stub 覆盖它实际调用的方法:
|
||||
// - getRoutes(): 返回 [] -> 不存在根路由 '/', 走 router.addRoute 分支
|
||||
// - addRoute/removeRoute: 空实现
|
||||
// 我们只断言返回的 accessibleRoutes 上自动生成的 redirect。
|
||||
function createRouterStub() {
|
||||
return {
|
||||
addRoute: () => {},
|
||||
getRoutes: () => [],
|
||||
removeRoute: () => {},
|
||||
} as any;
|
||||
}
|
||||
|
||||
async function generate(routes: RouteRecordRaw[]) {
|
||||
const { accessibleRoutes } = await generateAccessible('frontend', {
|
||||
router: createRouterStub(),
|
||||
routes,
|
||||
});
|
||||
return accessibleRoutes;
|
||||
}
|
||||
|
||||
function findByName(
|
||||
routes: RouteRecordRaw[],
|
||||
name: string,
|
||||
): RouteRecordRaw | undefined {
|
||||
for (const route of routes) {
|
||||
if (route.name === name) {
|
||||
return route;
|
||||
}
|
||||
if (route.children) {
|
||||
const found = findByName(route.children as RouteRecordRaw[], name);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe('generateAccessible - redirect normalization', () => {
|
||||
it('不为动态参数(:id)首子路由的父级生成 redirect', async () => {
|
||||
const routes = [
|
||||
{
|
||||
name: 'DyeSets',
|
||||
path: 'dye-sets',
|
||||
children: [
|
||||
{
|
||||
name: 'DyeSetDetail',
|
||||
path: ':id',
|
||||
meta: { hideInMenu: true, title: 'detail' },
|
||||
},
|
||||
],
|
||||
meta: { title: 'dye-sets' },
|
||||
},
|
||||
] as unknown as RouteRecordRaw[];
|
||||
|
||||
const result = await generate(routes);
|
||||
expect(findByName(result, 'DyeSets')?.redirect).toBeUndefined();
|
||||
});
|
||||
|
||||
it('父级为对象 redirect({name}) 且含 :id 子路由时不抛异常且不生成 redirect', async () => {
|
||||
const routes = [
|
||||
{
|
||||
name: 'Production',
|
||||
path: '/production',
|
||||
redirect: { name: 'ProductionTasks' },
|
||||
children: [
|
||||
{
|
||||
name: 'ProductionTasks',
|
||||
path: 'production-tasks',
|
||||
children: [
|
||||
{
|
||||
name: 'ProductionTaskDetail',
|
||||
path: ':id',
|
||||
meta: { hideInMenu: true, title: 'detail' },
|
||||
},
|
||||
{
|
||||
name: 'ProductionTaskMatch',
|
||||
path: ':id/match',
|
||||
meta: { hideInMenu: true, title: 'match' },
|
||||
},
|
||||
],
|
||||
meta: { title: 'tasks' },
|
||||
},
|
||||
],
|
||||
meta: { title: 'production' },
|
||||
},
|
||||
] as unknown as RouteRecordRaw[];
|
||||
|
||||
const result = await generate(routes);
|
||||
// 顶级对象 redirect 保持不变
|
||||
expect(findByName(result, 'Production')?.redirect).toEqual({
|
||||
name: 'ProductionTasks',
|
||||
});
|
||||
// :id 首子路由的父级不生成 redirect
|
||||
expect(findByName(result, 'ProductionTasks')?.redirect).toBeUndefined();
|
||||
});
|
||||
|
||||
it('父级为对象 redirect 时,普通相对首子路由回退用 parent.path 拼接', async () => {
|
||||
const routes = [
|
||||
{
|
||||
name: 'Setting',
|
||||
path: '/setting',
|
||||
redirect: { name: 'SettingService' },
|
||||
children: [
|
||||
{
|
||||
name: 'SettingGroup',
|
||||
path: 'group',
|
||||
children: [
|
||||
{
|
||||
name: 'SettingService',
|
||||
path: 'service',
|
||||
meta: { title: 'service' },
|
||||
},
|
||||
],
|
||||
meta: { title: 'group' },
|
||||
},
|
||||
],
|
||||
meta: { title: 'setting' },
|
||||
},
|
||||
] as unknown as RouteRecordRaw[];
|
||||
|
||||
const result = await generate(routes);
|
||||
expect(findByName(result, 'SettingGroup')?.redirect).toBe(
|
||||
'/setting/group/service',
|
||||
);
|
||||
});
|
||||
|
||||
it('深层嵌套(上游风格)相对路径逐级生成正确的累计绝对 redirect', async () => {
|
||||
const routes = [
|
||||
{
|
||||
name: 'Demos',
|
||||
path: '/demos',
|
||||
children: [
|
||||
{
|
||||
name: 'NestedDemos',
|
||||
path: 'nested',
|
||||
children: [
|
||||
{
|
||||
name: 'Menu1Demo',
|
||||
path: 'menu1',
|
||||
meta: { title: 'menu1' },
|
||||
},
|
||||
{
|
||||
name: 'Menu2Demo',
|
||||
path: 'menu2',
|
||||
children: [
|
||||
{
|
||||
name: 'Menu21Demo',
|
||||
path: 'menu2-1',
|
||||
meta: { title: 'menu2-1' },
|
||||
},
|
||||
],
|
||||
meta: { title: 'menu2' },
|
||||
},
|
||||
],
|
||||
meta: { title: 'nested' },
|
||||
},
|
||||
],
|
||||
meta: { title: 'demos' },
|
||||
},
|
||||
] as unknown as RouteRecordRaw[];
|
||||
|
||||
const result = await generate(routes);
|
||||
// Demos 重定向到第一级子路由,子路由继续级联到叶子
|
||||
expect(findByName(result, 'Demos')?.redirect).toBe('/demos/nested');
|
||||
expect(findByName(result, 'NestedDemos')?.redirect).toBe(
|
||||
'/demos/nested/menu1',
|
||||
);
|
||||
expect(findByName(result, 'Menu2Demo')?.redirect).toBe(
|
||||
'/demos/nested/menu2/menu2-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('首子路由为绝对路径(/foo)时不生成 redirect', async () => {
|
||||
const routes = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
path: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
name: 'Analytics',
|
||||
path: '/analytics',
|
||||
meta: { title: 'analytics' },
|
||||
},
|
||||
],
|
||||
meta: { title: 'dashboard' },
|
||||
},
|
||||
] as unknown as RouteRecordRaw[];
|
||||
|
||||
const result = await generate(routes);
|
||||
expect(findByName(result, 'Dashboard')?.redirect).toBeUndefined();
|
||||
});
|
||||
|
||||
it('首子路由为空 path 时不生成 redirect', async () => {
|
||||
const routes = [
|
||||
{
|
||||
name: 'HideChildrenParent',
|
||||
path: 'hide-menu-children',
|
||||
children: [
|
||||
{
|
||||
name: 'HideChildren',
|
||||
path: '',
|
||||
meta: { title: 'hide' },
|
||||
},
|
||||
],
|
||||
meta: { title: 'parent' },
|
||||
},
|
||||
] as unknown as RouteRecordRaw[];
|
||||
|
||||
const result = await generate(routes);
|
||||
expect(findByName(result, 'HideChildrenParent')?.redirect).toBeUndefined();
|
||||
});
|
||||
|
||||
it('已存在的 redirect 保持不变', async () => {
|
||||
const routes = [
|
||||
{
|
||||
name: 'Custom',
|
||||
path: '/custom',
|
||||
redirect: '/custom/keep',
|
||||
children: [
|
||||
{
|
||||
name: 'CustomChild',
|
||||
path: 'child',
|
||||
meta: { title: 'child' },
|
||||
},
|
||||
],
|
||||
meta: { title: 'custom' },
|
||||
},
|
||||
] as unknown as RouteRecordRaw[];
|
||||
|
||||
const result = await generate(routes);
|
||||
expect(findByName(result, 'Custom')?.redirect).toBe('/custom/keep');
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Component, DefineComponent } from 'vue';
|
||||
|
||||
import type {
|
||||
AccessModeType,
|
||||
GenerateMenuAndRoutesOptions,
|
||||
RouteRecordRaw,
|
||||
} from '@vben/types';
|
||||
|
||||
import { defineComponent, h } from 'vue';
|
||||
|
||||
import {
|
||||
cloneDeep,
|
||||
generateMenus,
|
||||
generateRoutesByBackend,
|
||||
generateRoutesByFrontend,
|
||||
isFunction,
|
||||
isString,
|
||||
mapTree,
|
||||
} from '@vben/utils';
|
||||
|
||||
@@ -19,16 +25,49 @@ async function generateAccessible(
|
||||
const { router } = options;
|
||||
|
||||
options.routes = cloneDeep(options.routes);
|
||||
|
||||
// 生成路由
|
||||
const accessibleRoutes = await generateRoutes(mode, options);
|
||||
|
||||
const root = router.getRoutes().find((item) => item.path === '/');
|
||||
|
||||
// 获取已有的路由名称列表
|
||||
const names = root?.children?.map((item) => item.name) ?? [];
|
||||
|
||||
// 动态添加到router实例内
|
||||
accessibleRoutes.forEach((route) => {
|
||||
router.addRoute(route);
|
||||
if (root && !route.meta?.noBasicLayout) {
|
||||
// 为了兼容之前的版本用法,如果包含子路由,则将component移除,以免出现多层BasicLayout
|
||||
// 如果你的项目已经跟进了本次修改,移除了所有自定义菜单首级的BasicLayout,可以将这段if代码删除
|
||||
if (route.children && route.children.length > 0) {
|
||||
delete route.component;
|
||||
}
|
||||
// 根据router name判断,如果路由已经存在,则不再添加
|
||||
if (names?.includes(route.name)) {
|
||||
// 找到已存在的路由索引并更新,不更新会造成切换用户时,一级目录未更新,homePath 在二级目录导致的404问题
|
||||
const index = root.children?.findIndex(
|
||||
(item) => item.name === route.name,
|
||||
);
|
||||
if (index !== undefined && index !== -1 && root.children) {
|
||||
root.children[index] = route;
|
||||
}
|
||||
} else {
|
||||
root.children?.push(route);
|
||||
}
|
||||
} else {
|
||||
router.addRoute(route);
|
||||
}
|
||||
});
|
||||
|
||||
if (root) {
|
||||
if (root.name) {
|
||||
router.removeRoute(root.name);
|
||||
}
|
||||
router.addRoute(root);
|
||||
}
|
||||
|
||||
// 生成菜单
|
||||
const accessibleMenus = await generateMenus(accessibleRoutes, options.router);
|
||||
const accessibleMenus = generateMenus(accessibleRoutes, options.router);
|
||||
|
||||
return { accessibleMenus, accessibleRoutes };
|
||||
}
|
||||
@@ -58,29 +97,144 @@ async function generateRoutes(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'mixed': {
|
||||
const [frontend_resultRoutes, backend_resultRoutes] = await Promise.all([
|
||||
generateRoutesByFrontend(routes, roles || [], forbiddenComponent),
|
||||
generateRoutesByBackend(options),
|
||||
]);
|
||||
resultRoutes = mergeRoutesByName(
|
||||
backend_resultRoutes,
|
||||
frontend_resultRoutes,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整路由树,做以下处理:
|
||||
* 1. 对未添加redirect的路由添加redirect
|
||||
* 2. 将懒加载的组件名称修改为当前路由的名称(如果启用了keep-alive的话)
|
||||
*/
|
||||
resultRoutes = mapTree(resultRoutes, (route) => {
|
||||
resultRoutes = mapTree(resultRoutes, (route, parent) => {
|
||||
// 重新包装component,使用与路由名称相同的name以支持keep-alive的条件缓存。
|
||||
if (
|
||||
route.meta?.keepAlive &&
|
||||
isFunction(route.component) &&
|
||||
route.name &&
|
||||
isString(route.name)
|
||||
) {
|
||||
const originalComponent = route.component as () => Promise<{
|
||||
default: Component | DefineComponent;
|
||||
}>;
|
||||
route.component = async () => {
|
||||
const component = await originalComponent();
|
||||
if (!component.default) return component;
|
||||
return defineComponent({
|
||||
name: route.name as string,
|
||||
setup(props, { attrs, slots }) {
|
||||
return () => h(component.default, { ...props, ...attrs }, slots);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// 如果有redirect或者没有子路由,则直接返回
|
||||
if (route.redirect || !route.children || route.children.length === 0) {
|
||||
return route;
|
||||
}
|
||||
const firstChild = route.children[0];
|
||||
|
||||
// 如果子路由不是以/开头,则直接返回,这种情况需要计算全部父级的path才能得出正确的path,这里不做处理
|
||||
if (!firstChild?.path || !firstChild.path.startsWith('/')) {
|
||||
if (!firstChild?.path || firstChild.path.startsWith('/')) {
|
||||
return route;
|
||||
}
|
||||
|
||||
route.redirect = firstChild.path;
|
||||
// fork 定制:如果第一个子路由是动态路由(如 :id),说明当前路由本身是一个
|
||||
// “列表+详情”页面(渲染自身组件),不应自动重定向到未填充的动态参数,
|
||||
// 否则地址栏会出现字面量 ":id" 或匹配失败导致 404。
|
||||
// 详见对上游重构 commit f00a8812 的修复。
|
||||
if (firstChild.path.startsWith(':')) {
|
||||
return route;
|
||||
}
|
||||
|
||||
// 拼接子路由的重定向绝对路径。
|
||||
// - 当 parent.redirect 为字符串时,它已经是累计好的绝对路径,直接替换最后一段
|
||||
// 即可正确支持任意层级的深层嵌套(如 /demos/nested/menu2/menu2-1)。
|
||||
// - fork 定制:后端菜单可能传入对象形式的 redirect(如 { name }),无法 split,
|
||||
// 此时回退到使用 parent.path 拼接(这类 parent 为顶级路由,path 为绝对路径)。
|
||||
if (parent && parent.redirect && isString(parent.redirect)) {
|
||||
const parentSplit = parent.redirect.split('/');
|
||||
parentSplit.splice(-1, 2, route.path, firstChild.path);
|
||||
const redirectPath = parentSplit.join('/');
|
||||
route.redirect = redirectPath;
|
||||
} else if (parent && parent.redirect) {
|
||||
route.redirect = `${parent.path}/${route.path}/${firstChild.path}`;
|
||||
} else {
|
||||
route.redirect = `${route.path}/${firstChild.path}`;
|
||||
}
|
||||
|
||||
return route;
|
||||
});
|
||||
|
||||
return resultRoutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 name 合并前后端路由
|
||||
* @param baseRoutes 后端路由
|
||||
* @param extraRoutes 前端路由
|
||||
*/
|
||||
function mergeRoutesByName(
|
||||
baseRoutes: RouteRecordRaw[],
|
||||
extraRoutes: RouteRecordRaw[],
|
||||
): RouteRecordRaw[] {
|
||||
const result: RouteRecordRaw[] = [];
|
||||
const routeMap = new Map<string, RouteRecordRaw>();
|
||||
|
||||
for (const route of baseRoutes) {
|
||||
const clone = { ...route } as RouteRecordRaw;
|
||||
result.push(clone);
|
||||
if (clone.name && isString(clone.name)) {
|
||||
routeMap.set(clone.name as string, clone);
|
||||
}
|
||||
}
|
||||
|
||||
for (const route of extraRoutes) {
|
||||
if (
|
||||
route.name &&
|
||||
isString(route.name) &&
|
||||
routeMap.has(route.name as string)
|
||||
) {
|
||||
const existing = routeMap.get(route.name as string);
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
const existingChildren = existing.children ?? [];
|
||||
const routeChildren = route.children ?? [];
|
||||
|
||||
const merged = {
|
||||
...route,
|
||||
...existing, // keep backend as base
|
||||
meta: {
|
||||
...route.meta,
|
||||
...existing.meta, // backend meta wins on conflicts
|
||||
},
|
||||
} as RouteRecordRaw;
|
||||
|
||||
if (existingChildren.length > 0 || routeChildren.length > 0) {
|
||||
merged.children = mergeRoutesByName(existingChildren, routeChildren);
|
||||
}
|
||||
|
||||
Object.assign(existing, merged);
|
||||
} else {
|
||||
const clone = { ...route } as RouteRecordRaw;
|
||||
result.push(clone);
|
||||
if (clone.name && isString(clone.name)) {
|
||||
routeMap.set(clone.name as string, clone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export { generateAccessible };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/common-ui",
|
||||
"version": "5.5.1",
|
||||
"version": "5.7.0",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
@@ -17,9 +17,18 @@
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./es/tippy": {
|
||||
"types": "./src/components/tippy/index.ts",
|
||||
"default": "./src/components/tippy/index.ts"
|
||||
},
|
||||
"./es/loading": {
|
||||
"types": "./src/components/loading/index.ts",
|
||||
"default": "./src/components/loading/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/design": "workspace:*",
|
||||
"@vben-core/form-ui": "workspace:*",
|
||||
"@vben-core/popup-ui": "workspace:*",
|
||||
"@vben-core/preferences": "workspace:*",
|
||||
@@ -32,11 +41,17 @@
|
||||
"@vben/types": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"json-bigint": "catalog:",
|
||||
"qrcode": "catalog:",
|
||||
"tippy.js": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
"vue-json-pretty": "catalog:",
|
||||
"vue-router": "catalog:",
|
||||
"vue-tippy": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "catalog:"
|
||||
"@types/json-bigint": "catalog:",
|
||||
"@types/qrcode": "catalog:",
|
||||
"@vue/test-utils": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
import type { AnyPromiseFunction } from '@vben/types';
|
||||
import type {
|
||||
ApiComponentProps,
|
||||
ApiComponentOptionsItem as OptionsItem,
|
||||
} from './types';
|
||||
|
||||
import { type Component, computed, ref, unref, useAttrs, watch } from 'vue';
|
||||
import { computed, nextTick, ref, unref, useAttrs, watch } from 'vue';
|
||||
|
||||
import { LoaderCircle } from '@vben/icons';
|
||||
import { get, isEqual, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { cloneDeep, get, isEqual, isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { objectOmit } from '@vueuse/core';
|
||||
|
||||
type OptionsItem = {
|
||||
[name: string]: any;
|
||||
children?: OptionsItem[];
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
/** 组件 */
|
||||
component: Component;
|
||||
/** 是否将value从数字转为string */
|
||||
numberToString?: boolean;
|
||||
/** 获取options数据的函数 */
|
||||
api?: (arg?: any) => Promise<OptionsItem[] | Record<string, any>>;
|
||||
/** 传递给api的参数 */
|
||||
params?: Record<string, any>;
|
||||
/** 从api返回的结果中提取options数组的字段名 */
|
||||
resultField?: string;
|
||||
/** label字段名 */
|
||||
labelField?: string;
|
||||
/** children字段名,需要层级数据的组件可用 */
|
||||
childrenField?: string;
|
||||
/** value字段名 */
|
||||
valueField?: string;
|
||||
/** 组件接收options数据的属性名 */
|
||||
optionsPropName?: string;
|
||||
/** 是否立即调用api */
|
||||
immediate?: boolean;
|
||||
/** 每次`visibleEvent`事件发生时都重新请求数据 */
|
||||
alwaysLoad?: boolean;
|
||||
/** 在api请求之前的回调函数 */
|
||||
beforeFetch?: AnyPromiseFunction<any, any>;
|
||||
/** 在api请求之后的回调函数 */
|
||||
afterFetch?: AnyPromiseFunction<any, any>;
|
||||
/** 直接传入选项数据,也作为api返回空数据时的后备数据 */
|
||||
options?: OptionsItem[];
|
||||
/** 组件的插槽名称,用来显示一个"加载中"的图标 */
|
||||
loadingSlot?: string;
|
||||
/** 触发api请求的事件名 */
|
||||
visibleEvent?: string;
|
||||
/** 组件的v-model属性名,默认为modelValue。部分组件可能为value */
|
||||
modelPropName?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'ApiComponent', inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<ApiComponentProps>(), {
|
||||
labelField: 'label',
|
||||
valueField: 'value',
|
||||
labelFn: undefined,
|
||||
disabledField: 'disabled',
|
||||
childrenField: '',
|
||||
optionsPropName: 'options',
|
||||
resultField: '',
|
||||
@@ -68,9 +29,11 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
alwaysLoad: false,
|
||||
loadingSlot: '',
|
||||
beforeFetch: undefined,
|
||||
shouldFetch: undefined,
|
||||
afterFetch: undefined,
|
||||
modelPropName: 'modelValue',
|
||||
api: undefined,
|
||||
autoSelect: false,
|
||||
options: () => [],
|
||||
});
|
||||
|
||||
@@ -78,37 +41,51 @@ const emit = defineEmits<{
|
||||
optionsChange: [OptionsItem[]];
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel({ default: '' });
|
||||
const modelValue = defineModel<any>({ default: undefined });
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const innerParams = ref({});
|
||||
const refOptions = ref<OptionsItem[]>([]);
|
||||
const loading = ref(false);
|
||||
// 首次是否加载过了
|
||||
const isFirstLoaded = ref(false);
|
||||
// 标记是否有待处理的请求
|
||||
const hasPendingRequest = ref(false);
|
||||
|
||||
const getOptions = computed(() => {
|
||||
const { labelField, valueField, childrenField, numberToString } = props;
|
||||
const {
|
||||
labelField,
|
||||
labelFn,
|
||||
valueField,
|
||||
disabledField,
|
||||
childrenField,
|
||||
numberToString,
|
||||
} = props;
|
||||
|
||||
const refOptionsData = unref(refOptions);
|
||||
|
||||
function transformData(data: OptionsItem[]): OptionsItem[] {
|
||||
function transformData(data: OptionsItem[] = []): OptionsItem[] {
|
||||
return data.map((item) => {
|
||||
const value = get(item, valueField);
|
||||
const children = childrenField ? get(item, childrenField) : item.children;
|
||||
return {
|
||||
...objectOmit(item, [labelField, valueField, childrenField]),
|
||||
label: get(item, labelField),
|
||||
...objectOmit(item, [
|
||||
labelField,
|
||||
valueField,
|
||||
disabledField,
|
||||
...(childrenField ? [childrenField] : []),
|
||||
]),
|
||||
label: labelFn ? labelFn(item) : get(item, labelField),
|
||||
value: numberToString ? `${value}` : value,
|
||||
...(childrenField && item[childrenField]
|
||||
? { children: transformData(item[childrenField]) }
|
||||
disabled: get(item, disabledField),
|
||||
...(Array.isArray(children) && children.length > 0
|
||||
? { children: transformData(children) }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const data: OptionsItem[] = transformData(refOptionsData);
|
||||
const data = transformData(unref(refOptions));
|
||||
|
||||
return data.length > 0 ? data : props.options;
|
||||
return data.length > 0 ? data : transformData(props.options);
|
||||
});
|
||||
|
||||
const bindProps = computed(() => {
|
||||
@@ -118,7 +95,7 @@ const bindProps = computed(() => {
|
||||
[`onUpdate:${props.modelPropName}`]: (val: string) => {
|
||||
modelValue.value = val;
|
||||
},
|
||||
...objectOmit(attrs, ['onUpdate:value']),
|
||||
...objectOmit(attrs, [`onUpdate:${props.modelPropName}`]),
|
||||
...(props.visibleEvent
|
||||
? {
|
||||
[props.visibleEvent]: handleFetchForVisible,
|
||||
@@ -128,18 +105,34 @@ const bindProps = computed(() => {
|
||||
});
|
||||
|
||||
async function fetchApi() {
|
||||
let { api, beforeFetch, afterFetch, params, resultField } = props;
|
||||
const { api, beforeFetch, shouldFetch, afterFetch, resultField } = props;
|
||||
|
||||
if (!api || !isFunction(api) || loading.value) {
|
||||
if (!api || !isFunction(api)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果正在加载,标记有待处理的请求并返回
|
||||
if (loading.value) {
|
||||
hasPendingRequest.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
refOptions.value = [];
|
||||
try {
|
||||
loading.value = true;
|
||||
let finalParams = unref(mergedParams);
|
||||
if (beforeFetch && isFunction(beforeFetch)) {
|
||||
params = (await beforeFetch(params)) || params;
|
||||
finalParams = (await beforeFetch(cloneDeep(finalParams))) || finalParams;
|
||||
}
|
||||
let res = await api(params);
|
||||
// 判断是否需要控制执行中断
|
||||
if (
|
||||
shouldFetch &&
|
||||
isFunction(shouldFetch) &&
|
||||
!(await shouldFetch(finalParams))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let res = await api(finalParams);
|
||||
if (afterFetch && isFunction(afterFetch)) {
|
||||
res = (await afterFetch(res)) || res;
|
||||
}
|
||||
@@ -159,6 +152,13 @@ async function fetchApi() {
|
||||
isFirstLoaded.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
// 如果有待处理的请求,立即触发新的请求
|
||||
if (hasPendingRequest.value) {
|
||||
hasPendingRequest.value = false;
|
||||
// 使用 nextTick 确保状态更新完成后再触发新请求
|
||||
await nextTick();
|
||||
fetchApi();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,8 +172,15 @@ async function handleFetchForVisible(visible: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
const mergedParams = computed(() => {
|
||||
return {
|
||||
...props.params,
|
||||
...unref(innerParams),
|
||||
};
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.params,
|
||||
mergedParams,
|
||||
(value, oldValue) => {
|
||||
if (isEqual(value, oldValue)) {
|
||||
return;
|
||||
@@ -184,22 +191,63 @@ watch(
|
||||
);
|
||||
|
||||
function emitChange() {
|
||||
if (
|
||||
modelValue.value === undefined &&
|
||||
props.autoSelect &&
|
||||
unref(getOptions).length > 0
|
||||
) {
|
||||
let firstOption;
|
||||
if (isFunction(props.autoSelect)) {
|
||||
firstOption = props.autoSelect(unref(getOptions));
|
||||
} else {
|
||||
switch (props.autoSelect) {
|
||||
case 'first': {
|
||||
firstOption = unref(getOptions)[0];
|
||||
break;
|
||||
}
|
||||
case 'last': {
|
||||
firstOption = unref(getOptions)[unref(getOptions).length - 1];
|
||||
break;
|
||||
}
|
||||
case 'one': {
|
||||
if (unref(getOptions).length === 1) {
|
||||
firstOption = unref(getOptions)[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstOption) modelValue.value = firstOption.value;
|
||||
}
|
||||
emit('optionsChange', unref(getOptions));
|
||||
}
|
||||
const componentRef = ref();
|
||||
defineExpose({
|
||||
/** 获取options数据 */
|
||||
getOptions: () => unref(getOptions),
|
||||
/** 获取当前值 */
|
||||
getValue: () => unref(modelValue),
|
||||
/** 获取被包装的组件实例 */
|
||||
getComponentRef: <T = any>() => componentRef.value as T,
|
||||
/** 更新Api参数 */
|
||||
updateParam(newParams: Record<string, any>) {
|
||||
innerParams.value = newParams;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div v-bind="{ ...$attrs }">
|
||||
<component
|
||||
:is="component"
|
||||
v-bind="bindProps"
|
||||
:placeholder="$attrs.placeholder"
|
||||
>
|
||||
<template v-for="item in Object.keys($slots)" #[item]="data">
|
||||
<slot :name="item" v-bind="data || {}"></slot>
|
||||
</template>
|
||||
<template v-if="loadingSlot && loading" #[loadingSlot]>
|
||||
<LoaderCircle class="animate-spin" />
|
||||
</template>
|
||||
</component>
|
||||
</div>
|
||||
<component
|
||||
:is="component"
|
||||
v-bind="bindProps"
|
||||
:placeholder="$attrs.placeholder"
|
||||
ref="componentRef"
|
||||
>
|
||||
<template v-for="item in Object.keys($slots)" #[item]="data">
|
||||
<slot :name="item" v-bind="data || {}"></slot>
|
||||
</template>
|
||||
<template v-if="loadingSlot && loading" #[loadingSlot]>
|
||||
<LoaderCircle class="animate-spin" />
|
||||
</template>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export { default as ApiComponent } from './api-component.vue';
|
||||
export type {
|
||||
ApiComponentLabelFn,
|
||||
ApiComponentOptionsItem,
|
||||
ApiComponentProps,
|
||||
ApiComponentSharedProps,
|
||||
} from './types';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { AnyPromiseFunction } from '@vben/types';
|
||||
|
||||
export type ApiComponentOptionsItem = {
|
||||
[name: string]: any;
|
||||
children?: ApiComponentOptionsItem[];
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
value?: number | string;
|
||||
};
|
||||
|
||||
export type ApiComponentLabelFn = (item: ApiComponentOptionsItem) => string;
|
||||
|
||||
export interface ApiComponentProps {
|
||||
/** 组件 */
|
||||
component: Component;
|
||||
/** 是否将value从数字转为string */
|
||||
numberToString?: boolean;
|
||||
/** 获取options数据的函数 */
|
||||
api?: (arg?: any) => Promise<ApiComponentOptionsItem[] | Record<string, any>>;
|
||||
/** 传递给api的参数 */
|
||||
params?: Record<string, any>;
|
||||
/** 从api返回的结果中提取options数组的字段名 */
|
||||
resultField?: string;
|
||||
/** label字段名 */
|
||||
labelField?: string;
|
||||
/** 通过选项数据自定义label */
|
||||
labelFn?: ApiComponentLabelFn;
|
||||
/** children字段名,需要层级数据的组件可用 */
|
||||
childrenField?: string;
|
||||
/** value字段名 */
|
||||
valueField?: string;
|
||||
/** disabled字段名 */
|
||||
disabledField?: string;
|
||||
/** 组件接收options数据的属性名 */
|
||||
optionsPropName?: string;
|
||||
/** 是否立即调用api */
|
||||
immediate?: boolean;
|
||||
/** 每次`visibleEvent`事件发生时都重新请求数据 */
|
||||
alwaysLoad?: boolean;
|
||||
/** 在api请求之前的回调函数 */
|
||||
beforeFetch?: AnyPromiseFunction<any, any>;
|
||||
/** 在api请求之前的判断是否允许请求的回调函数 */
|
||||
shouldFetch?: AnyPromiseFunction<any, boolean>;
|
||||
/** 在api请求之后的回调函数 */
|
||||
afterFetch?: AnyPromiseFunction<any, any>;
|
||||
/** 直接传入选项数据,也作为api返回空数据时的后备数据 */
|
||||
options?: ApiComponentOptionsItem[];
|
||||
/** 组件的插槽名称,用来显示一个"加载中"的图标 */
|
||||
loadingSlot?: string;
|
||||
/** 触发api请求的事件名 */
|
||||
visibleEvent?: string;
|
||||
/** 组件的v-model属性名,默认为modelValue。部分组件可能为value */
|
||||
modelPropName?: string;
|
||||
/**
|
||||
* 自动选择
|
||||
* - `first`:自动选择第一个选项
|
||||
* - `last`:自动选择最后一个选项
|
||||
* - `one`: 当请求的结果只有一个选项时,自动选择该选项
|
||||
* - 函数:自定义选择逻辑,函数的参数为请求的结果数组,返回值为选择的选项
|
||||
* - false:不自动选择(默认)
|
||||
*/
|
||||
autoSelect?:
|
||||
| 'first'
|
||||
| 'last'
|
||||
| 'one'
|
||||
| ((item: ApiComponentOptionsItem[]) => ApiComponentOptionsItem)
|
||||
| false;
|
||||
}
|
||||
|
||||
export type ApiComponentSharedProps = Omit<ApiComponentProps, 'component'>;
|
||||
@@ -9,7 +9,7 @@ export function useCaptchaPoints() {
|
||||
}
|
||||
|
||||
function clearPoints() {
|
||||
points.splice(0, points.length);
|
||||
points.splice(0);
|
||||
}
|
||||
return {
|
||||
addPoint,
|
||||
|
||||
@@ -3,4 +3,5 @@ export { default as PointSelectionCaptchaCard } from './point-selection-captcha/
|
||||
|
||||
export { default as SliderCaptcha } from './slider-captcha/index.vue';
|
||||
export { default as SliderRotateCaptcha } from './slider-rotate-captcha/index.vue';
|
||||
export { default as SliderTranslateCaptcha } from './slider-translate-captcha/index.vue';
|
||||
export type * from './types';
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CaptchaPoint, PointSelectionCaptchaProps } from '../types';
|
||||
|
||||
import { RotateCw } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { VbenButton, VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useCaptchaPoints } from '../hooks/useCaptchaPoints';
|
||||
@@ -151,7 +152,7 @@ function handleConfirm() {
|
||||
top: `${point.y - POINT_OFFSET}px`,
|
||||
left: `${point.x - POINT_OFFSET}px`,
|
||||
}"
|
||||
class="bg-primary text-primary-50 border-primary-50 absolute z-20 flex h-5 w-5 cursor-default items-center justify-center rounded-full border-2"
|
||||
class="absolute z-20 flex-center size-5 cursor-default rounded-full border-2 border-primary-50 bg-primary text-primary-50"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
@@ -162,11 +163,11 @@ function handleConfirm() {
|
||||
v-if="hintImage"
|
||||
:alt="$t('ui.captcha.alt')"
|
||||
:src="hintImage"
|
||||
class="border-border h-10 w-full rounded border"
|
||||
class="h-10 w-full rounded-sm border border-border"
|
||||
/>
|
||||
<div
|
||||
v-else-if="hintText"
|
||||
class="border-border flex-center h-10 w-full rounded border"
|
||||
class="flex-center h-10 w-full rounded-sm border border-border"
|
||||
>
|
||||
{{ `${$t('ui.captcha.clickInOrder')}` + `【${hintText}】` }}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PointSelectionCaptchaCardProps } from '../types';
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -63,7 +64,9 @@ function handleClick(e: MouseEvent) {
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="relative mt-2 flex w-full overflow-hidden rounded p-0">
|
||||
<CardContent
|
||||
class="relative mt-2 flex w-full overflow-hidden rounded-sm p-0"
|
||||
>
|
||||
<img
|
||||
v-show="captchaImage"
|
||||
:alt="$t('ui.captcha.alt')"
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { reactive, unref, useTemplateRef, watch, watchEffect } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
@@ -49,9 +50,11 @@ defineExpose({
|
||||
});
|
||||
|
||||
const wrapperRef = useTemplateRef<HTMLDivElement>('wrapperRef');
|
||||
const barRef = useTemplateRef<typeof SliderCaptchaBar>('barRef');
|
||||
const contentRef = useTemplateRef<typeof SliderCaptchaContent>('contentRef');
|
||||
const actionRef = useTemplateRef<typeof SliderCaptchaAction>('actionRef');
|
||||
const barRef = useTemplateRef<InstanceType<typeof SliderCaptchaBar>>('barRef');
|
||||
const contentRef =
|
||||
useTemplateRef<InstanceType<typeof SliderCaptchaContent>>('contentRef');
|
||||
const actionRef =
|
||||
useTemplateRef<InstanceType<typeof SliderCaptchaAction>>('actionRef');
|
||||
|
||||
watch(
|
||||
() => state.isPassing,
|
||||
@@ -82,20 +85,19 @@ function handleDragStart(e: MouseEvent | TouchEvent) {
|
||||
if (state.isPassing) {
|
||||
return;
|
||||
}
|
||||
if (!actionRef.value) return;
|
||||
const actionEl = actionRef.value;
|
||||
const actionStyle = actionEl?.getStyle();
|
||||
if (!actionEl || !actionStyle) return;
|
||||
emit('start', e);
|
||||
|
||||
state.moveDistance =
|
||||
getEventPageX(e) -
|
||||
Number.parseInt(
|
||||
actionRef.value.getStyle().left.replace('px', '') || '0',
|
||||
10,
|
||||
);
|
||||
Number.parseInt(actionStyle.left.replace('px', '') || '0', 10);
|
||||
state.startTime = Date.now();
|
||||
state.isMoving = true;
|
||||
}
|
||||
|
||||
function getOffset(actionEl: HTMLDivElement) {
|
||||
function getOffset(actionEl?: HTMLDivElement | null) {
|
||||
const wrapperWidth = wrapperRef.value?.offsetWidth ?? 220;
|
||||
const actionWidth = actionEl?.offsetWidth ?? 40;
|
||||
const offset = wrapperWidth - actionWidth - 6;
|
||||
@@ -108,7 +110,9 @@ function handleDragMoving(e: MouseEvent | TouchEvent) {
|
||||
const actionEl = unref(actionRef);
|
||||
const barEl = unref(barRef);
|
||||
if (!actionEl || !barEl) return;
|
||||
const { actionWidth, offset, wrapperWidth } = getOffset(actionEl.getEl());
|
||||
const actionNode = actionEl.getEl();
|
||||
if (!actionNode) return;
|
||||
const { actionWidth, offset, wrapperWidth } = getOffset(actionNode);
|
||||
const moveX = getEventPageX(e) - moveDistance;
|
||||
|
||||
emit('move', {
|
||||
@@ -137,14 +141,18 @@ function handleDragOver(e: MouseEvent | TouchEvent) {
|
||||
const barEl = unref(barRef);
|
||||
if (!actionEl || !barEl) return;
|
||||
const moveX = getEventPageX(e) - moveDistance;
|
||||
const { actionWidth, offset, wrapperWidth } = getOffset(actionEl.getEl());
|
||||
const actionNode = actionEl.getEl();
|
||||
if (!actionNode) return;
|
||||
const { actionWidth, offset, wrapperWidth } = getOffset(actionNode);
|
||||
if (moveX < offset) {
|
||||
if (props.isSlot) {
|
||||
setTimeout(() => {
|
||||
if (modelValue.value) {
|
||||
const contentEl = unref(contentRef);
|
||||
if (contentEl) {
|
||||
contentEl.getEl().style.width = `${Number.parseInt(barEl.getEl().style.width)}px`;
|
||||
const contentNode = contentEl?.getEl();
|
||||
const barNode = barEl.getEl();
|
||||
if (contentNode && barNode) {
|
||||
contentNode.style.width = `${Number.parseInt(barNode.style.width || '0', 10)}px`;
|
||||
}
|
||||
} else {
|
||||
resume();
|
||||
@@ -184,7 +192,10 @@ function resume() {
|
||||
const contentEl = unref(contentRef);
|
||||
if (!actionEl || !barEl || !contentEl) return;
|
||||
|
||||
contentEl.getEl().style.width = '100%';
|
||||
const contentNode = contentEl.getEl();
|
||||
if (!contentNode) return;
|
||||
|
||||
contentNode.style.width = '100%';
|
||||
state.toLeft = true;
|
||||
useTimeoutFn(() => {
|
||||
state.toLeft = false;
|
||||
@@ -199,7 +210,7 @@ function resume() {
|
||||
ref="wrapperRef"
|
||||
:class="
|
||||
cn(
|
||||
'border-border bg-background-deep relative flex h-10 w-full items-center overflow-hidden rounded-md border text-center',
|
||||
'relative flex h-10 w-full items-center overflow-hidden rounded-md border border-border bg-background-deep text-center',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
|
||||
import { Check, ChevronsRight } from '@vben/icons';
|
||||
|
||||
import { Slot } from '@vben-core/shadcn-ui';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -46,14 +48,14 @@ defineExpose({
|
||||
<div
|
||||
ref="actionRef"
|
||||
:class="{
|
||||
'transition-width !left-0 duration-300': toLeft,
|
||||
'transition-width left-0! duration-300': toLeft,
|
||||
'rounded-md': isDragging,
|
||||
}"
|
||||
:style="style"
|
||||
class="bg-background dark:bg-accent absolute left-0 top-0 flex h-full cursor-move items-center justify-center px-3.5 shadow-md"
|
||||
class="absolute top-0 left-0 flex-center h-full cursor-move bg-background px-3.5 shadow-md dark:bg-accent"
|
||||
name="captcha-action"
|
||||
>
|
||||
<Slot :is-passing="isPassing" class="text-foreground/60 size-4">
|
||||
<Slot :is-passing="isPassing" class="size-4 text-foreground/60">
|
||||
<slot name="icon">
|
||||
<ChevronsRight v-if="!isPassing" />
|
||||
<Check v-else />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type CSSProperties, ref, useTemplateRef } from 'vue';
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
barStyle: CSSProperties;
|
||||
@@ -31,8 +33,8 @@ defineExpose({
|
||||
<template>
|
||||
<div
|
||||
ref="barRef"
|
||||
:class="toLeft && 'transition-width !w-0 duration-300'"
|
||||
:class="toLeft && 'transition-width w-0! duration-300'"
|
||||
:style="style"
|
||||
class="bg-success absolute h-full"
|
||||
class="absolute h-full bg-success"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
|
||||
import { VbenSpineText } from '@vben-core/shadcn-ui';
|
||||
@@ -35,7 +36,7 @@ defineExpose({
|
||||
[$style.success]: isPassing,
|
||||
}"
|
||||
:style="style"
|
||||
class="absolute top-0 flex size-full select-none items-center justify-center text-xs"
|
||||
class="absolute top-0 flex-center size-full text-xs select-none"
|
||||
>
|
||||
<slot name="text">
|
||||
<VbenSpineText class="flex h-full items-center">
|
||||
|
||||
@@ -84,12 +84,12 @@ function handleDragBarMove(data: SliderRotateVerifyPassingData) {
|
||||
state.dragging = true;
|
||||
const { imageSize, maxDegree } = props;
|
||||
const { moveX } = data;
|
||||
const denominator = imageSize!;
|
||||
const denominator = imageSize;
|
||||
if (denominator === 0) {
|
||||
return;
|
||||
}
|
||||
const currentRotate = Math.ceil(
|
||||
(moveX / denominator) * 1.5 * maxDegree! * unref(getFactorRef),
|
||||
(moveX / denominator) * 1.5 * maxDegree * unref(getFactorRef),
|
||||
);
|
||||
state.currentRotate = currentRotate;
|
||||
setImgRotate(state.randomRotate - currentRotate);
|
||||
@@ -98,7 +98,7 @@ function handleDragBarMove(data: SliderRotateVerifyPassingData) {
|
||||
function handleImgOnLoad() {
|
||||
const { maxDegree, minDegree } = props;
|
||||
const ranRotate = Math.floor(
|
||||
minDegree! + Math.random() * (maxDegree! - minDegree!),
|
||||
minDegree + Math.random() * (maxDegree - minDegree),
|
||||
); // 生成随机角度
|
||||
state.randomRotate = ranRotate;
|
||||
setImgRotate(ranRotate);
|
||||
@@ -167,7 +167,7 @@ defineExpose({
|
||||
<div class="relative flex flex-col items-center">
|
||||
<div
|
||||
:style="getImgWrapStyleRef"
|
||||
class="border-border relative cursor-pointer overflow-hidden rounded-full border shadow-md"
|
||||
class="relative cursor-pointer overflow-hidden rounded-full border border-border shadow-md"
|
||||
>
|
||||
<img
|
||||
:class="imgCls"
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
CaptchaVerifyPassingData,
|
||||
SliderCaptchaActionType,
|
||||
SliderRotateVerifyPassingData,
|
||||
SliderTranslateCaptchaProps,
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
unref,
|
||||
useTemplateRef,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SliderCaptcha from '../slider-captcha/index.vue';
|
||||
|
||||
const props = withDefaults(defineProps<SliderTranslateCaptchaProps>(), {
|
||||
defaultTip: '',
|
||||
canvasWidth: 420,
|
||||
canvasHeight: 280,
|
||||
squareLength: 42,
|
||||
circleRadius: 10,
|
||||
src: '',
|
||||
diffDistance: 3,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: [CaptchaVerifyPassingData];
|
||||
}>();
|
||||
|
||||
const PI: number = Math.PI;
|
||||
const canvasOpr = {
|
||||
clip: 'clip',
|
||||
fill: 'fill',
|
||||
} as const;
|
||||
|
||||
type CanvasOpr = (typeof canvasOpr)[keyof typeof canvasOpr];
|
||||
|
||||
const modalValue = defineModel<boolean>({ default: false });
|
||||
|
||||
const slideBarRef = useTemplateRef<SliderCaptchaActionType>('slideBarRef');
|
||||
const puzzleCanvasRef = useTemplateRef<HTMLCanvasElement>('puzzleCanvasRef');
|
||||
const pieceCanvasRef = useTemplateRef<HTMLCanvasElement>('pieceCanvasRef');
|
||||
|
||||
const state = reactive({
|
||||
dragging: false,
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
pieceX: 0,
|
||||
pieceY: 0,
|
||||
moveDistance: 0,
|
||||
isPassing: false,
|
||||
showTip: false,
|
||||
});
|
||||
|
||||
const left = ref('0');
|
||||
|
||||
const pieceStyle = computed(() => {
|
||||
return {
|
||||
left: left.value,
|
||||
};
|
||||
});
|
||||
|
||||
function setLeft(val: string) {
|
||||
left.value = val;
|
||||
}
|
||||
|
||||
const verifyTip = computed(() => {
|
||||
return state.isPassing
|
||||
? $t('ui.captcha.sliderTranslateSuccessTip', [
|
||||
((state.endTime - state.startTime) / 1000).toFixed(1),
|
||||
])
|
||||
: $t('ui.captcha.sliderTranslateFailTip');
|
||||
});
|
||||
function handleStart() {
|
||||
state.startTime = Date.now();
|
||||
}
|
||||
|
||||
function handleDragBarMove(data: SliderRotateVerifyPassingData) {
|
||||
state.dragging = true;
|
||||
const { moveX } = data;
|
||||
state.moveDistance = moveX;
|
||||
setLeft(`${moveX}px`);
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
const { pieceX } = state;
|
||||
const { diffDistance } = props;
|
||||
|
||||
if (Math.abs(pieceX - state.moveDistance) >= (diffDistance || 3)) {
|
||||
setLeft('0');
|
||||
state.moveDistance = 0;
|
||||
} else {
|
||||
checkPass();
|
||||
}
|
||||
state.showTip = true;
|
||||
state.dragging = false;
|
||||
}
|
||||
|
||||
function checkPass() {
|
||||
state.isPassing = true;
|
||||
state.endTime = Date.now();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => state.isPassing,
|
||||
(isPassing) => {
|
||||
if (isPassing) {
|
||||
const { endTime, startTime } = state;
|
||||
const time = (endTime - startTime) / 1000;
|
||||
emit('success', { isPassing, time: time.toFixed(1) });
|
||||
}
|
||||
modalValue.value = isPassing;
|
||||
},
|
||||
);
|
||||
|
||||
function resetCanvas() {
|
||||
const { canvasWidth, canvasHeight } = props;
|
||||
const puzzleCanvas = unref(puzzleCanvasRef);
|
||||
const pieceCanvas = unref(pieceCanvasRef);
|
||||
if (!puzzleCanvas || !pieceCanvas) return;
|
||||
pieceCanvas.width = canvasWidth;
|
||||
const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
|
||||
// Canvas2D: Multiple readback operations using getImageData
|
||||
// are faster with the willReadFrequently attribute set to true.
|
||||
// See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
|
||||
const pieceCanvasCtx = pieceCanvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
|
||||
puzzleCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
pieceCanvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
function initCanvas() {
|
||||
const { canvasWidth, canvasHeight, squareLength, circleRadius, src } = props;
|
||||
const puzzleCanvas = unref(puzzleCanvasRef);
|
||||
const pieceCanvas = unref(pieceCanvasRef);
|
||||
if (!puzzleCanvas || !pieceCanvas) return;
|
||||
const puzzleCanvasCtx = puzzleCanvas.getContext('2d');
|
||||
// Canvas2D: Multiple readback operations using getImageData
|
||||
// are faster with the willReadFrequently attribute set to true.
|
||||
// See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently (anonymous)
|
||||
const pieceCanvasCtx = pieceCanvas.getContext('2d', {
|
||||
willReadFrequently: true,
|
||||
});
|
||||
if (!puzzleCanvasCtx || !pieceCanvasCtx) return;
|
||||
const img = new Image();
|
||||
// 解决跨域
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.src = src;
|
||||
img.addEventListener('load', () => {
|
||||
draw(puzzleCanvasCtx, pieceCanvasCtx);
|
||||
puzzleCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
|
||||
pieceCanvasCtx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
|
||||
const pieceLength = squareLength + 2 * circleRadius + 3;
|
||||
const sx = state.pieceX;
|
||||
const sy = state.pieceY - 2 * circleRadius - 1;
|
||||
const imageData = pieceCanvasCtx.getImageData(
|
||||
sx,
|
||||
sy,
|
||||
pieceLength,
|
||||
pieceLength,
|
||||
);
|
||||
pieceCanvas.width = pieceLength;
|
||||
pieceCanvasCtx.putImageData(imageData, 0, sy);
|
||||
setLeft('0');
|
||||
});
|
||||
}
|
||||
|
||||
function getRandomNumberByRange(start: number, end: number) {
|
||||
return Math.round(Math.random() * (end - start) + start);
|
||||
}
|
||||
|
||||
// 绘制拼图
|
||||
function draw(ctx1: CanvasRenderingContext2D, ctx2: CanvasRenderingContext2D) {
|
||||
const { canvasWidth, canvasHeight, squareLength, circleRadius } = props;
|
||||
state.pieceX = getRandomNumberByRange(
|
||||
squareLength + 2 * circleRadius,
|
||||
canvasWidth - (squareLength + 2 * circleRadius),
|
||||
);
|
||||
state.pieceY = getRandomNumberByRange(
|
||||
3 * circleRadius,
|
||||
canvasHeight - (squareLength + 2 * circleRadius),
|
||||
);
|
||||
drawPiece(ctx1, state.pieceX, state.pieceY, canvasOpr.fill);
|
||||
drawPiece(ctx2, state.pieceX, state.pieceY, canvasOpr.clip);
|
||||
}
|
||||
|
||||
// 绘制拼图切块
|
||||
function drawPiece(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
opr: CanvasOpr,
|
||||
) {
|
||||
const { squareLength, circleRadius } = props;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.arc(
|
||||
x + squareLength / 2,
|
||||
y - circleRadius + 2,
|
||||
circleRadius,
|
||||
0.72 * PI,
|
||||
2.26 * PI,
|
||||
);
|
||||
ctx.lineTo(x + squareLength, y);
|
||||
ctx.arc(
|
||||
x + squareLength + circleRadius - 2,
|
||||
y + squareLength / 2,
|
||||
circleRadius,
|
||||
1.21 * PI,
|
||||
2.78 * PI,
|
||||
);
|
||||
ctx.lineTo(x + squareLength, y + squareLength);
|
||||
ctx.lineTo(x, y + squareLength);
|
||||
ctx.arc(
|
||||
x + circleRadius - 2,
|
||||
y + squareLength / 2,
|
||||
circleRadius + 0.4,
|
||||
2.76 * PI,
|
||||
1.24 * PI,
|
||||
true,
|
||||
);
|
||||
ctx.lineTo(x, y);
|
||||
ctx.lineWidth = 2;
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.stroke();
|
||||
opr === canvasOpr.clip ? ctx.clip() : ctx.fill();
|
||||
ctx.globalCompositeOperation = 'destination-over';
|
||||
}
|
||||
|
||||
function resume() {
|
||||
state.showTip = false;
|
||||
const basicEl = unref(slideBarRef);
|
||||
if (!basicEl) {
|
||||
return;
|
||||
}
|
||||
state.dragging = false;
|
||||
state.isPassing = false;
|
||||
state.pieceX = 0;
|
||||
state.pieceY = 0;
|
||||
|
||||
basicEl.resume();
|
||||
resetCanvas();
|
||||
initCanvas();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initCanvas();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex flex-col items-center">
|
||||
<div
|
||||
class="relative flex cursor-pointer overflow-hidden border border-border shadow-md"
|
||||
>
|
||||
<canvas
|
||||
ref="puzzleCanvasRef"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
@click="resume"
|
||||
></canvas>
|
||||
<canvas
|
||||
ref="pieceCanvasRef"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
:style="pieceStyle"
|
||||
class="absolute"
|
||||
@click="resume"
|
||||
></canvas>
|
||||
<div
|
||||
class="absolute bottom-3 left-0 z-10 block h-15 w-full text-center text-xs leading-[30px] text-white"
|
||||
>
|
||||
<div
|
||||
v-if="state.showTip"
|
||||
:class="{
|
||||
'bg-success/80': state.isPassing,
|
||||
'bg-destructive/80': !state.isPassing,
|
||||
}"
|
||||
>
|
||||
{{ verifyTip }}
|
||||
</div>
|
||||
<div v-if="!state.dragging" class="bg-black/30">
|
||||
{{ defaultTip || $t('ui.captcha.sliderTranslateDefaultTip') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SliderCaptcha
|
||||
ref="slideBarRef"
|
||||
v-model="modalValue"
|
||||
class="mt-5"
|
||||
is-slot
|
||||
@end="handleDragEnd"
|
||||
@move="handleDragBarMove"
|
||||
@start="handleStart"
|
||||
>
|
||||
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
|
||||
<slot :name="key" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
</SliderCaptcha>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ClassType } from '@vben/types';
|
||||
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import type { ClassType } from '@vben/types';
|
||||
|
||||
export interface CaptchaData {
|
||||
/**
|
||||
* x
|
||||
@@ -54,8 +54,7 @@ export interface PointSelectionCaptchaCardProps {
|
||||
width?: number | string;
|
||||
}
|
||||
|
||||
export interface PointSelectionCaptchaProps
|
||||
extends PointSelectionCaptchaCardProps {
|
||||
export interface PointSelectionCaptchaProps extends PointSelectionCaptchaCardProps {
|
||||
/**
|
||||
* 是否展示确定按钮
|
||||
* @default false
|
||||
@@ -159,6 +158,42 @@ export interface SliderRotateCaptchaProps {
|
||||
defaultTip?: string;
|
||||
}
|
||||
|
||||
export interface SliderTranslateCaptchaProps {
|
||||
/**
|
||||
* @description 拼图的宽度
|
||||
* @default 420
|
||||
*/
|
||||
canvasWidth?: number;
|
||||
/**
|
||||
* @description 拼图的高度
|
||||
* @default 280
|
||||
*/
|
||||
canvasHeight?: number;
|
||||
/**
|
||||
* @description 切块上正方形的长度
|
||||
* @default 42
|
||||
*/
|
||||
squareLength?: number;
|
||||
/**
|
||||
* @description 切块上圆形的半径
|
||||
* @default 10
|
||||
*/
|
||||
circleRadius?: number;
|
||||
/**
|
||||
* @description 图片的地址
|
||||
*/
|
||||
src?: string;
|
||||
/**
|
||||
* @description 允许的最大差距
|
||||
* @default 3
|
||||
*/
|
||||
diffDistance?: number;
|
||||
/**
|
||||
* @description 默认提示文本
|
||||
*/
|
||||
defaultTip?: string;
|
||||
}
|
||||
|
||||
export interface CaptchaVerifyPassingData {
|
||||
isPassing: boolean;
|
||||
time: number | string;
|
||||
|
||||
@@ -76,14 +76,16 @@ defineExpose({
|
||||
:min-size="leftMinWidth"
|
||||
>
|
||||
<template #default="slotProps">
|
||||
<slot
|
||||
name="left"
|
||||
v-bind="{
|
||||
...slotProps,
|
||||
expand: expandLeft,
|
||||
collapse: collapseLeft,
|
||||
}"
|
||||
></slot>
|
||||
<div class="h-full pr-2">
|
||||
<slot
|
||||
name="left"
|
||||
v-bind="{
|
||||
...slotProps,
|
||||
expand: expandLeft,
|
||||
collapse: collapseLeft,
|
||||
}"
|
||||
></slot>
|
||||
</div>
|
||||
</template>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
@@ -99,7 +101,9 @@ defineExpose({
|
||||
:min-size="rightMinWidth"
|
||||
>
|
||||
<template #default>
|
||||
<slot></slot>
|
||||
<div class="h-full pl-2">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
123
packages/effects/common-ui/src/components/count-to/count-to.vue
Normal file
123
packages/effects/common-ui/src/components/count-to/count-to.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CountToProps } from './types';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { isString } from '@vben-core/shared/utils';
|
||||
|
||||
import { TransitionPresets, useTransition } from '@vueuse/core';
|
||||
|
||||
const props = withDefaults(defineProps<CountToProps>(), {
|
||||
startVal: 0,
|
||||
duration: 2000,
|
||||
separator: ',',
|
||||
decimal: '.',
|
||||
decimals: 0,
|
||||
delay: 0,
|
||||
transition: () => TransitionPresets.easeOutExpo,
|
||||
});
|
||||
|
||||
const emit = defineEmits(['started', 'finished']);
|
||||
|
||||
const lastValue = ref(props.startVal);
|
||||
|
||||
onMounted(() => {
|
||||
lastValue.value = props.endVal;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.endVal,
|
||||
(val) => {
|
||||
lastValue.value = val;
|
||||
},
|
||||
);
|
||||
|
||||
const currentValue = useTransition(lastValue, {
|
||||
delay: computed(() => props.delay),
|
||||
duration: computed(() => props.duration),
|
||||
disabled: computed(() => props.disabled),
|
||||
transition: computed(() => {
|
||||
return isString(props.transition)
|
||||
? TransitionPresets[props.transition]
|
||||
: props.transition;
|
||||
}),
|
||||
onStarted() {
|
||||
emit('started');
|
||||
},
|
||||
onFinished() {
|
||||
emit('finished');
|
||||
},
|
||||
});
|
||||
|
||||
const numMain = computed(() => {
|
||||
const result = currentValue.value
|
||||
.toFixed(props.decimals)
|
||||
.split('.')[0]
|
||||
?.replaceAll(/\B(?=(\d{3})+(?!\d))/g, props.separator);
|
||||
return result;
|
||||
});
|
||||
|
||||
const numDec = computed(() => {
|
||||
return (
|
||||
props.decimal + currentValue.value.toFixed(props.decimals).split('.')[1]
|
||||
);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="count-to" v-bind="$attrs">
|
||||
<slot name="prefix">
|
||||
<div
|
||||
class="count-to-prefix"
|
||||
:style="prefixStyle"
|
||||
:class="prefixClass"
|
||||
v-if="prefix"
|
||||
>
|
||||
{{ prefix }}
|
||||
</div>
|
||||
</slot>
|
||||
<div class="count-to-main" :class="mainClass" :style="mainStyle">
|
||||
<span>{{ numMain }}</span>
|
||||
<span
|
||||
class="count-to-main-decimal"
|
||||
v-if="decimals > 0"
|
||||
:class="decimalClass"
|
||||
:style="decimalStyle"
|
||||
>
|
||||
{{ numDec }}
|
||||
</span>
|
||||
</div>
|
||||
<slot name="suffix">
|
||||
<div
|
||||
class="count-to-suffix"
|
||||
:style="suffixStyle"
|
||||
:class="suffixClass"
|
||||
v-if="suffix"
|
||||
>
|
||||
{{ suffix }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.count-to {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
&-prefix {
|
||||
// font-size: 1rem;
|
||||
}
|
||||
|
||||
&-suffix {
|
||||
// font-size: 1rem;
|
||||
}
|
||||
|
||||
&-main {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
// font-size: 1.5rem;
|
||||
|
||||
&-decimal {
|
||||
// font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as CountTo } from './count-to.vue';
|
||||
export * from './types';
|
||||
53
packages/effects/common-ui/src/components/count-to/types.ts
Normal file
53
packages/effects/common-ui/src/components/count-to/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { CubicBezierPoints, EasingFunction } from '@vueuse/core';
|
||||
|
||||
import type { StyleValue } from 'vue';
|
||||
|
||||
import { TransitionPresets as TransitionPresetsData } from '@vueuse/core';
|
||||
|
||||
export type TransitionPresets = keyof typeof TransitionPresetsData;
|
||||
|
||||
export const TransitionPresetsKeys = Object.keys(
|
||||
TransitionPresetsData,
|
||||
) as TransitionPresets[];
|
||||
|
||||
export interface CountToProps {
|
||||
/** 初始值 */
|
||||
startVal?: number;
|
||||
/** 当前值 */
|
||||
endVal: number;
|
||||
/** 是否禁用动画 */
|
||||
disabled?: boolean;
|
||||
/** 延迟动画开始的时间 */
|
||||
delay?: number;
|
||||
/** 持续时间 */
|
||||
duration?: number;
|
||||
/** 小数位数 */
|
||||
decimals?: number;
|
||||
/** 小数点 */
|
||||
decimal?: string;
|
||||
/** 分隔符 */
|
||||
separator?: string;
|
||||
/** 前缀 */
|
||||
prefix?: string;
|
||||
/** 后缀 */
|
||||
suffix?: string;
|
||||
/** 过渡效果 */
|
||||
transition?: CubicBezierPoints | EasingFunction | TransitionPresets;
|
||||
/** 整数部分的类名 */
|
||||
mainClass?: string;
|
||||
/** 小数部分的类名 */
|
||||
decimalClass?: string;
|
||||
/** 前缀部分的类名 */
|
||||
prefixClass?: string;
|
||||
/** 后缀部分的类名 */
|
||||
suffixClass?: string;
|
||||
|
||||
/** 整数部分的样式 */
|
||||
mainStyle?: StyleValue;
|
||||
/** 小数部分的样式 */
|
||||
decimalStyle?: StyleValue;
|
||||
/** 前缀部分的样式 */
|
||||
prefixStyle?: StyleValue;
|
||||
/** 后缀部分的样式 */
|
||||
suffixStyle?: StyleValue;
|
||||
}
|
||||
981
packages/effects/common-ui/src/components/cropper/cropper.vue
Normal file
981
packages/effects/common-ui/src/components/cropper/cropper.vue
Normal file
@@ -0,0 +1,981 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
// 定义组件参数
|
||||
const props = defineProps<{
|
||||
/** 裁剪比例 格式如 '1:1', '16:9', '3:4' 等(非必填) */
|
||||
aspectRatio?: string;
|
||||
/** 容器高度(默认400) */
|
||||
height?: number;
|
||||
/** 图片地址 */
|
||||
img: string;
|
||||
/** 容器宽度(默认500) */
|
||||
width?: number;
|
||||
}>();
|
||||
|
||||
const CROPPER_CONSTANTS = {
|
||||
MIN_WIDTH: 60 as const,
|
||||
MIN_HEIGHT: 60 as const,
|
||||
DEFAULT_WIDTH: 500 as const,
|
||||
DEFAULT_HEIGHT: 400 as const,
|
||||
PADDING_RATIO: 0.1 as const,
|
||||
MAX_PADDING: 50 as const,
|
||||
} as const;
|
||||
|
||||
type Point = [number, number]; // [clientX, clientY]
|
||||
type Dimension = [number, number, number, number]; // [top, right, bottom, left]
|
||||
|
||||
// 拖拽点类型
|
||||
type DragAction =
|
||||
| 'bottom'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right'
|
||||
| 'left'
|
||||
| 'move'
|
||||
| 'right'
|
||||
| 'top'
|
||||
| 'top-left'
|
||||
| 'top-right';
|
||||
|
||||
// DOM 引用
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const bgImageRef = ref<HTMLImageElement | null>(null);
|
||||
// const maskRef = ref<HTMLDivElement | null>(null);
|
||||
const maskViewRef = ref<HTMLDivElement | null>(null);
|
||||
const cropperRef = ref<HTMLDivElement | null>(null);
|
||||
// const cropperViewRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
// 响应式数据
|
||||
const isCropperVisible = ref<boolean>(false);
|
||||
const validAspectRatio = ref<null | number>(null); // 有效比例值(null表示无固定比例)
|
||||
const containerWidth = ref<number>(
|
||||
props.width ?? CROPPER_CONSTANTS.DEFAULT_WIDTH,
|
||||
);
|
||||
const containerHeight = ref<number>(
|
||||
props.height ?? CROPPER_CONSTANTS.DEFAULT_HEIGHT,
|
||||
);
|
||||
|
||||
// 裁剪区域尺寸(top, right, bottom, left)
|
||||
const currentDimension = ref<Dimension>([50, 50, 50, 50]);
|
||||
const initDimension = ref<Dimension>([50, 50, 50, 50]);
|
||||
|
||||
// 拖拽状态
|
||||
const dragging = ref<boolean>(false);
|
||||
const startPoint = ref<Point>([0, 0]);
|
||||
const startDimension = ref<Dimension>([0, 0, 0, 0]);
|
||||
const direction = ref<Dimension>([0, 0, 0, 0]);
|
||||
const moving = ref<boolean>(false);
|
||||
|
||||
/**
|
||||
* 计算图片的适配尺寸,保证完整显示且不超过最大宽高限制
|
||||
*/
|
||||
const calculateImageFitSize = () => {
|
||||
if (!bgImageRef.value) return;
|
||||
|
||||
// 获取图片原始尺寸
|
||||
const imgWidth = bgImageRef.value.naturalWidth;
|
||||
const imgHeight = bgImageRef.value.naturalHeight;
|
||||
|
||||
if (imgWidth === 0 || imgHeight === 0) return;
|
||||
|
||||
// 计算缩放比例(使用传入的width/height,默认500/400)
|
||||
const widthRatio =
|
||||
(props.width ?? CROPPER_CONSTANTS.DEFAULT_WIDTH) / imgWidth;
|
||||
const heightRatio =
|
||||
(props.height ?? CROPPER_CONSTANTS.DEFAULT_HEIGHT) / imgHeight;
|
||||
const scaleRatio = Math.min(widthRatio, heightRatio, 1); // 不放大图片,只缩小
|
||||
|
||||
// 计算适配后的容器尺寸
|
||||
const fitWidth = Math.floor(imgWidth * scaleRatio);
|
||||
const fitHeight = Math.floor(imgHeight * scaleRatio);
|
||||
|
||||
containerWidth.value = fitWidth;
|
||||
containerHeight.value = fitHeight;
|
||||
|
||||
// 重置裁剪框初始尺寸(基于新的容器尺寸)
|
||||
const padding = Math.min(
|
||||
CROPPER_CONSTANTS.MAX_PADDING,
|
||||
Math.floor(fitWidth * CROPPER_CONSTANTS.PADDING_RATIO),
|
||||
Math.floor(fitHeight * CROPPER_CONSTANTS.PADDING_RATIO),
|
||||
);
|
||||
|
||||
initDimension.value = [padding, padding, padding, padding];
|
||||
currentDimension.value = [padding, padding, padding, padding];
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证并解析比例字符串
|
||||
* @returns {number|null} 比例值 (width/height),解析失败返回null
|
||||
*/
|
||||
const parseAndValidateAspectRatio = (): null | number => {
|
||||
// 如果未传入比例参数,直接返回null
|
||||
if (!props.aspectRatio) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 验证比例格式
|
||||
const ratioRegex = /^[1-9]\d*:[1-9]\d*$/;
|
||||
if (!ratioRegex.test(props.aspectRatio)) {
|
||||
console.warn('裁剪比例格式错误,应为 "数字:数字" 格式,如 "16:9"');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 解析比例
|
||||
const [width, height] = props.aspectRatio.split(':').map(Number);
|
||||
|
||||
// 验证解析结果有效性
|
||||
if (Number.isNaN(width) || Number.isNaN(height) || !width || !height) {
|
||||
console.warn('裁剪比例解析失败,宽高必须为正整数');
|
||||
return null;
|
||||
}
|
||||
|
||||
return width / height;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置裁剪区域尺寸
|
||||
* @param {Dimension} dimension - [top, right, bottom, left]
|
||||
*/
|
||||
const setDimension = (dimension: Dimension) => {
|
||||
currentDimension.value = [...dimension];
|
||||
if (maskViewRef.value) {
|
||||
maskViewRef.value.style.clipPath = `inset(${dimension[0]}px ${dimension[1]}px ${dimension[2]}px ${dimension[3]}px)`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 调整裁剪区域至指定比例
|
||||
*/
|
||||
const adjustCropperToAspectRatio = () => {
|
||||
if (!cropperRef.value) return;
|
||||
|
||||
// 验证并解析比例
|
||||
validAspectRatio.value = parseAndValidateAspectRatio();
|
||||
|
||||
// 如果无有效比例,使用初始尺寸,不强制固定比例
|
||||
if (validAspectRatio.value === null) {
|
||||
setDimension(initDimension.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// 有有效比例,按比例调整裁剪框
|
||||
const ratio = validAspectRatio.value;
|
||||
const containerWidthVal = containerWidth.value;
|
||||
const containerHeightVal = containerHeight.value;
|
||||
|
||||
// 根据比例计算裁剪框尺寸
|
||||
let newHeight: number, newWidth: number;
|
||||
|
||||
// 先按宽度优先计算
|
||||
newWidth = containerWidthVal;
|
||||
newHeight = newWidth / ratio;
|
||||
|
||||
// 如果高度超出容器,按高度优先计算
|
||||
if (newHeight > containerHeightVal) {
|
||||
newHeight = containerHeightVal;
|
||||
newWidth = newHeight * ratio;
|
||||
}
|
||||
|
||||
// 居中显示
|
||||
const leftRight = (containerWidthVal - newWidth) / 2;
|
||||
const topBottom = (containerHeightVal - newHeight) / 2;
|
||||
|
||||
const newDimension: Dimension = [topBottom, leftRight, topBottom, leftRight];
|
||||
|
||||
setDimension(newDimension);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建裁剪器
|
||||
*/
|
||||
const createCropper = () => {
|
||||
// 计算图片适配尺寸
|
||||
calculateImageFitSize();
|
||||
|
||||
isCropperVisible.value = true;
|
||||
adjustCropperToAspectRatio();
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理鼠标按下事件
|
||||
* @param {MouseEvent} e - 鼠标事件
|
||||
* @param {DragAction} action - 操作类型
|
||||
*/
|
||||
const handleMouseDown = (e: MouseEvent, action: DragAction) => {
|
||||
dragging.value = true;
|
||||
startPoint.value = [e.clientX, e.clientY];
|
||||
startDimension.value = [...currentDimension.value];
|
||||
direction.value = [0, 0, 0, 0];
|
||||
moving.value = false;
|
||||
|
||||
// 处理移动
|
||||
if (action === 'move') {
|
||||
direction.value[0] = 1;
|
||||
direction.value[2] = -1;
|
||||
direction.value[3] = 1;
|
||||
direction.value[1] = -1;
|
||||
moving.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理拖拽方向
|
||||
switch (action) {
|
||||
case 'bottom': {
|
||||
direction.value[2] = -1;
|
||||
break;
|
||||
}
|
||||
case 'bottom-left': {
|
||||
direction.value[2] = -1;
|
||||
direction.value[3] = 1;
|
||||
break;
|
||||
}
|
||||
case 'bottom-right': {
|
||||
direction.value[2] = -1;
|
||||
direction.value[1] = -1;
|
||||
break;
|
||||
}
|
||||
case 'left': {
|
||||
direction.value[3] = 1;
|
||||
break;
|
||||
}
|
||||
case 'right': {
|
||||
direction.value[1] = -1;
|
||||
break;
|
||||
}
|
||||
case 'top': {
|
||||
direction.value[0] = 1;
|
||||
break;
|
||||
}
|
||||
case 'top-left': {
|
||||
direction.value[0] = 1;
|
||||
direction.value[3] = 1;
|
||||
break;
|
||||
}
|
||||
case 'top-right': {
|
||||
direction.value[0] = 1;
|
||||
direction.value[1] = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理鼠标移动事件
|
||||
* @param {MouseEvent} e - 鼠标事件
|
||||
*/
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!dragging.value || !cropperRef.value) return;
|
||||
|
||||
const { clientX, clientY } = e;
|
||||
const diffX = clientX - startPoint.value[0];
|
||||
const diffY = clientY - startPoint.value[1];
|
||||
|
||||
// 处理移动裁剪框
|
||||
if (moving.value) {
|
||||
handleMoveCropBox(diffX, diffY);
|
||||
return;
|
||||
}
|
||||
|
||||
// 无有效比例
|
||||
if (validAspectRatio.value === null) {
|
||||
handleFreeAspectResize(diffX, diffY);
|
||||
} else {
|
||||
handleFixedAspectResize(diffX, diffY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveCropBox = (diffX: number, diffY: number) => {
|
||||
const newDimension = [...startDimension.value] as Dimension;
|
||||
|
||||
// 计算临时偏移后的位置
|
||||
const tempTop = startDimension.value[0] + diffY;
|
||||
const tempLeft = startDimension.value[3] + diffX;
|
||||
|
||||
// 计算裁剪框的固定尺寸
|
||||
const cropWidth =
|
||||
containerWidth.value - startDimension.value[3] - startDimension.value[1];
|
||||
const cropHeight =
|
||||
containerHeight.value - startDimension.value[0] - startDimension.value[2];
|
||||
|
||||
// 边界限制:确保裁剪框完全在容器内,且尺寸不变
|
||||
// 顶部边界:top >= 0,且 bottom = 容器高度 - top - 裁剪高度 >= 0
|
||||
newDimension[0] = Math.max(
|
||||
0,
|
||||
Math.min(tempTop, containerHeight.value - cropHeight),
|
||||
);
|
||||
// 底部边界:bottom = 容器高度 - top - 裁剪高度(由top推导,无需额外计算)
|
||||
newDimension[2] = containerHeight.value - newDimension[0] - cropHeight;
|
||||
// 左侧边界:left >= 0,且 right = 容器宽度 - left - 裁剪宽度 >= 0
|
||||
newDimension[3] = Math.max(
|
||||
0,
|
||||
Math.min(tempLeft, containerWidth.value - cropWidth),
|
||||
);
|
||||
// 右侧边界:right = 容器宽度 - left - 裁剪宽度(由left推导,无需额外计算)
|
||||
newDimension[1] = containerWidth.value - newDimension[3] - cropWidth;
|
||||
|
||||
// 强制保证尺寸不变(兜底)
|
||||
const finalWidth = containerWidth.value - newDimension[3] - newDimension[1];
|
||||
const finalHeight = containerHeight.value - newDimension[0] - newDimension[2];
|
||||
|
||||
if (finalWidth !== cropWidth) {
|
||||
newDimension[1] = containerWidth.value - newDimension[3] - cropWidth;
|
||||
}
|
||||
|
||||
if (finalHeight !== cropHeight) {
|
||||
newDimension[2] = containerHeight.value - newDimension[0] - cropHeight;
|
||||
}
|
||||
|
||||
// 更新裁剪区域(仅位置变化,尺寸/比例完全不变)
|
||||
setDimension(newDimension);
|
||||
};
|
||||
|
||||
const handleFreeAspectResize = (diffX: number, diffY: number) => {
|
||||
const cropperWidth = containerWidth.value;
|
||||
const cropperHeight = containerHeight.value;
|
||||
const currentDimensionNew: Dimension = [0, 0, 0, 0];
|
||||
|
||||
// 计算新的尺寸,确保不小于最小值
|
||||
currentDimensionNew[0] = Math.min(
|
||||
Math.max(startDimension.value[0] + direction.value[0] * diffY, 0),
|
||||
cropperHeight - CROPPER_CONSTANTS.MIN_HEIGHT,
|
||||
);
|
||||
|
||||
currentDimensionNew[1] = Math.min(
|
||||
Math.max(startDimension.value[1] + direction.value[1] * diffX, 0),
|
||||
cropperWidth - CROPPER_CONSTANTS.MIN_WIDTH,
|
||||
);
|
||||
|
||||
currentDimensionNew[2] = Math.min(
|
||||
Math.max(startDimension.value[2] + direction.value[2] * diffY, 0),
|
||||
cropperHeight - CROPPER_CONSTANTS.MIN_HEIGHT,
|
||||
);
|
||||
|
||||
currentDimensionNew[3] = Math.min(
|
||||
Math.max(startDimension.value[3] + direction.value[3] * diffX, 0),
|
||||
cropperWidth - CROPPER_CONSTANTS.MIN_WIDTH,
|
||||
);
|
||||
|
||||
// 确保裁剪区域宽度和高度不小于最小值
|
||||
const newWidth =
|
||||
cropperWidth - currentDimensionNew[3] - currentDimensionNew[1];
|
||||
const newHeight =
|
||||
cropperHeight - currentDimensionNew[0] - currentDimensionNew[2];
|
||||
|
||||
if (newWidth < CROPPER_CONSTANTS.MIN_WIDTH) {
|
||||
if (direction.value[3] === 1) {
|
||||
currentDimensionNew[3] =
|
||||
cropperWidth - currentDimensionNew[1] - CROPPER_CONSTANTS.MIN_WIDTH;
|
||||
} else {
|
||||
currentDimensionNew[1] =
|
||||
cropperWidth - currentDimensionNew[3] - CROPPER_CONSTANTS.MIN_WIDTH;
|
||||
}
|
||||
}
|
||||
|
||||
if (newHeight < CROPPER_CONSTANTS.MIN_HEIGHT) {
|
||||
if (direction.value[0] === 1) {
|
||||
currentDimensionNew[0] =
|
||||
cropperHeight - currentDimensionNew[2] - CROPPER_CONSTANTS.MIN_HEIGHT;
|
||||
} else {
|
||||
currentDimensionNew[2] =
|
||||
cropperHeight - currentDimensionNew[0] - CROPPER_CONSTANTS.MIN_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
setDimension(currentDimensionNew);
|
||||
};
|
||||
|
||||
const handleFixedAspectResize = (diffX: number, diffY: number) => {
|
||||
if (validAspectRatio.value === null) return;
|
||||
const cropperWidth = containerWidth.value;
|
||||
const cropperHeight = containerHeight.value;
|
||||
// 有有效比例 - 固定比例裁剪
|
||||
const ratio = validAspectRatio.value;
|
||||
const currentWidth =
|
||||
cropperWidth - startDimension.value[3] - startDimension.value[1];
|
||||
const currentHeight =
|
||||
cropperHeight - startDimension.value[0] - startDimension.value[2];
|
||||
|
||||
let newHeight: number, newWidth: number;
|
||||
let widthChange = 0;
|
||||
let heightChange = 0;
|
||||
|
||||
// 计算宽度/高度变化量
|
||||
if (direction.value[3] === 1) widthChange = -diffX;
|
||||
else if (direction.value[1] === -1) widthChange = diffX;
|
||||
|
||||
if (direction.value[0] === 1) heightChange = -diffY;
|
||||
else if (direction.value[2] === -1) heightChange = diffY;
|
||||
|
||||
const isCornerDrag =
|
||||
(direction.value[3] === 1 || direction.value[1] === -1) &&
|
||||
(direction.value[0] === 1 || direction.value[2] === -1);
|
||||
|
||||
// 计算新尺寸
|
||||
if (isCornerDrag) {
|
||||
if (Math.abs(widthChange) > Math.abs(heightChange)) {
|
||||
newWidth = Math.max(
|
||||
CROPPER_CONSTANTS.MIN_WIDTH,
|
||||
currentWidth + widthChange,
|
||||
);
|
||||
newHeight = newWidth / ratio;
|
||||
} else {
|
||||
newHeight = Math.max(
|
||||
CROPPER_CONSTANTS.MIN_HEIGHT,
|
||||
currentHeight + heightChange,
|
||||
);
|
||||
newWidth = newHeight * ratio;
|
||||
}
|
||||
} else {
|
||||
if (direction.value[3] === 1 || direction.value[1] === -1) {
|
||||
newWidth = Math.max(
|
||||
CROPPER_CONSTANTS.MIN_WIDTH,
|
||||
currentWidth + widthChange,
|
||||
);
|
||||
newHeight = newWidth / ratio;
|
||||
} else {
|
||||
newHeight = Math.max(
|
||||
CROPPER_CONSTANTS.MIN_HEIGHT,
|
||||
currentHeight + heightChange,
|
||||
);
|
||||
newWidth = newHeight * ratio;
|
||||
}
|
||||
}
|
||||
|
||||
// 限制最大尺寸
|
||||
const maxWidth = cropperWidth;
|
||||
const maxHeight = cropperHeight;
|
||||
|
||||
if (newWidth > maxWidth) {
|
||||
newWidth = maxWidth;
|
||||
newHeight = newWidth / ratio;
|
||||
}
|
||||
|
||||
if (newHeight > maxHeight) {
|
||||
newHeight = maxHeight;
|
||||
newWidth = newHeight * ratio;
|
||||
}
|
||||
|
||||
// 计算新的位置
|
||||
let newLeft = startDimension.value[3];
|
||||
let newTop = startDimension.value[0];
|
||||
let newRight = startDimension.value[1];
|
||||
let newBottom = startDimension.value[2];
|
||||
|
||||
// 根据拖拽方向调整位置
|
||||
if (direction.value[3] === 1) {
|
||||
newLeft = cropperWidth - newWidth - startDimension.value[1];
|
||||
} else if (direction.value[1] === -1) {
|
||||
newRight = cropperWidth - newWidth - startDimension.value[3];
|
||||
} else if (!isCornerDrag) {
|
||||
// 居中调整
|
||||
const currentHorizontalCenter = startDimension.value[3] + currentWidth / 2;
|
||||
newLeft = Math.max(
|
||||
0,
|
||||
Math.min(cropperWidth - newWidth, currentHorizontalCenter - newWidth / 2),
|
||||
);
|
||||
newRight = cropperWidth - newWidth - newLeft;
|
||||
}
|
||||
|
||||
if (direction.value[0] === 1) {
|
||||
newTop = cropperHeight - newHeight - startDimension.value[2];
|
||||
} else if (direction.value[2] === -1) {
|
||||
newBottom = cropperHeight - newHeight - startDimension.value[0];
|
||||
} else if (!isCornerDrag) {
|
||||
// 居中调整
|
||||
const currentVerticalCenter = startDimension.value[0] + currentHeight / 2;
|
||||
newTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
cropperHeight - newHeight,
|
||||
currentVerticalCenter - newHeight / 2,
|
||||
),
|
||||
);
|
||||
newBottom = cropperHeight - newHeight - newTop;
|
||||
}
|
||||
|
||||
// 边界检查
|
||||
newLeft = Math.max(0, newLeft);
|
||||
newTop = Math.max(0, newTop);
|
||||
newRight = Math.max(0, newRight);
|
||||
newBottom = Math.max(0, newBottom);
|
||||
|
||||
const newDimension: Dimension = [newTop, newRight, newBottom, newLeft];
|
||||
setDimension(newDimension);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理鼠标抬起事件
|
||||
*/
|
||||
const handleMouseUp = () => {
|
||||
dragging.value = false;
|
||||
moving.value = false;
|
||||
direction.value = [0, 0, 0, 0];
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理图片加载完成
|
||||
*/
|
||||
const handleImageLoad = () => {
|
||||
createCropper();
|
||||
};
|
||||
|
||||
/**
|
||||
* 裁剪图片
|
||||
* @param {'image/jpeg' | 'image/png'} format - 输出图片格式
|
||||
* @param {number} quality - 压缩质量(0-1)
|
||||
* @param {'blob' | 'base64'} outputType - 输出类型
|
||||
* @param {number} targetWidth - 目标宽度(可选,不传则为原始裁剪宽度)
|
||||
* @param {number} targetHeight - 目标高度(可选,不传则为原始裁剪高度)
|
||||
*/
|
||||
const getCropImage = async (
|
||||
format: 'image/jpeg' | 'image/png' = 'image/png',
|
||||
quality: number = 0.92,
|
||||
outputType: 'base64' | 'blob' = 'blob',
|
||||
targetWidth?: number,
|
||||
targetHeight?: number,
|
||||
): Promise<Blob | string | undefined> => {
|
||||
if (!props.img || !bgImageRef.value || !containerRef.value) return;
|
||||
|
||||
// 质量参数边界修正:强制限制在 0-1 区间,防止传入非法值报错
|
||||
const validQuality = Math.max(0, Math.min(1, quality));
|
||||
|
||||
// 创建临时图片对象获取原始尺寸
|
||||
const tempImg = new Image();
|
||||
// 跨域图片处理:仅对非同源的网络图片设置跨域匿名
|
||||
if (props.img.startsWith('http://') || props.img.startsWith('https://')) {
|
||||
try {
|
||||
const url = new URL(props.img);
|
||||
if (url.origin !== location.origin) {
|
||||
tempImg.crossOrigin = 'anonymous';
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL,跳过跨域配置,不中断执行
|
||||
}
|
||||
}
|
||||
|
||||
// 等待临时图片加载完成
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
tempImg.removeEventListener('load', handleLoad);
|
||||
tempImg.removeEventListener('error', handleError);
|
||||
reject(new Error('图片加载超时,超时时间10秒'));
|
||||
}, 10_000);
|
||||
const handleLoad = () => {
|
||||
clearTimeout(timeout);
|
||||
tempImg.removeEventListener('load', handleLoad);
|
||||
tempImg.removeEventListener('error', handleError);
|
||||
resolve();
|
||||
};
|
||||
|
||||
const handleError = (err: ErrorEvent) => {
|
||||
clearTimeout(timeout);
|
||||
tempImg.removeEventListener('load', handleLoad);
|
||||
tempImg.removeEventListener('error', handleError);
|
||||
reject(new Error(`图片加载失败: ${err.message}`));
|
||||
};
|
||||
|
||||
tempImg.addEventListener('load', handleLoad);
|
||||
tempImg.addEventListener('error', handleError);
|
||||
tempImg.src = props.img;
|
||||
});
|
||||
|
||||
const containerRect = containerRef.value.getBoundingClientRect();
|
||||
const imgRect = bgImageRef.value.getBoundingClientRect();
|
||||
|
||||
// 1. 计算图片在容器内的渲染参数
|
||||
const containerWidth = containerRect.width;
|
||||
const containerHeight = containerRect.height;
|
||||
const renderedImgWidth = imgRect.width;
|
||||
const renderedImgHeight = imgRect.height;
|
||||
const imgOffsetX = (containerWidth - renderedImgWidth) / 2;
|
||||
const imgOffsetY = (containerHeight - renderedImgHeight) / 2;
|
||||
|
||||
// 2. 计算裁剪框在容器内的实际坐标
|
||||
const [cropTop, cropRight, cropBottom, cropLeft] = currentDimension.value;
|
||||
const cropBoxWidth = containerWidth - cropLeft - cropRight;
|
||||
const cropBoxHeight = containerHeight - cropTop - cropBottom;
|
||||
|
||||
// 3. 将裁剪框坐标转换为图片上的坐标(考虑图片偏移)
|
||||
const cropOnImgX = cropLeft - imgOffsetX;
|
||||
const cropOnImgY = cropTop - imgOffsetY;
|
||||
|
||||
// 4. 计算渲染图片到原始图片的缩放比例(保留原始像素)
|
||||
const scaleX = tempImg.width / renderedImgWidth;
|
||||
const scaleY = tempImg.height / renderedImgHeight;
|
||||
|
||||
// 5. 映射到原始图片的裁剪区域(精确到原始像素,防止越界)
|
||||
const originalCropX = Math.max(0, Math.floor(cropOnImgX * scaleX));
|
||||
const originalCropY = Math.max(0, Math.floor(cropOnImgY * scaleY));
|
||||
const originalCropWidth = Math.min(
|
||||
Math.floor(cropBoxWidth * scaleX),
|
||||
tempImg.width - originalCropX,
|
||||
);
|
||||
const originalCropHeight = Math.min(
|
||||
Math.floor(cropBoxHeight * scaleY),
|
||||
tempImg.height - originalCropY,
|
||||
);
|
||||
|
||||
// 边界校验:裁剪尺寸非法则返回
|
||||
if (originalCropWidth <= 0 || originalCropHeight <= 0) return;
|
||||
|
||||
// 6. 处理高清屏适配(解决Retina屏模糊)
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// 最终画布尺寸(优先使用传入的目标尺寸,无则用原始裁剪尺寸)
|
||||
const finalWidth = targetWidth ? Math.max(1, targetWidth) : originalCropWidth;
|
||||
const finalHeight = targetHeight
|
||||
? Math.max(1, targetHeight)
|
||||
: originalCropHeight;
|
||||
|
||||
// 创建画布并获取绘制上下文
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 画布物理尺寸(乘以设备像素比,保证高清无模糊)
|
||||
canvas.width = finalWidth * dpr;
|
||||
canvas.height = finalHeight * dpr;
|
||||
|
||||
// 画布显示尺寸(视觉尺寸,和最终展示一致)
|
||||
canvas.style.width = `${finalWidth}px`;
|
||||
canvas.style.height = `${finalHeight}px`;
|
||||
|
||||
// 缩放画布上下文,适配高清屏DPR
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// 7. 绘制裁剪后的图片(使用原始像素绘制,保证清晰度)
|
||||
ctx.drawImage(
|
||||
tempImg,
|
||||
originalCropX, // 原始图片裁剪起始X(精确像素)
|
||||
originalCropY, // 原始图片裁剪起始Y(精确像素)
|
||||
originalCropWidth, // 原始图片裁剪宽度(精确像素)
|
||||
originalCropHeight, // 原始图片裁剪高度(精确像素)
|
||||
0, // 画布绘制起始X
|
||||
0, // 画布绘制起始Y
|
||||
finalWidth, // 画布绘制宽度(目标尺寸)
|
||||
finalHeight, // 画布绘制高度(目标尺寸)
|
||||
);
|
||||
|
||||
try {
|
||||
return outputType === 'base64'
|
||||
? canvas.toDataURL(format, validQuality)
|
||||
: new Promise<Blob>((resolve) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
// 兜底:如果blob生成失败,返回空Blob(防止null)
|
||||
resolve(blob || new Blob([], { type: format }));
|
||||
},
|
||||
format,
|
||||
validQuality,
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('图片导出失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听比例变化,重新调整裁剪框
|
||||
watch(() => props.aspectRatio, adjustCropperToAspectRatio);
|
||||
|
||||
// 监听width/height变化,重新计算尺寸
|
||||
watch([() => props.width, () => props.height], () => {
|
||||
calculateImageFitSize();
|
||||
adjustCropperToAspectRatio();
|
||||
});
|
||||
|
||||
// 组件挂载时注册全局事件
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
// 如果图片已经加载完成,手动触发创建裁剪器
|
||||
if (
|
||||
bgImageRef.value &&
|
||||
bgImageRef.value.complete &&
|
||||
bgImageRef.value.naturalWidth > 0
|
||||
) {
|
||||
createCropper();
|
||||
}
|
||||
});
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
});
|
||||
|
||||
defineExpose({ getCropImage });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:style="{
|
||||
width: `${width || CROPPER_CONSTANTS.DEFAULT_WIDTH}px`,
|
||||
height: `${height || CROPPER_CONSTANTS.DEFAULT_HEIGHT}px`,
|
||||
}"
|
||||
class="cropper-action-wrapper"
|
||||
>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="cropper-container"
|
||||
:style="{
|
||||
width: `${containerWidth}px`,
|
||||
height: `${containerHeight}px`,
|
||||
}"
|
||||
>
|
||||
<!-- 原图展示 - 自适应尺寸 -->
|
||||
<img
|
||||
ref="bgImageRef"
|
||||
class="cropper-image"
|
||||
:src="img"
|
||||
@load="handleImageLoad"
|
||||
:style="{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}"
|
||||
alt="裁剪原图"
|
||||
/>
|
||||
|
||||
<!-- 遮罩层 -->
|
||||
<div
|
||||
class="cropper-mask"
|
||||
:style="{
|
||||
display: isCropperVisible ? 'block' : 'none',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
ref="maskViewRef"
|
||||
class="cropper-mask-view"
|
||||
:style="{
|
||||
backgroundImage: `url(${img})`,
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
clipPath: `inset(${currentDimension[0]}px ${currentDimension[1]}px ${currentDimension[2]}px ${currentDimension[3]}px)`,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 裁剪框 -->
|
||||
<div
|
||||
ref="cropperRef"
|
||||
class="cropper-box"
|
||||
:style="{
|
||||
display: isCropperVisible ? 'block' : 'none',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="cropper-view"
|
||||
:style="{
|
||||
inset: `${currentDimension[0]}px ${currentDimension[1]}px ${currentDimension[2]}px ${currentDimension[3]}px`,
|
||||
}"
|
||||
>
|
||||
<!-- 裁剪框辅助线-->
|
||||
<span class="cropper-dashed-h"></span>
|
||||
<span class="cropper-dashed-v"></span>
|
||||
|
||||
<!-- 裁剪框拖拽区域 -->
|
||||
<span
|
||||
class="cropper-move-area"
|
||||
@mousedown="handleMouseDown($event, 'move')"
|
||||
></span>
|
||||
|
||||
<!-- 边框线 -->
|
||||
<span class="cropper-line-e"></span>
|
||||
<span class="cropper-line-n"></span>
|
||||
<span class="cropper-line-w"></span>
|
||||
<span class="cropper-line-s"></span>
|
||||
|
||||
<!-- 边角拖拽点 -->
|
||||
<span
|
||||
class="cropper-point cropper-point-ne"
|
||||
@mousedown="handleMouseDown($event, 'top-right')"
|
||||
>
|
||||
<span class="cropper-point-inner"></span>
|
||||
</span>
|
||||
<span
|
||||
class="cropper-point cropper-point-nw"
|
||||
@mousedown="handleMouseDown($event, 'top-left')"
|
||||
>
|
||||
<span class="cropper-point-inner"></span>
|
||||
</span>
|
||||
<span
|
||||
class="cropper-point cropper-point-sw"
|
||||
@mousedown="handleMouseDown($event, 'bottom-left')"
|
||||
>
|
||||
<span class="cropper-point-inner"></span>
|
||||
</span>
|
||||
<span
|
||||
class="cropper-point cropper-point-se"
|
||||
@mousedown="handleMouseDown($event, 'bottom-right')"
|
||||
>
|
||||
<span class="cropper-point-inner"></span>
|
||||
</span>
|
||||
|
||||
<!-- 边中点拖拽点 -->
|
||||
<span
|
||||
class="cropper-point cropper-point-e"
|
||||
@mousedown="handleMouseDown($event, 'right')"
|
||||
>
|
||||
<span class="cropper-point-inner"></span>
|
||||
</span>
|
||||
<span
|
||||
class="cropper-point cropper-point-n"
|
||||
@mousedown="handleMouseDown($event, 'top')"
|
||||
>
|
||||
<span class="cropper-point-inner"></span>
|
||||
</span>
|
||||
<span
|
||||
class="cropper-point cropper-point-w"
|
||||
@mousedown="handleMouseDown($event, 'left')"
|
||||
>
|
||||
<span class="cropper-point-inner"></span>
|
||||
</span>
|
||||
<span
|
||||
class="cropper-point cropper-point-s"
|
||||
@mousedown="handleMouseDown($event, 'bottom')"
|
||||
>
|
||||
<span class="cropper-point-inner"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@reference "@vben/tailwind-config/theme";
|
||||
|
||||
.cropper-action-wrapper {
|
||||
@apply box-border flex items-center justify-center;
|
||||
|
||||
background-color: transparent;
|
||||
|
||||
/* 马赛克背景 */
|
||||
background-image:
|
||||
linear-gradient(45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(-45deg, #ccc 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, #ccc 75%),
|
||||
linear-gradient(-45deg, transparent 75%, #ccc 75%);
|
||||
background-position:
|
||||
0 0,
|
||||
0 10px,
|
||||
10px -10px,
|
||||
-10px 0;
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.cropper-container {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.cropper-image {
|
||||
@apply block;
|
||||
}
|
||||
|
||||
/* 遮罩层 */
|
||||
.cropper-mask {
|
||||
@apply absolute top-0 left-0 bg-black/50;
|
||||
}
|
||||
|
||||
.cropper-mask-view {
|
||||
@apply absolute top-0 left-0;
|
||||
}
|
||||
|
||||
/* 裁剪框 */
|
||||
.cropper-box {
|
||||
@apply absolute top-0 left-0 z-10;
|
||||
}
|
||||
|
||||
.cropper-view {
|
||||
@apply absolute top-0 right-0 bottom-0 left-0 outline-1 outline-blue-500 select-none;
|
||||
}
|
||||
|
||||
/* 裁剪框辅助线 */
|
||||
.cropper-dashed-h {
|
||||
@apply absolute top-1/3 left-0 block h-1/3 w-full border-t border-b border-dashed border-gray-200/50;
|
||||
}
|
||||
|
||||
.cropper-dashed-v {
|
||||
@apply absolute top-0 left-1/3 block h-full w-1/3 border-r border-l border-dashed border-gray-200/50;
|
||||
}
|
||||
|
||||
/* 裁剪框拖拽区域 */
|
||||
.cropper-move-area {
|
||||
@apply absolute top-0 left-0 block h-full w-full cursor-move bg-white/10;
|
||||
}
|
||||
|
||||
/* 边框拖拽线 */
|
||||
.cropper-line-e,
|
||||
.cropper-line-n,
|
||||
.cropper-line-w,
|
||||
.cropper-line-s {
|
||||
@apply absolute block bg-blue-500/10;
|
||||
}
|
||||
|
||||
.cropper-line-e {
|
||||
@apply top-0 -right-0.75 h-full w-1;
|
||||
}
|
||||
|
||||
.cropper-line-n {
|
||||
@apply -top-0.75 left-0 h-1 w-full;
|
||||
}
|
||||
|
||||
.cropper-line-w {
|
||||
@apply top-0 -left-0.75 h-full w-1;
|
||||
}
|
||||
|
||||
.cropper-line-s {
|
||||
@apply -bottom-0.75 left-0 h-1 w-full;
|
||||
}
|
||||
|
||||
/* 拖拽点 */
|
||||
.cropper-point {
|
||||
@apply absolute flex h-2 w-2 items-center justify-center bg-blue-500;
|
||||
}
|
||||
|
||||
.cropper-point-inner {
|
||||
@apply block h-1.5 w-1.5 bg-white;
|
||||
}
|
||||
|
||||
/* 边角拖拽点位置和光标 */
|
||||
.cropper-point-ne {
|
||||
@apply -top-1.25 -right-1.25 cursor-ne-resize;
|
||||
}
|
||||
|
||||
.cropper-point-nw {
|
||||
@apply -top-1.25 -left-1.25 cursor-nw-resize;
|
||||
}
|
||||
|
||||
.cropper-point-sw {
|
||||
@apply -bottom-1.25 -left-1.25 cursor-sw-resize;
|
||||
}
|
||||
|
||||
.cropper-point-se {
|
||||
@apply -right-1.25 -bottom-1.25 cursor-se-resize;
|
||||
}
|
||||
|
||||
/* 边中点拖拽点位置和光标 */
|
||||
.cropper-point-e {
|
||||
@apply top-1/2 -right-1.25 -mt-1 cursor-e-resize;
|
||||
}
|
||||
|
||||
.cropper-point-n {
|
||||
@apply -top-1.25 left-1/2 -ml-1 cursor-n-resize;
|
||||
}
|
||||
|
||||
.cropper-point-w {
|
||||
@apply top-1/2 -left-1.25 -mt-1 cursor-w-resize;
|
||||
}
|
||||
|
||||
.cropper-point-s {
|
||||
@apply -bottom-1.25 left-1/2 -ml-1 cursor-s-resize;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as VCropper } from './cropper.vue';
|
||||
@@ -1,8 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, type CSSProperties, ref, watchEffect } from 'vue';
|
||||
import type { CSSProperties } from 'vue';
|
||||
|
||||
import {
|
||||
computed,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
onUpdated,
|
||||
ref,
|
||||
watchEffect,
|
||||
} from 'vue';
|
||||
|
||||
import { VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 是否启用点击文本展开全部
|
||||
@@ -29,6 +40,16 @@ interface Props {
|
||||
* @default true
|
||||
*/
|
||||
tooltip?: boolean;
|
||||
/**
|
||||
* 是否只在文本被截断时显示提示框
|
||||
* @default false
|
||||
*/
|
||||
tooltipWhenEllipsis?: boolean;
|
||||
/**
|
||||
* 文本截断检测的像素差异阈值,越大则判断越严格
|
||||
* @default 3
|
||||
*/
|
||||
ellipsisThreshold?: number;
|
||||
/**
|
||||
* 提示框背景颜色,优先级高于 overlayStyle
|
||||
*/
|
||||
@@ -58,12 +79,15 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
maxWidth: '100%',
|
||||
placement: 'top',
|
||||
tooltip: true,
|
||||
tooltipWhenEllipsis: false,
|
||||
ellipsisThreshold: 3,
|
||||
tooltipBackgroundColor: '',
|
||||
tooltipColor: '',
|
||||
tooltipFontSize: 14,
|
||||
tooltipMaxWidth: undefined,
|
||||
tooltipOverlayStyle: () => ({ textAlign: 'justify' }),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ expandChange: [boolean] }>();
|
||||
|
||||
const textMaxWidth = computed(() => {
|
||||
@@ -75,19 +99,83 @@ const textMaxWidth = computed(() => {
|
||||
const ellipsis = ref();
|
||||
const isExpand = ref(false);
|
||||
const defaultTooltipMaxWidth = ref();
|
||||
const isEllipsis = ref(false);
|
||||
|
||||
const { width: eleWidth } = useElementSize(ellipsis);
|
||||
|
||||
// 检测文本是否被截断
|
||||
const checkEllipsis = () => {
|
||||
if (!ellipsis.value || !props.tooltipWhenEllipsis) return;
|
||||
|
||||
const element = ellipsis.value;
|
||||
|
||||
const originalText = element.textContent || '';
|
||||
const originalTrimmed = originalText.trim();
|
||||
|
||||
// 对于空文本直接返回 false
|
||||
if (!originalTrimmed) {
|
||||
isEllipsis.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const widthDiff = element.scrollWidth - element.clientWidth;
|
||||
const heightDiff = element.scrollHeight - element.clientHeight;
|
||||
|
||||
// 使用足够大的差异阈值确保只有真正被截断的文本才会显示 tooltip
|
||||
isEllipsis.value =
|
||||
props.line === 1
|
||||
? widthDiff > props.ellipsisThreshold
|
||||
: heightDiff > props.ellipsisThreshold;
|
||||
};
|
||||
|
||||
// 使用 ResizeObserver 监听尺寸变化
|
||||
let resizeObserver: null | ResizeObserver = null;
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof ResizeObserver !== 'undefined' && props.tooltipWhenEllipsis) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
checkEllipsis();
|
||||
});
|
||||
|
||||
if (ellipsis.value) {
|
||||
resizeObserver.observe(ellipsis.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始检测
|
||||
checkEllipsis();
|
||||
});
|
||||
|
||||
// 使用onUpdated钩子检测内容变化
|
||||
onUpdated(() => {
|
||||
if (props.tooltipWhenEllipsis) {
|
||||
checkEllipsis();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (props.tooltip && ellipsis.value) {
|
||||
if (props.tooltip && eleWidth.value) {
|
||||
defaultTooltipMaxWidth.value =
|
||||
props.tooltipMaxWidth ?? ellipsis.value.offsetWidth + 24;
|
||||
props.tooltipMaxWidth ?? eleWidth.value + 24;
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
);
|
||||
|
||||
function onExpand() {
|
||||
isExpand.value = !isExpand.value;
|
||||
emit('expandChange', isExpand.value);
|
||||
if (props.tooltipWhenEllipsis) {
|
||||
checkEllipsis();
|
||||
}
|
||||
}
|
||||
|
||||
function handleExpand() {
|
||||
@@ -104,7 +192,9 @@ function handleExpand() {
|
||||
color: tooltipColor,
|
||||
backgroundColor: tooltipBackgroundColor,
|
||||
}"
|
||||
:disabled="!props.tooltip || isExpand"
|
||||
:disabled="
|
||||
!props.tooltip || isExpand || (props.tooltipWhenEllipsis && !isEllipsis)
|
||||
"
|
||||
:side="placement"
|
||||
>
|
||||
<slot name="tooltip">
|
||||
@@ -115,7 +205,7 @@ function handleExpand() {
|
||||
<div
|
||||
ref="ellipsis"
|
||||
:class="{
|
||||
'!cursor-pointer': expand,
|
||||
'cursor-pointer!': expand,
|
||||
['block truncate']: line === 1,
|
||||
[$style.ellipsisMultiLine]: line > 1,
|
||||
}"
|
||||
|
||||
@@ -1,67 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref, type VNode, watch, watchEffect } from 'vue';
|
||||
import type { IconPickerProps } from './types';
|
||||
|
||||
import { computed, ref, useAttrs, watch, watchEffect } from 'vue';
|
||||
|
||||
import { usePagination } from '@vben/hooks';
|
||||
import { EmptyIcon, Grip, listIcons } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationItem,
|
||||
PaginationLast,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
PaginationPrevious,
|
||||
VbenIcon,
|
||||
VbenIconButton,
|
||||
VbenPopover,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { isFunction } from '@vben-core/shared/utils';
|
||||
|
||||
import { refDebounced } from '@vueuse/core';
|
||||
import { objectOmit, refDebounced, watchDebounced } from '@vueuse/core';
|
||||
|
||||
interface Props {
|
||||
pageSize?: number;
|
||||
prefix?: string;
|
||||
/**
|
||||
* 图标列表
|
||||
*/
|
||||
icons?: string[];
|
||||
/** Input组件 */
|
||||
inputComponent?: VNode;
|
||||
/** 图标插槽名,预览图标将被渲染到此插槽中 */
|
||||
iconSlot?: string;
|
||||
/** input组件的值属性名称 */
|
||||
modelValueProp?: string;
|
||||
/** 图标样式 */
|
||||
iconClass?: string;
|
||||
}
|
||||
import { fetchIconsData } from './icons';
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<IconPickerProps>(), {
|
||||
prefix: 'ant-design',
|
||||
pageSize: 36,
|
||||
icons: () => [],
|
||||
inputComponent: () => h('div'),
|
||||
iconSlot: 'default',
|
||||
iconClass: 'size-4',
|
||||
modelValueProp: 'value',
|
||||
autoFetchApi: true,
|
||||
modelValueProp: 'modelValue',
|
||||
inputComponent: undefined,
|
||||
type: 'input',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [string];
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const modelValue = defineModel({ default: '', type: String });
|
||||
|
||||
const visible = ref(false);
|
||||
const currentSelect = ref('');
|
||||
const currentPage = ref(1);
|
||||
const keyword = ref('');
|
||||
const keywordDebounce = refDebounced(keyword, 300);
|
||||
const innerIcons = ref<string[]>([]);
|
||||
|
||||
watchDebounced(
|
||||
() => props.prefix,
|
||||
async (prefix) => {
|
||||
if (prefix && prefix !== 'svg' && props.autoFetchApi) {
|
||||
innerIcons.value = await fetchIconsData(prefix);
|
||||
}
|
||||
},
|
||||
{ immediate: true, debounce: 500, maxWait: 1000 },
|
||||
);
|
||||
|
||||
const currentList = computed(() => {
|
||||
try {
|
||||
if (props.prefix) {
|
||||
if (
|
||||
props.prefix !== 'svg' &&
|
||||
props.autoFetchApi &&
|
||||
props.icons.length === 0
|
||||
) {
|
||||
return innerIcons.value;
|
||||
}
|
||||
const icons = listIcons('', props.prefix);
|
||||
if (icons.length === 0) {
|
||||
console.warn(`No icons found for prefix: ${props.prefix}`);
|
||||
@@ -82,7 +94,7 @@ const showList = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const { paginationList, total, setCurrentPage } = usePagination(
|
||||
const { paginationList, total, setCurrentPage, currentPage } = usePagination(
|
||||
showList,
|
||||
props.pageSize,
|
||||
);
|
||||
@@ -105,7 +117,6 @@ const handleClick = (icon: string) => {
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page;
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
@@ -134,31 +145,90 @@ const searchInputProps = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
function updateCurrentSelect(v: string) {
|
||||
currentSelect.value = v;
|
||||
if (props.modelValueProp === 'modelValue') {
|
||||
modelValue.value = v;
|
||||
}
|
||||
const eventKey = `onUpdate:${props.modelValueProp}`;
|
||||
if (attrs[eventKey] && isFunction(attrs[eventKey])) {
|
||||
attrs[eventKey](v);
|
||||
}
|
||||
}
|
||||
const getBindAttrs = computed(() => {
|
||||
return objectOmit(attrs, [`onUpdate:${props.modelValueProp}`]);
|
||||
});
|
||||
|
||||
defineExpose({ toggleOpenState, open, close });
|
||||
</script>
|
||||
<template>
|
||||
<VbenPopover
|
||||
v-model:open="visible"
|
||||
:content-props="{ align: 'end', alignOffset: -11, sideOffset: 8 }"
|
||||
content-class="p-0 pt-3"
|
||||
content-class="p-0 pt-3 w-full"
|
||||
trigger-class="w-full"
|
||||
>
|
||||
<template #trigger>
|
||||
<component
|
||||
:is="inputComponent"
|
||||
:[modelValueProp]="currentSelect"
|
||||
:placeholder="$t('ui.iconPicker.placeholder')"
|
||||
>
|
||||
<template #[iconSlot]>
|
||||
<VbenIcon :icon="currentSelect || Grip" class="size-4" />
|
||||
</template>
|
||||
</component>
|
||||
<template v-if="props.type === 'input'">
|
||||
<component
|
||||
v-if="props.inputComponent"
|
||||
:is="inputComponent"
|
||||
:[modelValueProp]="currentSelect"
|
||||
:placeholder="$t('ui.iconPicker.placeholder')"
|
||||
role="combobox"
|
||||
:aria-label="$t('ui.iconPicker.placeholder')"
|
||||
aria-expanded="visible"
|
||||
:[`onUpdate:${modelValueProp}`]="updateCurrentSelect"
|
||||
v-bind="getBindAttrs"
|
||||
>
|
||||
<template #[iconSlot]>
|
||||
<VbenIcon
|
||||
:icon="currentSelect || Grip"
|
||||
class="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
</component>
|
||||
<div class="relative w-full" v-else>
|
||||
<Input
|
||||
v-bind="$attrs"
|
||||
v-model="currentSelect"
|
||||
:placeholder="$t('ui.iconPicker.placeholder')"
|
||||
class="h-8 w-full pr-8"
|
||||
role="combobox"
|
||||
:aria-label="$t('ui.iconPicker.placeholder')"
|
||||
aria-expanded="visible"
|
||||
/>
|
||||
<VbenIcon
|
||||
:icon="currentSelect || Grip"
|
||||
class="absolute top-1 right-1 size-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<VbenIcon
|
||||
:icon="currentSelect || Grip"
|
||||
v-else
|
||||
class="size-4"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</template>
|
||||
<div class="mb-2 flex w-full">
|
||||
<component :is="inputComponent" v-bind="searchInputProps" />
|
||||
<component
|
||||
v-if="inputComponent"
|
||||
:is="inputComponent"
|
||||
v-bind="searchInputProps"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
class="mx-2 h-8 w-full"
|
||||
:placeholder="$t('ui.iconPicker.search')"
|
||||
v-model="keyword"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="paginationList.length > 0">
|
||||
<div class="grid max-h-[360px] w-full grid-cols-6 justify-items-center">
|
||||
<div class="grid max-h-90 w-full grid-cols-6 justify-items-center">
|
||||
<VbenIconButton
|
||||
v-for="(item, index) in paginationList"
|
||||
:key="index"
|
||||
@@ -186,14 +256,14 @@ defineExpose({ toggleOpenState, open, close });
|
||||
size="small"
|
||||
@update:page="handlePageChange"
|
||||
>
|
||||
<PaginationList
|
||||
<PaginationContent
|
||||
v-slot="{ items }"
|
||||
class="flex w-full items-center gap-1"
|
||||
>
|
||||
<PaginationFirst class="size-5" />
|
||||
<PaginationPrev class="size-5" />
|
||||
<PaginationPrevious class="size-5" />
|
||||
<template v-for="(item, index) in items">
|
||||
<PaginationListItem
|
||||
<PaginationItem
|
||||
v-if="item.type === 'page'"
|
||||
:key="index"
|
||||
:value="item.value"
|
||||
@@ -205,7 +275,7 @@ defineExpose({ toggleOpenState, open, close });
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis
|
||||
v-else
|
||||
:key="item.type"
|
||||
@@ -215,13 +285,13 @@ defineExpose({ toggleOpenState, open, close });
|
||||
</template>
|
||||
<PaginationNext class="size-5" />
|
||||
<PaginationLast class="size-5" />
|
||||
</PaginationList>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex-col-center text-muted-foreground min-h-[150px] w-full">
|
||||
<div class="flex-col-center min-h-37.5 w-full text-muted-foreground">
|
||||
<EmptyIcon class="size-10" />
|
||||
<div class="mt-1 text-sm">{{ $t('common.noData') }}</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
/**
|
||||
* 一个缓存对象,在不刷新页面时,无需重复请求远程接口
|
||||
*/
|
||||
export const ICONS_MAP: Recordable<string[]> = {};
|
||||
|
||||
interface IconifyResponse {
|
||||
prefix: string;
|
||||
total: number;
|
||||
title: string;
|
||||
uncategorized?: string[];
|
||||
categories?: Recordable<string[]>;
|
||||
aliases?: Recordable<string>;
|
||||
}
|
||||
|
||||
const PENDING_REQUESTS: Recordable<Promise<string[]>> = {};
|
||||
|
||||
/**
|
||||
* 通过Iconify接口获取图标集数据。
|
||||
* 同一时间多个图标选择器同时请求同一个图标集时,实际上只会发起一次请求(所有请求共享同一份结果)。
|
||||
* 请求结果会被缓存,刷新页面前同一个图标集不会再次请求
|
||||
* @param prefix 图标集名称
|
||||
* @returns 图标集中包含的所有图标名称
|
||||
*/
|
||||
export async function fetchIconsData(prefix: string): Promise<string[]> {
|
||||
if (Reflect.has(ICONS_MAP, prefix) && ICONS_MAP[prefix]) {
|
||||
return ICONS_MAP[prefix];
|
||||
}
|
||||
if (Reflect.has(PENDING_REQUESTS, prefix) && PENDING_REQUESTS[prefix]) {
|
||||
return PENDING_REQUESTS[prefix];
|
||||
}
|
||||
PENDING_REQUESTS[prefix] = (async () => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1000 * 10);
|
||||
const response: IconifyResponse = await fetch(
|
||||
`https://api.iconify.design/collection?prefix=${prefix}`,
|
||||
{ signal: controller.signal },
|
||||
).then((res) => res.json());
|
||||
clearTimeout(timeoutId);
|
||||
const list = response.uncategorized || [];
|
||||
if (response.categories) {
|
||||
for (const category in response.categories) {
|
||||
list.push(...(response.categories[category] || []));
|
||||
}
|
||||
}
|
||||
ICONS_MAP[prefix] = list.map((v) => `${prefix}:${v}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch icons for prefix ${prefix}:`, error);
|
||||
return [] as string[];
|
||||
}
|
||||
return ICONS_MAP[prefix];
|
||||
})();
|
||||
return PENDING_REQUESTS[prefix];
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export { default as IconPicker } from './icon-picker.vue';
|
||||
export type { IconPickerProps } from './types';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { VNode } from 'vue';
|
||||
|
||||
export interface IconPickerProps {
|
||||
pageSize?: number;
|
||||
/** 图标集的名字 */
|
||||
prefix?: string;
|
||||
/** 是否自动请求API以获得图标集的数据.提供prefix时有效 */
|
||||
autoFetchApi?: boolean;
|
||||
/**
|
||||
* 图标列表
|
||||
*/
|
||||
icons?: string[];
|
||||
/** Input组件 */
|
||||
inputComponent?: VNode;
|
||||
/** 图标插槽名,预览图标将被渲染到此插槽中 */
|
||||
iconSlot?: string;
|
||||
/** input组件的值属性名称 */
|
||||
modelValueProp?: string;
|
||||
/** 图标样式 */
|
||||
iconClass?: string;
|
||||
type?: 'icon' | 'input';
|
||||
}
|
||||
@@ -1,22 +1,52 @@
|
||||
export * from './api-component';
|
||||
export * from './captcha';
|
||||
export * from './col-page';
|
||||
export * from './count-to';
|
||||
export * from './cropper';
|
||||
export * from './ellipsis-text';
|
||||
export * from './icon-picker';
|
||||
export * from './json-viewer';
|
||||
export * from './loading';
|
||||
export * from './page';
|
||||
export * from './resize';
|
||||
export * from './tippy';
|
||||
export * from './tree';
|
||||
export * from '@vben-core/form-ui';
|
||||
export * from '@vben-core/popup-ui';
|
||||
|
||||
// 给文档用
|
||||
export {
|
||||
VbenAvatar,
|
||||
VbenButton,
|
||||
VbenButtonGroup,
|
||||
VbenCheckbox,
|
||||
VbenCheckButtonGroup,
|
||||
VbenCollapsibleParams,
|
||||
VbenContextMenu,
|
||||
VbenCountToAnimator,
|
||||
VbenIcon,
|
||||
VbenDescriptions,
|
||||
VbenDescriptionsItem,
|
||||
VbenFullScreen,
|
||||
VbenIconButton,
|
||||
VbenInputPassword,
|
||||
VbenLoading,
|
||||
VbenLogo,
|
||||
VbenPinInput,
|
||||
VbenSelect,
|
||||
VbenSpinner,
|
||||
VbenTableAction,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
export type {
|
||||
ActionItem,
|
||||
CollapsibleParamSchema,
|
||||
CollapsibleParamsProps,
|
||||
DescriptionsColumn,
|
||||
DescriptionsItemType,
|
||||
DescriptionsProps,
|
||||
DescriptionsSize,
|
||||
FlattenedItem,
|
||||
TableActionProps,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
export { globalShareState } from '@vben-core/shared/global-state';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as JsonViewer } from './index.vue';
|
||||
|
||||
export * from './types';
|
||||
128
packages/effects/common-ui/src/components/json-viewer/index.vue
Normal file
128
packages/effects/common-ui/src/components/json-viewer/index.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SetupContext } from 'vue';
|
||||
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type {
|
||||
JsonViewerAction,
|
||||
JsonViewerProps,
|
||||
JsonViewerToggle,
|
||||
JsonViewerValue,
|
||||
} from './types';
|
||||
|
||||
import { computed, ref, useAttrs } from 'vue';
|
||||
import VueJsonPretty from 'vue-json-pretty';
|
||||
import 'vue-json-pretty/lib/styles.css';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import JsonBigint from 'json-bigint';
|
||||
|
||||
defineOptions({ name: 'JsonViewer' });
|
||||
|
||||
const props = withDefaults(defineProps<JsonViewerProps>(), {
|
||||
expandDepth: 1,
|
||||
copyable: false,
|
||||
sort: false,
|
||||
boxed: false,
|
||||
theme: 'default-json-theme',
|
||||
expanded: false,
|
||||
previewMode: false,
|
||||
showArrayIndex: true,
|
||||
showDoubleQuotes: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [event: MouseEvent];
|
||||
copied: [event: JsonViewerAction];
|
||||
keyClick: [key: string];
|
||||
toggle: [param: JsonViewerToggle];
|
||||
valueClick: [value: JsonViewerValue];
|
||||
}>();
|
||||
|
||||
const attrs: SetupContext['attrs'] = useAttrs();
|
||||
|
||||
const copiedPath = ref<null | string>(null);
|
||||
|
||||
const copyConfig = computed(() => {
|
||||
return {
|
||||
copiedText: $t('ui.jsonViewer.copied'),
|
||||
copyText: $t('ui.jsonViewer.copy'),
|
||||
timeout: 2000,
|
||||
};
|
||||
});
|
||||
|
||||
function handleCopy(node: any, defaultCopy: () => void) {
|
||||
defaultCopy();
|
||||
copiedPath.value = node.path;
|
||||
emit('copied', {
|
||||
action: 'copy',
|
||||
text: JSON.stringify(node.content),
|
||||
trigger: node.el ?? document.body,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (copiedPath.value === node.path) {
|
||||
copiedPath.value = null;
|
||||
}
|
||||
}, copyConfig.value.timeout ?? 2000);
|
||||
}
|
||||
|
||||
// 支持显示 bigint 数据,如较长的订单号
|
||||
const jsonData = computed<Record<string, any>>(() => {
|
||||
if (typeof props.value !== 'string') {
|
||||
return props.value || {};
|
||||
}
|
||||
|
||||
try {
|
||||
return JsonBigint({ storeAsString: true }).parse(props.value);
|
||||
} catch (error) {
|
||||
console.error('JSON parse error:', error);
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const bindProps = computed<Recordable<any>>(() => {
|
||||
const prettyTheme =
|
||||
props.theme === 'dark' || props.theme === 'dark-json-theme'
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
return {
|
||||
...attrs,
|
||||
data: jsonData.value,
|
||||
deep: props.expanded ? Infinity : props.expandDepth,
|
||||
showDoubleQuotes: props.showDoubleQuotes,
|
||||
showLine: props.boxed,
|
||||
showLength: true,
|
||||
showIcon: true,
|
||||
theme: prettyTheme,
|
||||
collapsedNodeLength: props.previewMode ? 0 : Infinity,
|
||||
renderNodeActions: !!props.copyable,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div :class="[props.theme, { boxed: props.boxed }]" class="vben-json-viewer">
|
||||
<VueJsonPretty v-bind="bindProps">
|
||||
<template #renderNodeActions="{ node, defaultActions }">
|
||||
<slot name="copy" :node="node" :default-actions="defaultActions">
|
||||
<span
|
||||
v-if="props.copyable"
|
||||
class="vben-json-copy-btn"
|
||||
:class="[{ 'is-copied': copiedPath === node.path }]"
|
||||
@click.stop="handleCopy(node, defaultActions.copy)"
|
||||
>
|
||||
{{
|
||||
copiedPath === node.path
|
||||
? copyConfig.copiedText
|
||||
: copyConfig.copyText
|
||||
}}
|
||||
</span>
|
||||
</slot>
|
||||
</template>
|
||||
</VueJsonPretty>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss">
|
||||
@use './style.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,91 @@
|
||||
.vben-json-viewer {
|
||||
font-family: Consolas, Menlo, Courier, monospace;
|
||||
font-size: 14px;
|
||||
color: hsl(var(--foreground));
|
||||
background: hsl(var(--background));
|
||||
|
||||
&.boxed {
|
||||
padding: 10px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.vjs-key {
|
||||
color: hsl(var(--heavy-foreground));
|
||||
}
|
||||
|
||||
.vjs-value-null,
|
||||
.vjs-value-undefined {
|
||||
color: hsl(var(--secondary-foreground));
|
||||
}
|
||||
|
||||
.vjs-value-boolean {
|
||||
color: hsl(var(--red-400));
|
||||
}
|
||||
|
||||
.vjs-value-number {
|
||||
color: hsl(var(--info-foreground));
|
||||
}
|
||||
|
||||
.vjs-value-string {
|
||||
color: hsl(var(--primary));
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.vjs-tree-brackets {
|
||||
color: hsl(var(--heavy-foreground));
|
||||
}
|
||||
|
||||
.vjs-comment {
|
||||
color: hsl(var(--secondary-foreground));
|
||||
}
|
||||
|
||||
.vjs-tree-node {
|
||||
&.is-highlight {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-carets {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-indent-unit {
|
||||
&.has-line {
|
||||
border-left: 1px solid hsl(var(--border));
|
||||
}
|
||||
}
|
||||
|
||||
.vben-json-copy-btn {
|
||||
display: inline-block;
|
||||
padding: 0 6px;
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background-color: hsl(var(--secondary));
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
&.is-copied {
|
||||
color: hsl(var(--success-foreground, var(--primary)));
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-tree-node:hover .vben-json-copy-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface JsonViewerProps {
|
||||
/** 要展示的结构数据 */
|
||||
value: any;
|
||||
/** 展开深度 */
|
||||
expandDepth?: number;
|
||||
/** 是否可复制 */
|
||||
copyable?: boolean;
|
||||
/** 是否排序 */
|
||||
sort?: boolean;
|
||||
/** 显示边框 */
|
||||
boxed?: boolean;
|
||||
/** 主题 */
|
||||
theme?: string;
|
||||
/** 是否展开 */
|
||||
expanded?: boolean;
|
||||
/** 时间格式化函数 */
|
||||
timeformat?: (time: Date | number | string) => string;
|
||||
/** 预览模式 */
|
||||
previewMode?: boolean;
|
||||
/** 显示数组索引 */
|
||||
showArrayIndex?: boolean;
|
||||
/** 显示双引号 */
|
||||
showDoubleQuotes?: boolean;
|
||||
}
|
||||
|
||||
export interface JsonViewerAction {
|
||||
action: string;
|
||||
text: string;
|
||||
trigger: HTMLElement;
|
||||
}
|
||||
|
||||
export interface JsonViewerValue {
|
||||
value: any;
|
||||
path: string;
|
||||
depth: number;
|
||||
el: HTMLElement;
|
||||
}
|
||||
|
||||
export interface JsonViewerToggle {
|
||||
/** 鼠标事件 */
|
||||
event: MouseEvent;
|
||||
/** 当前展开状态 */
|
||||
open: boolean;
|
||||
}
|
||||
132
packages/effects/common-ui/src/components/loading/directive.ts
Normal file
132
packages/effects/common-ui/src/components/loading/directive.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { App, Directive, DirectiveBinding } from 'vue';
|
||||
|
||||
import { h, render } from 'vue';
|
||||
|
||||
import { VbenLoading, VbenSpinner } from '@vben-core/shadcn-ui';
|
||||
import { isString } from '@vben-core/shared/utils';
|
||||
|
||||
const LOADING_INSTANCE_KEY = Symbol('loading');
|
||||
const SPINNER_INSTANCE_KEY = Symbol('spinner');
|
||||
|
||||
const CLASS_NAME_RELATIVE = 'spinner-parent--relative';
|
||||
|
||||
const loadingDirective: Directive = {
|
||||
mounted(el, binding) {
|
||||
const instance = h(VbenLoading, getOptions(binding));
|
||||
render(instance, el);
|
||||
|
||||
el.classList.add(CLASS_NAME_RELATIVE);
|
||||
el[LOADING_INSTANCE_KEY] = instance;
|
||||
},
|
||||
unmounted(el) {
|
||||
const instance = el[LOADING_INSTANCE_KEY];
|
||||
el.classList.remove(CLASS_NAME_RELATIVE);
|
||||
render(null, el);
|
||||
instance.el.remove();
|
||||
|
||||
el[LOADING_INSTANCE_KEY] = null;
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const instance = el[LOADING_INSTANCE_KEY];
|
||||
const options = getOptions(binding);
|
||||
if (options && instance?.component) {
|
||||
try {
|
||||
Object.keys(options).forEach((key) => {
|
||||
instance.component.props[key] = options[key];
|
||||
});
|
||||
instance.component.update();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to update loading component in directive:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function getOptions(binding: DirectiveBinding) {
|
||||
if (binding.value === undefined) {
|
||||
return { spinning: true };
|
||||
} else if (typeof binding.value === 'boolean') {
|
||||
return { spinning: binding.value };
|
||||
} else {
|
||||
return { ...binding.value };
|
||||
}
|
||||
}
|
||||
|
||||
const spinningDirective: Directive = {
|
||||
mounted(el, binding) {
|
||||
const instance = h(VbenSpinner, getOptions(binding));
|
||||
render(instance, el);
|
||||
|
||||
el.classList.add(CLASS_NAME_RELATIVE);
|
||||
el[SPINNER_INSTANCE_KEY] = instance;
|
||||
},
|
||||
unmounted(el) {
|
||||
const instance = el[SPINNER_INSTANCE_KEY];
|
||||
el.classList.remove(CLASS_NAME_RELATIVE);
|
||||
render(null, el);
|
||||
instance.el.remove();
|
||||
|
||||
el[SPINNER_INSTANCE_KEY] = null;
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const instance = el[SPINNER_INSTANCE_KEY];
|
||||
const options = getOptions(binding);
|
||||
if (options && instance?.component) {
|
||||
try {
|
||||
Object.keys(options).forEach((key) => {
|
||||
instance.component.props[key] = options[key];
|
||||
});
|
||||
instance.component.update();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to update spinner component in directive:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
type loadingDirectiveParams = {
|
||||
/** 是否注册loading指令。如果提供一个string,则将指令注册为指定的名称 */
|
||||
loading?: boolean | string;
|
||||
/** 是否注册spinning指令。如果提供一个string,则将指令注册为指定的名称 */
|
||||
spinning?: boolean | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 注册loading指令
|
||||
* @param app
|
||||
* @param params
|
||||
*/
|
||||
export function registerLoadingDirective(
|
||||
app: App,
|
||||
params?: loadingDirectiveParams,
|
||||
) {
|
||||
// 注入一个样式供指令使用,确保容器是相对定位
|
||||
const style = document.createElement('style');
|
||||
style.id = CLASS_NAME_RELATIVE;
|
||||
style.innerHTML = `
|
||||
.${CLASS_NAME_RELATIVE} {
|
||||
position: relative !important;
|
||||
}
|
||||
`;
|
||||
document.head.append(style);
|
||||
if (params?.loading !== false) {
|
||||
app.directive(
|
||||
isString(params?.loading) ? params.loading : 'loading',
|
||||
loadingDirective,
|
||||
);
|
||||
}
|
||||
if (params?.spinning !== false) {
|
||||
app.directive(
|
||||
isString(params?.spinning) ? params.spinning : 'spinning',
|
||||
spinningDirective,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './directive';
|
||||
export { default as Loading } from './loading.vue';
|
||||
export { default as Spinner } from './spinner.vue';
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts" setup>
|
||||
import { VbenLoading } from '@vben-core/shadcn-ui';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface LoadingProps {
|
||||
class?: string;
|
||||
/**
|
||||
* @zh_CN 最小加载时间
|
||||
* @en_US Minimum loading time
|
||||
*/
|
||||
minLoadingTime?: number;
|
||||
|
||||
/**
|
||||
* @zh_CN loading状态开启
|
||||
*/
|
||||
spinning?: boolean;
|
||||
/**
|
||||
* @zh_CN 文字
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'Loading' });
|
||||
const props = defineProps<LoadingProps>();
|
||||
</script>
|
||||
<template>
|
||||
<div :class="cn('relative min-h-20', props.class)">
|
||||
<slot></slot>
|
||||
<VbenLoading
|
||||
:min-loading-time="props.minLoadingTime"
|
||||
:spinning="props.spinning"
|
||||
:text="props.text"
|
||||
>
|
||||
<template v-if="$slots.icon" #icon>
|
||||
<slot name="icon"></slot>
|
||||
</template>
|
||||
</VbenLoading>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import { VbenSpinner } from '@vben-core/shadcn-ui';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
|
||||
interface SpinnerProps {
|
||||
class?: string;
|
||||
/**
|
||||
* @zh_CN 最小加载时间
|
||||
* @en_US Minimum loading time
|
||||
*/
|
||||
minLoadingTime?: number;
|
||||
/**
|
||||
* @zh_CN loading状态开启
|
||||
*/
|
||||
spinning?: boolean;
|
||||
}
|
||||
defineOptions({ name: 'Spinner' });
|
||||
const props = defineProps<SpinnerProps>();
|
||||
</script>
|
||||
<template>
|
||||
<div :class="cn('relative min-h-20', props.class)">
|
||||
<slot></slot>
|
||||
<VbenSpinner
|
||||
:min-loading-time="props.minLoadingTime"
|
||||
:spinning="props.spinning"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Page } from '..';
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { StyleValue } from 'vue';
|
||||
|
||||
import type { PageProps } from './types';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onMounted,
|
||||
ref,
|
||||
type StyleValue,
|
||||
useTemplateRef,
|
||||
} from 'vue';
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
|
||||
import { CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT } from '@vben-core/shared/constants';
|
||||
import { cn } from '@vben-core/shared/utils';
|
||||
@@ -17,7 +12,11 @@ defineOptions({
|
||||
name: 'Page',
|
||||
});
|
||||
|
||||
const { autoContentHeight = false } = defineProps<PageProps>();
|
||||
const {
|
||||
autoContentHeight = false,
|
||||
heightOffset = 0,
|
||||
footerFixed = false,
|
||||
} = defineProps<PageProps>();
|
||||
|
||||
const headerHeight = ref(0);
|
||||
const footerHeight = ref(0);
|
||||
@@ -29,7 +28,7 @@ const footerRef = useTemplateRef<HTMLDivElement>('footerRef');
|
||||
const contentStyle = computed<StyleValue>(() => {
|
||||
if (autoContentHeight) {
|
||||
return {
|
||||
height: `calc(var(${CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT}) - ${headerHeight.value}px)`,
|
||||
height: `calc(var(${CSS_VARIABLE_LAYOUT_CONTENT_HEIGHT}) - ${headerHeight.value}px - ${footerHeight.value}px - ${typeof heightOffset === 'number' ? `${heightOffset}px` : heightOffset})`,
|
||||
overflowY: shouldAutoHeight.value ? 'auto' : 'unset',
|
||||
};
|
||||
}
|
||||
@@ -40,9 +39,12 @@ async function calcContentHeight() {
|
||||
if (!autoContentHeight) {
|
||||
return;
|
||||
}
|
||||
shouldAutoHeight.value = false;
|
||||
await nextTick();
|
||||
headerHeight.value = headerRef.value?.offsetHeight || 0;
|
||||
footerHeight.value = footerRef.value?.offsetHeight || 0;
|
||||
|
||||
footerHeight.value = footerFixed ? 0 : footerRef.value?.offsetHeight || 0;
|
||||
|
||||
setTimeout(() => {
|
||||
shouldAutoHeight.value = true;
|
||||
}, 30);
|
||||
@@ -54,7 +56,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="relative flex h-full flex-col">
|
||||
<div
|
||||
v-if="
|
||||
description ||
|
||||
@@ -66,7 +68,7 @@ onMounted(() => {
|
||||
ref="headerRef"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card border-border relative flex items-end border-b px-6 py-4',
|
||||
'relative flex items-end border-b border-border bg-card px-6 py-4',
|
||||
headerClass,
|
||||
)
|
||||
"
|
||||
@@ -90,19 +92,16 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="cn('h-full p-4', contentClass)" :style="contentStyle">
|
||||
<div
|
||||
:class="cn(autoContentHeight ? 'h-full' : 'flex-1', 'p-4', contentClass)"
|
||||
:style="contentStyle"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
ref="footerRef"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card align-center absolute bottom-0 left-0 right-0 flex px-6 py-4',
|
||||
footerClass,
|
||||
)
|
||||
"
|
||||
:class="cn('align-center flex bg-card px-6 py-4', footerClass)"
|
||||
>
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
|
||||
@@ -8,4 +8,16 @@ export interface PageProps {
|
||||
autoContentHeight?: boolean;
|
||||
headerClass?: string;
|
||||
footerClass?: string;
|
||||
/**
|
||||
* Custom height offset value (in pixels) to adjust content area sizing
|
||||
* when used with autoContentHeight
|
||||
* @default 0
|
||||
*/
|
||||
heightOffset?: number;
|
||||
/**
|
||||
* Whether the footer is position: fixed.
|
||||
* When true, footer height is excluded from content height calculation.
|
||||
* @default false
|
||||
*/
|
||||
footerFixed?: boolean;
|
||||
}
|
||||
|
||||
@@ -264,13 +264,64 @@ const currentStick = ref<null | string>(null);
|
||||
|
||||
const parentElement = ref<HTMLElement | null>(null);
|
||||
|
||||
const width = computed(() => parentWidth.value! - left.value! - right.value!);
|
||||
function getPointerPosition(
|
||||
ev: Partial<{
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
touches: ArrayLike<{ pageX: number; pageY: number }>;
|
||||
}>,
|
||||
): null | { pointerX: number; pointerY: number } {
|
||||
const touch = ev.touches?.[0];
|
||||
const pointerX = ev.pageX ?? touch?.pageX;
|
||||
const pointerY = ev.pageY ?? touch?.pageY;
|
||||
|
||||
const height = computed(() => parentHeight.value! - top.value! - bottom.value!);
|
||||
if (
|
||||
pointerX === null ||
|
||||
pointerX === undefined ||
|
||||
pointerY === null ||
|
||||
pointerY === undefined
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { pointerX, pointerY };
|
||||
}
|
||||
|
||||
const width = computed(() => {
|
||||
const currentParentWidth = parentWidth.value;
|
||||
const currentLeft = left.value;
|
||||
const currentRight = right.value;
|
||||
|
||||
if (
|
||||
currentParentWidth === null ||
|
||||
currentLeft === null ||
|
||||
currentRight === null
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return currentParentWidth - currentLeft - currentRight;
|
||||
});
|
||||
|
||||
const height = computed(() => {
|
||||
const currentParentHeight = parentHeight.value;
|
||||
const currentTop = top.value;
|
||||
const currentBottom = bottom.value;
|
||||
|
||||
if (
|
||||
currentParentHeight === null ||
|
||||
currentTop === null ||
|
||||
currentBottom === null
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return currentParentHeight - currentTop - currentBottom;
|
||||
});
|
||||
|
||||
const rect = computed(() => ({
|
||||
left: Math.round(left.value!),
|
||||
top: Math.round(top.value!),
|
||||
left: Math.round(left.value ?? 0),
|
||||
top: Math.round(top.value ?? 0),
|
||||
width: Math.round(width.value),
|
||||
height: Math.round(height.value),
|
||||
}));
|
||||
@@ -348,36 +399,48 @@ const rectCorrectionByAspectRatio = (rect: {
|
||||
newTop: number;
|
||||
}) => {
|
||||
let { newLeft, newRight, newTop, newBottom } = rect;
|
||||
// const { parentWidth, parentHeight, currentStick, aspectFactor, dimensionsBeforeMove } = this;
|
||||
const currentParentWidth = parentWidth.value;
|
||||
const currentParentHeight = parentHeight.value;
|
||||
const stick = currentStick.value;
|
||||
const factor = aspectFactor.value;
|
||||
|
||||
let newWidth = parentWidth.value! - newLeft - newRight;
|
||||
let newHeight = parentHeight.value! - newTop - newBottom;
|
||||
if (
|
||||
currentParentWidth === null ||
|
||||
currentParentHeight === null ||
|
||||
!stick ||
|
||||
factor === null
|
||||
) {
|
||||
return { newLeft, newRight, newTop, newBottom };
|
||||
}
|
||||
|
||||
if (currentStick.value![1] === 'm') {
|
||||
let newWidth = currentParentWidth - newLeft - newRight;
|
||||
let newHeight = currentParentHeight - newTop - newBottom;
|
||||
|
||||
if (stick[1] === 'm') {
|
||||
const deltaHeight = newHeight - dimensionsBeforeMove.value.height;
|
||||
|
||||
newLeft -= (deltaHeight * aspectFactor.value!) / 2;
|
||||
newRight -= (deltaHeight * aspectFactor.value!) / 2;
|
||||
} else if (currentStick.value![0] === 'm') {
|
||||
newLeft -= (deltaHeight * factor) / 2;
|
||||
newRight -= (deltaHeight * factor) / 2;
|
||||
} else if (stick[0] === 'm') {
|
||||
const deltaWidth = newWidth - dimensionsBeforeMove.value.width;
|
||||
|
||||
newTop -= deltaWidth / aspectFactor.value! / 2;
|
||||
newBottom -= deltaWidth / aspectFactor.value! / 2;
|
||||
} else if (newWidth / newHeight > aspectFactor.value!) {
|
||||
newWidth = aspectFactor.value! * newHeight;
|
||||
newTop -= deltaWidth / factor / 2;
|
||||
newBottom -= deltaWidth / factor / 2;
|
||||
} else if (newWidth / newHeight > factor) {
|
||||
newWidth = factor * newHeight;
|
||||
|
||||
if (currentStick.value![1] === 'l') {
|
||||
newLeft = parentWidth.value! - newRight - newWidth;
|
||||
if (stick[1] === 'l') {
|
||||
newLeft = currentParentWidth - newRight - newWidth;
|
||||
} else {
|
||||
newRight = parentWidth.value! - newLeft - newWidth;
|
||||
newRight = currentParentWidth - newLeft - newWidth;
|
||||
}
|
||||
} else {
|
||||
newHeight = newWidth / aspectFactor.value!;
|
||||
newHeight = newWidth / factor;
|
||||
|
||||
if (currentStick.value![0] === 't') {
|
||||
newTop = parentHeight.value! - newBottom - newHeight;
|
||||
if (stick[0] === 't') {
|
||||
newTop = currentParentHeight - newBottom - newHeight;
|
||||
} else {
|
||||
newBottom = parentHeight.value! - newTop - newHeight;
|
||||
newBottom = currentParentHeight - newTop - newHeight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,11 +448,17 @@ const rectCorrectionByAspectRatio = (rect: {
|
||||
};
|
||||
|
||||
const stickMove = (delta: { x: number; y: number }) => {
|
||||
const stick = currentStick.value;
|
||||
|
||||
if (!stick) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newTop = dimensionsBeforeMove.value.top;
|
||||
let newBottom = dimensionsBeforeMove.value.bottom;
|
||||
let newLeft = dimensionsBeforeMove.value.left;
|
||||
let newRight = dimensionsBeforeMove.value.right;
|
||||
switch (currentStick.value![0]) {
|
||||
switch (stick[0]) {
|
||||
case 'b': {
|
||||
newBottom = dimensionsBeforeMove.value.bottom + delta.y;
|
||||
|
||||
@@ -419,7 +488,7 @@ const stickMove = (delta: { x: number; y: number }) => {
|
||||
}
|
||||
}
|
||||
|
||||
switch (currentStick.value![1]) {
|
||||
switch (stick[1]) {
|
||||
case 'l': {
|
||||
newLeft = dimensionsBeforeMove.value.left - delta.x;
|
||||
|
||||
@@ -512,89 +581,121 @@ const calcDragLimitation = () => {
|
||||
};
|
||||
|
||||
const calcResizeLimits = () => {
|
||||
// const { aspectFactor, width, height, bottom, top, left, right } = this;
|
||||
|
||||
const parentLim = parentLimitation.value ? 0 : null;
|
||||
const currentAspectFactor = aspectFactor.value;
|
||||
const currentLeft = left.value;
|
||||
const currentRight = right.value;
|
||||
const currentTop = top.value;
|
||||
const currentBottom = bottom.value;
|
||||
const stick = currentStick.value;
|
||||
|
||||
if (aspectRatio.value) {
|
||||
if (minw.value / minh.value > (aspectFactor.value as number)) {
|
||||
minh.value = minw.value / (aspectFactor.value as number);
|
||||
if (
|
||||
currentLeft === null ||
|
||||
currentRight === null ||
|
||||
currentTop === null ||
|
||||
currentBottom === null
|
||||
) {
|
||||
return {
|
||||
left: { min: parentLim, max: parentLim },
|
||||
right: { min: parentLim, max: parentLim },
|
||||
top: { min: parentLim, max: parentLim },
|
||||
bottom: { min: parentLim, max: parentLim },
|
||||
};
|
||||
}
|
||||
|
||||
let minWidth = minw.value;
|
||||
let minHeight = minh.value;
|
||||
|
||||
if (aspectRatio.value && currentAspectFactor) {
|
||||
if (minWidth / minHeight > currentAspectFactor) {
|
||||
minHeight = minWidth / currentAspectFactor;
|
||||
} else {
|
||||
minw.value = ((aspectFactor.value as number) * minh.value) as number;
|
||||
minWidth = currentAspectFactor * minHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const limits = {
|
||||
left: {
|
||||
min: parentLim,
|
||||
max: (left.value as number) + (width.value - minw.value),
|
||||
max: currentLeft + (width.value - minWidth),
|
||||
},
|
||||
right: {
|
||||
min: parentLim,
|
||||
max: (right.value as number) + (width.value - minw.value),
|
||||
max: currentRight + (width.value - minWidth),
|
||||
},
|
||||
top: {
|
||||
min: parentLim,
|
||||
max: (top.value as number) + (height.value - minh.value),
|
||||
max: currentTop + (height.value - minHeight),
|
||||
},
|
||||
bottom: {
|
||||
min: parentLim,
|
||||
max: (bottom.value as number) + (height.value - minh.value),
|
||||
max: currentBottom + (height.value - minHeight),
|
||||
},
|
||||
};
|
||||
|
||||
if (aspectRatio.value) {
|
||||
if (aspectRatio.value && currentAspectFactor) {
|
||||
const aspectLimits = {
|
||||
left: {
|
||||
min:
|
||||
left.value! -
|
||||
Math.min(top.value!, bottom.value!) * aspectFactor.value! * 2,
|
||||
currentLeft -
|
||||
Math.min(currentTop, currentBottom) * currentAspectFactor * 2,
|
||||
max:
|
||||
left.value! +
|
||||
((height.value - minh.value!) / 2) * aspectFactor.value! * 2,
|
||||
currentLeft +
|
||||
((height.value - minHeight) / 2) * currentAspectFactor * 2,
|
||||
},
|
||||
right: {
|
||||
min:
|
||||
right.value! -
|
||||
Math.min(top.value!, bottom.value!) * aspectFactor.value! * 2,
|
||||
currentRight -
|
||||
Math.min(currentTop, currentBottom) * currentAspectFactor * 2,
|
||||
max:
|
||||
right.value! +
|
||||
((height.value - minh.value!) / 2) * aspectFactor.value! * 2,
|
||||
currentRight +
|
||||
((height.value - minHeight) / 2) * currentAspectFactor * 2,
|
||||
},
|
||||
top: {
|
||||
min:
|
||||
top.value! -
|
||||
(Math.min(left.value!, right.value!) / aspectFactor.value!) * 2,
|
||||
currentTop -
|
||||
(Math.min(currentLeft, currentRight) / currentAspectFactor) * 2,
|
||||
max:
|
||||
top.value! +
|
||||
((width.value - minw.value) / 2 / aspectFactor.value!) * 2,
|
||||
currentTop + ((width.value - minWidth) / 2 / currentAspectFactor) * 2,
|
||||
},
|
||||
bottom: {
|
||||
min:
|
||||
bottom.value! -
|
||||
(Math.min(left.value!, right.value!) / aspectFactor.value!) * 2,
|
||||
currentBottom -
|
||||
(Math.min(currentLeft, currentRight) / currentAspectFactor) * 2,
|
||||
max:
|
||||
bottom.value! +
|
||||
((width.value - minw.value) / 2 / aspectFactor.value!) * 2,
|
||||
currentBottom +
|
||||
((width.value - minWidth) / 2 / currentAspectFactor) * 2,
|
||||
},
|
||||
};
|
||||
|
||||
if (currentStick.value![0] === 'm') {
|
||||
if (stick?.[0] === 'm') {
|
||||
limits.left = {
|
||||
min: Math.max(limits.left.min!, aspectLimits.left.min),
|
||||
min: Math.max(
|
||||
limits.left.min ?? aspectLimits.left.min,
|
||||
aspectLimits.left.min,
|
||||
),
|
||||
max: Math.min(limits.left.max, aspectLimits.left.max),
|
||||
};
|
||||
limits.right = {
|
||||
min: Math.max(limits.right.min!, aspectLimits.right.min),
|
||||
min: Math.max(
|
||||
limits.right.min ?? aspectLimits.right.min,
|
||||
aspectLimits.right.min,
|
||||
),
|
||||
max: Math.min(limits.right.max, aspectLimits.right.max),
|
||||
};
|
||||
} else if (currentStick.value![1] === 'm') {
|
||||
} else if (stick?.[1] === 'm') {
|
||||
limits.top = {
|
||||
min: Math.max(limits.top.min!, aspectLimits.top.min),
|
||||
min: Math.max(
|
||||
limits.top.min ?? aspectLimits.top.min,
|
||||
aspectLimits.top.min,
|
||||
),
|
||||
max: Math.min(limits.top.max, aspectLimits.top.max),
|
||||
};
|
||||
limits.bottom = {
|
||||
min: Math.max(limits.bottom.min!, aspectLimits.bottom.min),
|
||||
min: Math.max(
|
||||
limits.bottom.min ?? aspectLimits.bottom.min,
|
||||
aspectLimits.bottom.min,
|
||||
),
|
||||
max: Math.min(limits.bottom.max, aspectLimits.bottom.max),
|
||||
};
|
||||
}
|
||||
@@ -604,9 +705,9 @@ const calcResizeLimits = () => {
|
||||
};
|
||||
|
||||
const positionStyle = computed(() => ({
|
||||
top: `${top.value}px`,
|
||||
left: `${left.value}px`,
|
||||
zIndex: zIndex.value!,
|
||||
top: `${top.value ?? 0}px`,
|
||||
left: `${left.value ?? 0}px`,
|
||||
zIndex: zIndex.value ?? 'auto',
|
||||
}));
|
||||
|
||||
const sizeStyle = computed(() => ({
|
||||
@@ -618,13 +719,11 @@ const stickStyles = computed(() => (stick: string) => {
|
||||
const stickStyle = {
|
||||
width: `${stickSize.value / parentScaleX.value}px`,
|
||||
height: `${stickSize.value / parentScaleY.value}px`,
|
||||
[styleMapping.y[stick[0] as 'b' | 'm' | 't'] as 'height' | 'width']:
|
||||
`${stickSize.value / parentScaleX.value / -2}px`,
|
||||
[styleMapping.x[stick[1] as 'l' | 'm' | 'r'] as 'height' | 'width']:
|
||||
`${stickSize.value / parentScaleX.value / -2}px`,
|
||||
};
|
||||
stickStyle[
|
||||
styleMapping.y[stick[0] as 'b' | 'm' | 't'] as 'height' | 'width'
|
||||
] = `${stickSize.value / parentScaleX.value / -2}px`;
|
||||
stickStyle[
|
||||
styleMapping.x[stick[1] as 'l' | 'm' | 'r'] as 'height' | 'width'
|
||||
] = `${stickSize.value / parentScaleX.value / -2}px`;
|
||||
return stickStyle;
|
||||
});
|
||||
|
||||
@@ -719,12 +818,15 @@ const stickDown = (
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerPosition = getPointerPosition(ev);
|
||||
|
||||
if (!pointerPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
stickDrag.value = true;
|
||||
|
||||
const pointerX = ev.pageX === undefined ? ev.touches[0].pageX : ev.pageX;
|
||||
const pointerY = ev.pageY === undefined ? ev.touches[0].pageY : ev.pageY;
|
||||
|
||||
saveDimensionsBeforeMove({ pointerX, pointerY });
|
||||
saveDimensionsBeforeMove(pointerPosition);
|
||||
|
||||
currentStick.value = stick;
|
||||
|
||||
@@ -738,13 +840,19 @@ const move = (ev: MouseEvent & TouchEvent) => {
|
||||
|
||||
ev.stopPropagation();
|
||||
|
||||
// touches 兼容性代码
|
||||
const pageX = ev.pageX === undefined ? ev.touches![0]!.pageX : ev.pageX;
|
||||
const pageY = ev.pageY === undefined ? ev.touches![0]!.pageY : ev.pageY;
|
||||
const pointerPosition = getPointerPosition(ev);
|
||||
|
||||
if (!pointerPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = {
|
||||
x: (dimensionsBeforeMove.value.pointerX - pageX) / parentScaleX.value,
|
||||
y: (dimensionsBeforeMove.value.pointerY - pageY) / parentScaleY.value,
|
||||
x:
|
||||
(dimensionsBeforeMove.value.pointerX - pointerPosition.pointerX) /
|
||||
parentScaleX.value,
|
||||
y:
|
||||
(dimensionsBeforeMove.value.pointerY - pointerPosition.pointerY) /
|
||||
parentScaleY.value,
|
||||
};
|
||||
|
||||
if (stickDrag.value) {
|
||||
@@ -812,12 +920,17 @@ onMounted(() => {
|
||||
|
||||
left.value = x.value;
|
||||
top.value = y.value;
|
||||
right.value = (parentWidth.value -
|
||||
(w.value === 'auto' ? container.value!.scrollWidth : (w.value as number)) -
|
||||
left.value) as number;
|
||||
bottom.value = (parentHeight.value -
|
||||
(h.value === 'auto' ? container.value!.scrollHeight : (h.value as number)) -
|
||||
top.value) as number;
|
||||
const containerElement = container.value;
|
||||
const contentWidth =
|
||||
w.value === 'auto'
|
||||
? (containerElement?.scrollWidth ?? 0)
|
||||
: (w.value as number);
|
||||
const contentHeight =
|
||||
h.value === 'auto'
|
||||
? (containerElement?.scrollHeight ?? 0)
|
||||
: (h.value as number);
|
||||
right.value = (parentWidth.value ?? 0) - contentWidth - (left.value ?? 0);
|
||||
bottom.value = (parentHeight.value ?? 0) - contentHeight - (top.value ?? 0);
|
||||
|
||||
addEvents(domEvents.value);
|
||||
|
||||
@@ -841,13 +954,14 @@ onMounted(() => {
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeEvents(domEvents.value);
|
||||
});
|
||||
|
||||
const bodyDown = (ev: MouseEvent & TouchEvent) => {
|
||||
const { target, button } = ev;
|
||||
const targetElement = target instanceof HTMLElement ? target : null;
|
||||
const uid = getCurrentInstance()?.uid.toString();
|
||||
|
||||
if (!preventActiveBehavior.value) {
|
||||
active.value = true;
|
||||
@@ -865,17 +979,13 @@ const bodyDown = (ev: MouseEvent & TouchEvent) => {
|
||||
|
||||
if (
|
||||
dragHandle.value &&
|
||||
(target! as HTMLElement).dataset.dragHandle !==
|
||||
getCurrentInstance()?.uid.toString()
|
||||
targetElement &&
|
||||
targetElement.dataset.dragHandle !== uid
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
dragCancel.value &&
|
||||
(target! as HTMLElement).dataset.dragCancel ===
|
||||
getCurrentInstance()?.uid.toString()
|
||||
) {
|
||||
if (dragCancel.value && targetElement?.dataset.dragCancel === uid) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -887,14 +997,17 @@ const bodyDown = (ev: MouseEvent & TouchEvent) => {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
const pointerPosition = getPointerPosition(ev);
|
||||
|
||||
if (!pointerPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDraggable.value) {
|
||||
bodyDrag.value = true;
|
||||
}
|
||||
|
||||
const pointerX = ev.pageX === undefined ? ev.touches[0]!.pageX : ev.pageX;
|
||||
const pointerY = ev.pageY === undefined ? ev.touches[0]!.pageY : ev.pageY;
|
||||
|
||||
saveDimensionsBeforeMove({ pointerX, pointerY });
|
||||
saveDimensionsBeforeMove(pointerPosition);
|
||||
|
||||
if (parentLimitation.value) {
|
||||
limits.value = calcDragLimitation();
|
||||
@@ -923,8 +1036,10 @@ watch(
|
||||
watch(
|
||||
() => z.value,
|
||||
(val) => {
|
||||
if ((val as number) >= 0 || val === 'auto') {
|
||||
zIndex.value = val as number;
|
||||
if (typeof val === 'number' && val >= 0) {
|
||||
zIndex.value = val;
|
||||
} else if (val === 'auto') {
|
||||
zIndex.value = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -933,13 +1048,22 @@ watch(
|
||||
watch(
|
||||
() => x.value,
|
||||
(newVal, oldVal) => {
|
||||
if (stickDrag.value || bodyDrag.value || newVal === left.value) {
|
||||
const currentLeft = left.value;
|
||||
const currentTop = top.value;
|
||||
|
||||
if (
|
||||
stickDrag.value ||
|
||||
bodyDrag.value ||
|
||||
currentLeft === null ||
|
||||
currentTop === null ||
|
||||
newVal === currentLeft
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = oldVal - newVal;
|
||||
|
||||
bodyDown({ pageX: left.value!, pageY: top.value! } as MouseEvent &
|
||||
bodyDown({ pageX: currentLeft, pageY: currentTop } as MouseEvent &
|
||||
TouchEvent);
|
||||
bodyMove({ x: delta, y: 0 });
|
||||
|
||||
@@ -952,13 +1076,22 @@ watch(
|
||||
watch(
|
||||
() => y.value,
|
||||
(newVal, oldVal) => {
|
||||
if (stickDrag.value || bodyDrag.value || newVal === top.value) {
|
||||
const currentLeft = left.value;
|
||||
const currentTop = top.value;
|
||||
|
||||
if (
|
||||
stickDrag.value ||
|
||||
bodyDrag.value ||
|
||||
currentLeft === null ||
|
||||
currentTop === null ||
|
||||
newVal === currentTop
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = oldVal - newVal;
|
||||
|
||||
bodyDown({ pageX: left.value, pageY: top.value } as MouseEvent &
|
||||
bodyDown({ pageX: currentLeft, pageY: currentTop } as MouseEvent &
|
||||
TouchEvent);
|
||||
bodyMove({ x: 0, y: delta });
|
||||
|
||||
@@ -971,16 +1104,23 @@ watch(
|
||||
watch(
|
||||
() => w.value,
|
||||
(newVal, oldVal) => {
|
||||
const currentRight = right.value;
|
||||
const currentTop = top.value;
|
||||
|
||||
if (stickDrag.value || bodyDrag.value || newVal === width.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentRight === null || currentTop === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stick = 'mr';
|
||||
const delta = (oldVal as number) - (newVal as number);
|
||||
|
||||
stickDown(
|
||||
stick,
|
||||
{ pageX: right.value, pageY: top.value! + height.value / 2 },
|
||||
{ pageX: currentRight, pageY: currentTop + height.value / 2 },
|
||||
true,
|
||||
);
|
||||
stickMove({ x: delta, y: 0 });
|
||||
@@ -994,16 +1134,23 @@ watch(
|
||||
watch(
|
||||
() => h.value,
|
||||
(newVal, oldVal) => {
|
||||
const currentLeft = left.value;
|
||||
const currentBottom = bottom.value;
|
||||
|
||||
if (stickDrag.value || bodyDrag.value || newVal === height.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentLeft === null || currentBottom === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stick = 'bm';
|
||||
const delta = (oldVal as number) - (newVal as number);
|
||||
|
||||
stickDown(
|
||||
stick,
|
||||
{ pageX: left.value! + width.value / 2, pageY: bottom.value },
|
||||
{ pageX: currentLeft + width.value / 2, pageY: currentBottom },
|
||||
true,
|
||||
);
|
||||
stickMove({ x: 0, y: delta });
|
||||
@@ -1017,7 +1164,7 @@ watch(
|
||||
watch(
|
||||
() => parentW.value,
|
||||
(val) => {
|
||||
right.value = val - width.value - left.value!;
|
||||
right.value = val - width.value - (left.value ?? 0);
|
||||
parentWidth.value = val;
|
||||
},
|
||||
);
|
||||
@@ -1025,7 +1172,7 @@ watch(
|
||||
watch(
|
||||
() => parentH.value,
|
||||
(val) => {
|
||||
bottom.value = val - height.value - top.value!;
|
||||
bottom.value = val - height.value - (top.value ?? 0);
|
||||
parentHeight.value = val;
|
||||
},
|
||||
);
|
||||
@@ -1072,8 +1219,8 @@ watch(
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: '';
|
||||
outline: 1px dashed #d6d6d6;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.resize-stick {
|
||||
|
||||
100
packages/effects/common-ui/src/components/tippy/directive.ts
Normal file
100
packages/effects/common-ui/src/components/tippy/directive.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { ComputedRef, Directive } from 'vue';
|
||||
|
||||
import { useTippy } from 'vue-tippy';
|
||||
|
||||
export default function useTippyDirective(isDark: ComputedRef<boolean>) {
|
||||
const directive: Directive = {
|
||||
mounted(el, binding, vnode) {
|
||||
const opts =
|
||||
typeof binding.value === 'string'
|
||||
? { content: binding.value }
|
||||
: binding.value || {};
|
||||
|
||||
const modifiers = Object.keys(binding.modifiers || {});
|
||||
const placement = modifiers.find((modifier) => modifier !== 'arrow');
|
||||
const withArrow = modifiers.includes('arrow');
|
||||
|
||||
if (placement) {
|
||||
opts.placement = opts.placement || placement;
|
||||
}
|
||||
|
||||
if (withArrow) {
|
||||
opts.arrow = opts.arrow === undefined ? true : opts.arrow;
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyShow) {
|
||||
opts.onShow = function (...args: any[]) {
|
||||
return vnode.props?.onTippyShow(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyShown) {
|
||||
opts.onShown = function (...args: any[]) {
|
||||
return vnode.props?.onTippyShown(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyHidden) {
|
||||
opts.onHidden = function (...args: any[]) {
|
||||
return vnode.props?.onTippyHidden(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyHide) {
|
||||
opts.onHide = function (...args: any[]) {
|
||||
return vnode.props?.onTippyHide(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (vnode.props && vnode.props.onTippyMount) {
|
||||
opts.onMount = function (...args: any[]) {
|
||||
return vnode.props?.onTippyMount(...args);
|
||||
};
|
||||
}
|
||||
|
||||
if (el.getAttribute('title') && !opts.content) {
|
||||
opts.content = el.getAttribute('title');
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
|
||||
if (el.getAttribute('content') && !opts.content) {
|
||||
opts.content = el.getAttribute('content');
|
||||
}
|
||||
|
||||
useTippy(el, opts);
|
||||
},
|
||||
unmounted(el) {
|
||||
if (el.$tippy) {
|
||||
el.$tippy.destroy();
|
||||
} else if (el._tippy) {
|
||||
el._tippy.destroy();
|
||||
}
|
||||
},
|
||||
|
||||
updated(el, binding) {
|
||||
const opts =
|
||||
typeof binding.value === 'string'
|
||||
? { content: binding.value, theme: isDark.value ? '' : 'light' }
|
||||
: Object.assign(
|
||||
{ theme: isDark.value ? '' : 'light' },
|
||||
binding.value,
|
||||
);
|
||||
|
||||
if (el.getAttribute('title') && !opts.content) {
|
||||
opts.content = el.getAttribute('title');
|
||||
el.removeAttribute('title');
|
||||
}
|
||||
|
||||
if (el.getAttribute('content') && !opts.content) {
|
||||
opts.content = el.getAttribute('content');
|
||||
}
|
||||
|
||||
if (el.$tippy) {
|
||||
el.$tippy.setProps(opts || {});
|
||||
} else if (el._tippy) {
|
||||
el._tippy.setProps(opts || {});
|
||||
}
|
||||
},
|
||||
};
|
||||
return directive;
|
||||
}
|
||||
67
packages/effects/common-ui/src/components/tippy/index.ts
Normal file
67
packages/effects/common-ui/src/components/tippy/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { DefaultProps, Props } from 'tippy.js';
|
||||
|
||||
import type { App, SetupContext } from 'vue';
|
||||
|
||||
import { h, watchEffect } from 'vue';
|
||||
import { setDefaultProps, Tippy as TippyComponent } from 'vue-tippy';
|
||||
|
||||
import { usePreferences } from '@vben-core/preferences';
|
||||
|
||||
import useTippyDirective from './directive';
|
||||
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
import 'tippy.js/dist/backdrop.css';
|
||||
import 'tippy.js/themes/light.css';
|
||||
import 'tippy.js/animations/scale.css';
|
||||
import 'tippy.js/animations/shift-toward.css';
|
||||
import 'tippy.js/animations/shift-away.css';
|
||||
import 'tippy.js/animations/perspective.css';
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
export type TippyProps = Partial<
|
||||
Props & {
|
||||
animation?:
|
||||
| 'fade'
|
||||
| 'perspective'
|
||||
| 'scale'
|
||||
| 'shift-away'
|
||||
| 'shift-toward'
|
||||
| boolean;
|
||||
theme?: 'auto' | 'dark' | 'light';
|
||||
}
|
||||
>;
|
||||
|
||||
export function initTippy(app: App<Element>, options?: DefaultProps) {
|
||||
setDefaultProps({
|
||||
allowHTML: true,
|
||||
delay: [500, 200],
|
||||
theme: isDark.value ? '' : 'light',
|
||||
...options,
|
||||
});
|
||||
if (!options || !Reflect.has(options, 'theme') || options.theme === 'auto') {
|
||||
watchEffect(() => {
|
||||
setDefaultProps({ theme: isDark.value ? '' : 'light' });
|
||||
});
|
||||
}
|
||||
|
||||
app.directive('tippy', useTippyDirective(isDark));
|
||||
}
|
||||
|
||||
export const Tippy = (props: any, { attrs, slots }: SetupContext) => {
|
||||
let theme: string = (attrs.theme as string) ?? 'auto';
|
||||
if (theme === 'auto') {
|
||||
theme = isDark.value ? '' : 'light';
|
||||
}
|
||||
if (theme === 'dark') {
|
||||
theme = '';
|
||||
}
|
||||
return h(
|
||||
TippyComponent,
|
||||
{
|
||||
...props,
|
||||
...attrs,
|
||||
theme,
|
||||
},
|
||||
slots,
|
||||
);
|
||||
};
|
||||
1
packages/effects/common-ui/src/components/tree/index.ts
Normal file
1
packages/effects/common-ui/src/components/tree/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Tree } from './tree.vue';
|
||||
25
packages/effects/common-ui/src/components/tree/tree.vue
Normal file
25
packages/effects/common-ui/src/components/tree/tree.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeProps } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { Inbox } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { treePropsDefaults, VbenTree } from '@vben-core/shadcn-ui';
|
||||
|
||||
const props = withDefaults(defineProps<TreeProps>(), treePropsDefaults());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VbenTree v-if="props.treeData?.length > 0" v-bind="props">
|
||||
<template v-for="(_, key) in $slots" :key="key" #[key]="slotProps">
|
||||
<slot :name="key" v-bind="slotProps"> </slot>
|
||||
</template>
|
||||
</VbenTree>
|
||||
<div
|
||||
v-else
|
||||
class="flex-col-center cursor-pointer rounded-lg border p-10 text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
<Inbox class="size-10" />
|
||||
<div class="mt-1">{{ $t('common.noData') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
VBEN_GITHUB_URL,
|
||||
VBEN_PREVIEW_URL,
|
||||
} from '@vben/constants';
|
||||
|
||||
import { VbenRenderContent } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { Page } from '../../components';
|
||||
@@ -113,7 +114,7 @@ const devDependenciesItems = Object.keys(devDependencies).map((key) => ({
|
||||
<template>
|
||||
<Page :title="title">
|
||||
<template #description>
|
||||
<p class="text-foreground mt-3 text-sm leading-6">
|
||||
<p class="mt-3 text-sm/6 text-foreground">
|
||||
<a :href="VBEN_GITHUB_URL" class="vben-link" target="_blank">
|
||||
{{ name }}
|
||||
</a>
|
||||
@@ -122,16 +123,16 @@ const devDependenciesItems = Object.keys(devDependencies).map((key) => ({
|
||||
</template>
|
||||
<div class="card-box p-5">
|
||||
<div>
|
||||
<h5 class="text-foreground text-lg">基本信息</h5>
|
||||
<h5 class="text-lg text-foreground">基本信息</h5>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<dl class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<template v-for="item in vbenDescriptionItems" :key="item.title">
|
||||
<div class="border-border border-t px-4 py-6 sm:col-span-1 sm:px-0">
|
||||
<dt class="text-foreground text-sm font-medium leading-6">
|
||||
<div class="border-t border-border px-4 py-6 sm:col-span-1 sm:px-0">
|
||||
<dt class="text-sm/6 font-medium text-foreground">
|
||||
{{ item.title }}
|
||||
</dt>
|
||||
<dd class="text-foreground mt-1 text-sm leading-6 sm:mt-2">
|
||||
<dd class="mt-1 text-sm/6 text-foreground sm:mt-2">
|
||||
<VbenRenderContent :content="item.content" />
|
||||
</dd>
|
||||
</div>
|
||||
@@ -142,16 +143,16 @@ const devDependenciesItems = Object.keys(devDependencies).map((key) => ({
|
||||
|
||||
<div class="card-box mt-6 p-5">
|
||||
<div>
|
||||
<h5 class="text-foreground text-lg">生产环境依赖</h5>
|
||||
<h5 class="text-lg text-foreground">生产环境依赖</h5>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<dl class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<template v-for="item in dependenciesItems" :key="item.title">
|
||||
<div class="border-border border-t px-4 py-3 sm:col-span-1 sm:px-0">
|
||||
<dt class="text-foreground text-sm">
|
||||
<div class="border-t border-border px-4 py-3 sm:col-span-1 sm:px-0">
|
||||
<dt class="text-sm text-foreground">
|
||||
{{ item.title }}
|
||||
</dt>
|
||||
<dd class="text-foreground/80 mt-1 text-sm sm:mt-2">
|
||||
<dd class="mt-1 text-sm text-foreground/80 sm:mt-2">
|
||||
<VbenRenderContent :content="item.content" />
|
||||
</dd>
|
||||
</div>
|
||||
@@ -161,16 +162,16 @@ const devDependenciesItems = Object.keys(devDependencies).map((key) => ({
|
||||
</div>
|
||||
<div class="card-box mt-6 p-5">
|
||||
<div>
|
||||
<h5 class="text-foreground text-lg">开发环境依赖</h5>
|
||||
<h5 class="text-lg text-foreground">开发环境依赖</h5>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<dl class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
<template v-for="item in devDependenciesItems" :key="item.title">
|
||||
<div class="border-border border-t px-4 py-3 sm:col-span-1 sm:px-0">
|
||||
<dt class="text-foreground text-sm">
|
||||
<div class="border-t border-border px-4 py-3 sm:col-span-1 sm:px-0">
|
||||
<dt class="text-sm text-foreground">
|
||||
{{ item.title }}
|
||||
</dt>
|
||||
<dd class="text-foreground/80 mt-1 text-sm sm:mt-2">
|
||||
<dd class="mt-1 text-sm text-foreground/80 sm:mt-2">
|
||||
<VbenRenderContent :content="item.content" />
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="mb-7 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2
|
||||
class="text-foreground mb-3 text-3xl font-bold leading-9 tracking-tight lg:text-4xl"
|
||||
class="mb-3 text-3xl/9 font-bold tracking-tight text-foreground lg:text-4xl"
|
||||
>
|
||||
<slot></slot>
|
||||
</h2>
|
||||
|
||||
<p class="text-muted-foreground lg:text-md text-sm">
|
||||
<p class="lg:text-md text-sm text-muted-foreground">
|
||||
<slot name="desc"></slot>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '@vben-core/form-ui';
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenForm } from '@vben-core/form-ui';
|
||||
import { VbenButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
@@ -33,6 +35,10 @@ interface Props {
|
||||
* @zh_CN 按钮文本
|
||||
*/
|
||||
submitButtonText?: string;
|
||||
/**
|
||||
* @zh_CN 是否显示返回按钮
|
||||
*/
|
||||
showBack?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
@@ -41,6 +47,7 @@ defineOptions({
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
showBack: true,
|
||||
loginPath: '/auth/login',
|
||||
submitButtonText: '',
|
||||
subTitle: '',
|
||||
@@ -68,10 +75,7 @@ async function handleSubmit() {
|
||||
const { valid } = await formApi.validate();
|
||||
const values = await formApi.getValues();
|
||||
if (valid) {
|
||||
emit('submit', {
|
||||
code: values?.code,
|
||||
phoneNumber: values?.phoneNumber,
|
||||
});
|
||||
emit('submit', values);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +115,12 @@ defineExpose({
|
||||
{{ submitButtonText || $t('common.login') }}
|
||||
</slot>
|
||||
</VbenButton>
|
||||
<VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
||||
<VbenButton
|
||||
v-if="showBack"
|
||||
class="mt-4 w-full"
|
||||
variant="outline"
|
||||
@click="goToLogin()"
|
||||
>
|
||||
{{ $t('common.back') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { SvgDingDingIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { alert, useVbenModal } from '@vben-core/popup-ui';
|
||||
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
import { loadScript } from '@vben-core/shared/utils';
|
||||
|
||||
interface Props {
|
||||
clientId: string;
|
||||
corpId: string;
|
||||
// 登录回调地址
|
||||
redirectUri?: string;
|
||||
// 是否内嵌二维码登录
|
||||
isQrCode?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
header: false,
|
||||
footer: false,
|
||||
fullscreenButton: false,
|
||||
class: 'size-75.5 dingding-qrcode-login-modal',
|
||||
onOpened() {
|
||||
handleQrCodeLogin();
|
||||
},
|
||||
});
|
||||
|
||||
const getRedirectUri = () => {
|
||||
const { redirectUri } = props;
|
||||
if (redirectUri) {
|
||||
return redirectUri;
|
||||
}
|
||||
return window.location.origin + route.fullPath;
|
||||
};
|
||||
|
||||
/**
|
||||
* 内嵌二维码登录
|
||||
*/
|
||||
const handleQrCodeLogin = async () => {
|
||||
const { clientId, corpId } = props;
|
||||
if (!(window as any).DTFrameLogin) {
|
||||
// 二维码登录 加载资源
|
||||
await loadScript(
|
||||
'https://g.alicdn.com/dingding/h5-dingtalk-login/0.21.0/ddlogin.js',
|
||||
);
|
||||
}
|
||||
(window as any).DTFrameLogin(
|
||||
{
|
||||
id: 'dingding_qrcode_login_element',
|
||||
width: 300,
|
||||
height: 300,
|
||||
},
|
||||
{
|
||||
// 注意:redirect_uri 需为完整URL,扫码后钉钉会带code跳转到这里
|
||||
redirect_uri: encodeURIComponent(getRedirectUri()),
|
||||
client_id: clientId,
|
||||
scope: 'openid corpid',
|
||||
response_type: 'code',
|
||||
state: '1',
|
||||
prompt: 'consent',
|
||||
corpId,
|
||||
},
|
||||
(loginResult: any) => {
|
||||
const { redirectUrl } = loginResult;
|
||||
// 这里可以直接进行重定向
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
(errorMsg: string) => {
|
||||
// 这里一般需要展示登录失败的具体原因
|
||||
alert(`Login Error: ${errorMsg}`);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
const { clientId, corpId, isQrCode } = props;
|
||||
if (isQrCode) {
|
||||
// 内嵌二维码登录
|
||||
modalApi.open();
|
||||
} else {
|
||||
window.location.href = `https://login.dingtalk.com/oauth2/auth?redirect_uri=${encodeURIComponent(getRedirectUri())}&response_type=code&client_id=${clientId}&scope=openid&corpid=${corpId}&prompt=consent`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VbenIconButton
|
||||
@click="handleLogin"
|
||||
:tooltip="$t('authentication.dingdingLogin')"
|
||||
tooltip-side="top"
|
||||
>
|
||||
<SvgDingDingIcon />
|
||||
</VbenIconButton>
|
||||
<Modal>
|
||||
<div id="dingding_qrcode_login_element"></div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.dingding-qrcode-login-modal {
|
||||
.relative {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ import { computed, reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenForm } from '@vben-core/form-ui';
|
||||
import { VbenButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { default as AuthenticationCodeLogin } from './code-login.vue';
|
||||
export { default as AuthenticationForgetPassword } from './forget-password.vue';
|
||||
export { default as AuthenticationLogin } from './login.vue';
|
||||
export { default as AuthenticationLoginExpiredModal } from './login-expired-modal.vue';
|
||||
export { default as AuthenticationLogin } from './login.vue';
|
||||
export { default as AuthenticationQrCodeLogin } from './qrcode-login.vue';
|
||||
export { default as AuthenticationRegister } from './register.vue';
|
||||
export type { AuthenticationProps } from './types';
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { AuthenticationProps } from './types';
|
||||
|
||||
import { computed, watch } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenModal } from '@vben-core/popup-ui';
|
||||
import { Slot, VbenAvatar } from '@vben-core/shadcn-ui';
|
||||
|
||||
@@ -35,6 +37,16 @@ const getZIndex = computed(() => {
|
||||
return props.zIndex || calcZIndex();
|
||||
});
|
||||
|
||||
/**
|
||||
* 排除ant-message和loading:9999的z-index
|
||||
*/
|
||||
const zIndexExcludeClass = ['ant-message', 'loading'];
|
||||
function isZIndexExcludeClass(element: Element) {
|
||||
return zIndexExcludeClass.some((className) =>
|
||||
element.classList.contains(className),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最大的zIndex值
|
||||
*/
|
||||
@@ -44,7 +56,11 @@ function calcZIndex() {
|
||||
[...elements].forEach((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
const zIndex = style.getPropertyValue('z-index');
|
||||
if (zIndex && !Number.isNaN(Number.parseInt(zIndex))) {
|
||||
if (
|
||||
zIndex &&
|
||||
!Number.isNaN(Number.parseInt(zIndex)) &&
|
||||
!isZIndexExcludeClass(element)
|
||||
) {
|
||||
maxZ = Math.max(maxZ, Number.parseInt(zIndex));
|
||||
}
|
||||
});
|
||||
@@ -62,7 +78,7 @@ function calcZIndex() {
|
||||
:fullscreen-button="false"
|
||||
:header="false"
|
||||
:z-index="getZIndex"
|
||||
class="border-none px-10 py-6 text-center shadow-xl sm:w-[600px] sm:rounded-2xl md:h-[unset]"
|
||||
class="border-none px-10 py-6 text-center shadow-xl sm:w-150 sm:rounded-2xl md:h-[unset]"
|
||||
>
|
||||
<VbenAvatar :src="avatar" class="mx-auto mb-6 size-20" />
|
||||
<Slot
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '@vben-core/form-ui';
|
||||
|
||||
import type { AuthenticationProps } from './types';
|
||||
@@ -8,6 +9,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenForm } from '@vben-core/form-ui';
|
||||
import { VbenButton, VbenCheckbox } from '@vben-core/shadcn-ui';
|
||||
|
||||
@@ -15,7 +17,7 @@ import Title from './auth-title.vue';
|
||||
import ThirdPartyLogin from './third-party-login.vue';
|
||||
|
||||
interface Props extends AuthenticationProps {
|
||||
formSchema: VbenFormSchema[];
|
||||
formSchema?: VbenFormSchema[];
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
@@ -115,7 +117,7 @@ defineExpose({
|
||||
<div class="flex-center">
|
||||
<VbenCheckbox
|
||||
v-if="showRememberMe"
|
||||
v-model:checked="rememberMe"
|
||||
v-model="rememberMe"
|
||||
name="rememberMe"
|
||||
>
|
||||
{{ $t('authentication.rememberMe') }}
|
||||
@@ -144,7 +146,7 @@ defineExpose({
|
||||
|
||||
<div
|
||||
v-if="showCodeLogin || showQrcodeLogin"
|
||||
class="mb-2 mt-4 flex items-center justify-between"
|
||||
class="mt-4 mb-2 flex items-center justify-between"
|
||||
>
|
||||
<VbenButton
|
||||
v-if="showCodeLogin"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { VbenButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useQRCode } from '@vueuse/integrations/useQRCode';
|
||||
@@ -34,6 +35,10 @@ interface Props {
|
||||
* @zh_CN 描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @zh_CN 是否显示返回按钮
|
||||
*/
|
||||
showBack?: boolean;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
@@ -43,6 +48,7 @@ defineOptions({
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
description: '',
|
||||
loading: false,
|
||||
showBack: true,
|
||||
loginPath: '/auth/login',
|
||||
submitButtonText: '',
|
||||
subTitle: '',
|
||||
@@ -78,16 +84,21 @@ function goToLogin() {
|
||||
</template>
|
||||
</Title>
|
||||
|
||||
<div class="flex-col-center mt-6">
|
||||
<div class="mt-6 flex-col-center">
|
||||
<img :src="qrcode" alt="qrcode" class="w-1/2" />
|
||||
<p class="text-muted-foreground mt-4 text-sm">
|
||||
<p class="mt-4 text-sm text-muted-foreground">
|
||||
<slot name="description">
|
||||
{{ description || $t('authentication.qrcodePrompt') }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<VbenButton class="mt-4 w-full" variant="outline" @click="goToLogin()">
|
||||
<VbenButton
|
||||
v-if="showBack"
|
||||
class="mt-4 w-full"
|
||||
variant="outline"
|
||||
@click="goToLogin()"
|
||||
>
|
||||
{{ $t('common.back') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '@vben-core/form-ui';
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenForm } from '@vben-core/form-ui';
|
||||
import { VbenButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
import Title from './auth-title.vue';
|
||||
|
||||
interface Props {
|
||||
formSchema: VbenFormSchema[];
|
||||
formSchema?: VbenFormSchema[];
|
||||
/**
|
||||
* @zh_CN 是否处于加载处理状态
|
||||
*/
|
||||
|
||||
@@ -1,36 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { MdiGithub, MdiGoogle, MdiQqchat, MdiWechat } from '@vben/icons';
|
||||
import { useAppConfig } from '@vben/hooks';
|
||||
import {
|
||||
SvgGithubIcon,
|
||||
SvgGoogleIcon,
|
||||
SvgQQChatIcon,
|
||||
SvgWeChatIcon,
|
||||
} from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
import DingdingLogin from './dingding-login.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThirdPartyLogin',
|
||||
});
|
||||
|
||||
const {
|
||||
auth: { dingding: dingdingAuthConfig },
|
||||
} = useAppConfig(import.meta.env, import.meta.env.PROD);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full sm:mx-auto md:max-w-md">
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
<span class="border-input w-[35%] border-b dark:border-gray-600"></span>
|
||||
<span class="text-muted-foreground text-center text-xs uppercase">
|
||||
<span class="w-[35%] border-b border-input dark:border-gray-600"></span>
|
||||
<span class="text-center text-xs text-muted-foreground uppercase">
|
||||
{{ $t('authentication.thirdPartyLogin') }}
|
||||
</span>
|
||||
<span class="border-input w-[35%] border-b dark:border-gray-600"></span>
|
||||
<span class="w-[35%] border-b border-input dark:border-gray-600"></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-center">
|
||||
<VbenIconButton class="mb-3">
|
||||
<MdiWechat />
|
||||
<VbenIconButton
|
||||
:tooltip="$t('authentication.wechatLogin')"
|
||||
tooltip-side="top"
|
||||
class="mb-3"
|
||||
>
|
||||
<SvgWeChatIcon />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton class="mb-3">
|
||||
<MdiQqchat />
|
||||
<VbenIconButton
|
||||
:tooltip="$t('authentication.qqLogin')"
|
||||
tooltip-side="top"
|
||||
class="mb-3"
|
||||
>
|
||||
<SvgQQChatIcon />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton class="mb-3">
|
||||
<MdiGithub />
|
||||
<VbenIconButton
|
||||
:tooltip="$t('authentication.githubLogin')"
|
||||
tooltip-side="top"
|
||||
class="mb-3"
|
||||
>
|
||||
<SvgGithubIcon />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton class="mb-3">
|
||||
<MdiGoogle />
|
||||
<VbenIconButton
|
||||
:tooltip="$t('authentication.googleLogin')"
|
||||
tooltip-side="top"
|
||||
class="mb-3"
|
||||
>
|
||||
<SvgGoogleIcon />
|
||||
</VbenIconButton>
|
||||
<DingdingLogin
|
||||
v-if="dingdingAuthConfig"
|
||||
:corp-id="dingdingAuthConfig.corpId"
|
||||
:client-id="dingdingAuthConfig.clientId"
|
||||
class="mb-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { computed } from 'vue';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
tabs: TabOption[];
|
||||
tabs?: TabOption[];
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
@@ -23,7 +23,7 @@ const defaultValue = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-box w-full px-4 pb-5 pt-3">
|
||||
<div class="card-box w-full px-4 pt-3 pb-5">
|
||||
<Tabs :default-value="defaultValue">
|
||||
<TabsList>
|
||||
<template v-for="tab in tabs" :key="tab.label">
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
import { desensitize } from '../../../../../../../apps/web-antd/src/util/tool';
|
||||
|
||||
interface Props {
|
||||
items: AnalysisOverviewItem[];
|
||||
myCard: any;
|
||||
items?: AnalysisOverviewItem[];
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
@@ -62,7 +62,7 @@ withDefaults(defineProps<Props>(), {
|
||||
class="text-xl"
|
||||
prefix=""
|
||||
/>
|
||||
<VbenIcon :icon="item.icon" class="size-8 flex-shrink-0" />
|
||||
<VbenIcon :icon="item.icon" class="size-8 shrink-0" />
|
||||
</CardContent>
|
||||
<CardFooter class="justify-between">
|
||||
<span>{{ item.totalTitle }}</span>
|
||||
|
||||
@@ -2,13 +2,15 @@ import type { Component } from 'vue';
|
||||
|
||||
interface AnalysisOverviewItem {
|
||||
icon: Component | string;
|
||||
title: string;
|
||||
title?: string;
|
||||
totalTitle: string;
|
||||
totalValue: number;
|
||||
value: number;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
interface WorkbenchProjectItem {
|
||||
/** 临时字段 */
|
||||
[key: string]: any;
|
||||
color?: string;
|
||||
content: string;
|
||||
date: string;
|
||||
|
||||
@@ -15,32 +15,34 @@ withDefaults(defineProps<Props>(), {
|
||||
</script>
|
||||
<template>
|
||||
<div class="card-box p-4 py-6 lg:flex">
|
||||
<VbenAvatar :src="avatar" class="size-20" />
|
||||
<VbenAvatar v-if="avatar" :src="avatar" class="size-20" />
|
||||
<div
|
||||
v-if="$slots.title || $slots.description"
|
||||
class="flex flex-col justify-center md:ml-6 md:mt-0"
|
||||
class="flex flex-col justify-center md:mt-0 md:ml-6"
|
||||
>
|
||||
<h1 v-if="$slots.title" class="text-md font-semibold md:text-xl">
|
||||
<slot name="title"></slot>
|
||||
</h1>
|
||||
<span v-if="$slots.description" class="text-foreground/80 mt-1">
|
||||
<span v-if="$slots.description" class="mt-1 text-foreground/80">
|
||||
<slot name="description"></slot>
|
||||
</span>
|
||||
</div>
|
||||
<!-- <div class="mt-4 flex flex-1 justify-end md:mt-0">-->
|
||||
<!-- <div class="flex flex-col justify-center text-right">-->
|
||||
<!-- <span class="text-foreground/80"> 待办 </span>-->
|
||||
<!-- <span class="text-2xl">2/10</span>-->
|
||||
<!-- </div>-->
|
||||
<!-- <slot name="actions">-->
|
||||
<!-- <div class="flex flex-col justify-center text-right">-->
|
||||
<!-- <span class="text-foreground/80"> 待办 </span>-->
|
||||
<!-- <span class="text-2xl">2/10</span>-->
|
||||
<!-- </div>-->
|
||||
|
||||
<!-- <div class="mx-12 flex flex-col justify-center text-right md:mx-16">-->
|
||||
<!-- <span class="text-foreground/80"> 项目 </span>-->
|
||||
<!-- <span class="text-2xl">8</span>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mr-4 flex flex-col justify-center text-right md:mr-10">-->
|
||||
<!-- <span class="text-foreground/80"> 团队 </span>-->
|
||||
<!-- <span class="text-2xl">300</span>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mx-12 flex flex-col justify-center text-right md:mx-16">-->
|
||||
<!-- <span class="text-foreground/80"> 项目 </span>-->
|
||||
<!-- <span class="text-2xl">8</span>-->
|
||||
<!-- </div>-->
|
||||
<!-- <div class="mr-4 flex flex-col justify-center text-right md:mr-10">-->
|
||||
<!-- <span class="text-foreground/80"> 团队 </span>-->
|
||||
<!-- <span class="text-2xl">300</span>-->
|
||||
<!-- </div>-->
|
||||
<!-- </slot>-->
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
items: WorkbenchProjectItem[];
|
||||
items?: WorkbenchProjectItem[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
@@ -37,8 +37,10 @@ defineEmits(['click']);
|
||||
'border-r-0': index % 3 === 2,
|
||||
'border-b-0': index < 3,
|
||||
'pb-4': index > 2,
|
||||
'rounded-bl-xl': index >= items.length - 3 && index % 3 === 0,
|
||||
'rounded-br-xl': index === items.length - 1 && index % 3 === 2,
|
||||
}"
|
||||
class="border-border group w-full cursor-pointer border-r border-t p-4 transition-all hover:shadow-xl md:w-1/2 lg:w-1/3"
|
||||
class="border-border group w-full cursor-pointer border-b border-r border-t p-4 transition-all hover:shadow-xl md:w-1/2 lg:w-1/3"
|
||||
@click="$emit('click', item)"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
@@ -46,16 +48,25 @@ defineEmits(['click']);
|
||||
:color="item.color"
|
||||
:icon="item.icon"
|
||||
class="size-8 transition-all duration-300 group-hover:scale-110"
|
||||
@click="$emit('click', item)"
|
||||
/>
|
||||
<span class="ml-4 text-lg font-medium">{{ item.title }}</span>
|
||||
</div>
|
||||
<div class="text-foreground/80 mt-4 flex h-10">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
<div class="text-foreground/80 flex justify-between">
|
||||
<span>{{ item.group }}</span>
|
||||
<span>{{ item.date }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域支持插槽自定义 -->
|
||||
<slot name="content" :item="item" :index="index">
|
||||
<div class="text-foreground/80 mt-4 flex h-10">
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- 底部信息区域支持插槽自定义 -->
|
||||
<slot name="footer" :item="item" :index="index">
|
||||
<div class="text-foreground/80 flex justify-between">
|
||||
<span>{{ item.group }}</span>
|
||||
<span>{{ item.date }}</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
items: WorkbenchQuickNavItem[];
|
||||
items?: WorkbenchQuickNavItem[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ defineEmits(['click']);
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader class="py-4">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-lg">{{ title }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap p-0">
|
||||
@@ -35,10 +35,12 @@ defineEmits(['click']);
|
||||
<div
|
||||
:class="{
|
||||
'border-r-0': index % 3 === 2,
|
||||
'pb-4': index > 2,
|
||||
'border-b-0': index < 3,
|
||||
'pb-4': index > 2,
|
||||
'rounded-bl-xl': index === items.length - 3,
|
||||
'rounded-br-xl': index === items.length - 1,
|
||||
}"
|
||||
class="flex-col-center border-border group w-1/3 cursor-pointer border-r border-t py-8 hover:shadow-xl"
|
||||
class="group flex-col-center w-1/3 cursor-pointer border-t border-r border-border py-8 hover:shadow-xl"
|
||||
@click="$emit('click', item)"
|
||||
>
|
||||
<VbenIcon
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
items: WorkbenchTodoItem[];
|
||||
items?: WorkbenchTodoItem[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
@@ -25,34 +25,34 @@ withDefaults(defineProps<Props>(), {
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader class="py-4">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-lg">{{ title }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap p-5 pt-0">
|
||||
<ul class="divide-border w-full divide-y" role="list">
|
||||
<ul class="w-full divide-y divide-border" role="list">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.title"
|
||||
:class="{
|
||||
'select-none line-through opacity-60': item.completed,
|
||||
'line-through opacity-60 select-none': item.completed,
|
||||
}"
|
||||
class="flex cursor-pointer justify-between gap-x-6 py-5"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-x-4">
|
||||
<VbenCheckbox v-model:checked="item.completed" name="completed" />
|
||||
<VbenCheckbox v-model="item.completed" name="completed" />
|
||||
<div class="min-w-0 flex-auto">
|
||||
<p class="text-foreground text-sm font-semibold leading-6">
|
||||
<p class="text-sm/6 font-semibold text-foreground">
|
||||
{{ item.title }}
|
||||
</p>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<p
|
||||
class="text-foreground/80 *:text-primary mt-1 truncate text-xs leading-5"
|
||||
class="mt-1 truncate text-xs/5 text-foreground/80 *:text-primary"
|
||||
v-html="item.content"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden h-full shrink-0 sm:flex sm:flex-col sm:items-end">
|
||||
<span class="text-foreground/80 mt-6 text-xs leading-6">
|
||||
<span class="mt-6 text-xs/6 text-foreground/80">
|
||||
{{ item.date }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
items: WorkbenchTrendItem[];
|
||||
items?: WorkbenchTrendItem[];
|
||||
title: string;
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ withDefaults(defineProps<Props>(), {
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader class="py-4">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-lg">{{ title }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap p-5 pt-0">
|
||||
<ul class="divide-border w-full divide-y" role="list">
|
||||
<ul class="w-full divide-y divide-border" role="list">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.title"
|
||||
@@ -42,18 +42,18 @@ withDefaults(defineProps<Props>(), {
|
||||
class="size-10 flex-none rounded-full"
|
||||
/>
|
||||
<div class="min-w-0 flex-auto">
|
||||
<p class="text-foreground text-sm font-semibold leading-6">
|
||||
<p class="text-sm/6 font-semibold text-foreground">
|
||||
{{ item.title }}
|
||||
</p>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<p
|
||||
class="text-foreground/80 *:text-primary mt-1 truncate text-xs leading-5"
|
||||
class="mt-1 truncate text-xs/5 text-foreground/80 *:text-primary"
|
||||
v-html="item.content"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden h-full shrink-0 sm:flex sm:flex-col sm:items-end">
|
||||
<span class="text-foreground/80 mt-6 text-xs leading-6">
|
||||
<span class="mt-6 text-xs/6 text-foreground/80">
|
||||
{{ item.date }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useRouter } from 'vue-router';
|
||||
|
||||
import { ArrowLeft, RotateCw } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { VbenButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props extends FallbackProps {}
|
||||
@@ -127,7 +128,7 @@ function refresh() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex size-full flex-col items-center justify-center duration-300">
|
||||
<div class="flex-col-center size-full duration-300">
|
||||
<img v-if="image" :src="image" class="md:1/3 w-1/2 lg:w-1/4" />
|
||||
<component
|
||||
:is="fallbackIcon"
|
||||
@@ -138,14 +139,14 @@ function refresh() {
|
||||
<slot v-if="$slots.title" name="title"></slot>
|
||||
<p
|
||||
v-else-if="titleText"
|
||||
class="text-foreground mt-8 text-2xl md:text-3xl lg:text-4xl"
|
||||
class="mt-8 text-2xl text-foreground md:text-3xl lg:text-4xl"
|
||||
>
|
||||
{{ titleText }}
|
||||
</p>
|
||||
<slot v-if="$slots.describe" name="describe"></slot>
|
||||
<p
|
||||
v-else-if="descText"
|
||||
class="text-muted-foreground md:text-md my-4 lg:text-lg"
|
||||
class="md:text-md my-4 text-muted-foreground lg:text-lg"
|
||||
>
|
||||
{{ descText }}
|
||||
</p>
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './about';
|
||||
export * from './authentication';
|
||||
export * from './dashboard';
|
||||
export * from './fallback';
|
||||
export * from './profile';
|
||||
|
||||
58
packages/effects/common-ui/src/ui/profile/base-setting.vue
Normal file
58
packages/effects/common-ui/src/ui/profile/base-setting.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '@vben-core/form-ui';
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenForm } from '@vben-core/form-ui';
|
||||
import { VbenButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
formSchema?: VbenFormSchema[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
formSchema: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [Recordable<any>];
|
||||
}>();
|
||||
|
||||
const [Form, formApi] = useVbenForm(
|
||||
reactive({
|
||||
commonConfig: {
|
||||
// 所有表单项
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: computed(() => props.formSchema),
|
||||
showDefaultActions: false,
|
||||
}),
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
const { valid } = await formApi.validate();
|
||||
const values = await formApi.getValues();
|
||||
if (valid) {
|
||||
emit('submit', values);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getFormApi: () => formApi,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div @keydown.enter.prevent="handleSubmit">
|
||||
<Form />
|
||||
<VbenButton type="submit" class="mt-4" @click="handleSubmit">
|
||||
{{ $t('profile.updateBasicProfile') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
</template>
|
||||
6
packages/effects/common-ui/src/ui/profile/index.ts
Normal file
6
packages/effects/common-ui/src/ui/profile/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as ProfileBaseSetting } from './base-setting.vue';
|
||||
export { default as ProfileNotificationSetting } from './notification-setting.vue';
|
||||
export { default as ProfilePasswordSetting } from './password-setting.vue';
|
||||
export { default as Profile } from './profile.vue';
|
||||
export { default as ProfileSecuritySetting } from './security-setting.vue';
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { SettingProps } from './types';
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
Switch,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
withDefaults(defineProps<SettingProps>(), {
|
||||
formSchema: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [Recordable<any>];
|
||||
}>();
|
||||
|
||||
function handleChange(fieldName: string, value: boolean) {
|
||||
emit('change', { fieldName, value });
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<form class="space-y-8">
|
||||
<div class="space-y-4">
|
||||
<template v-for="item in formSchema" :key="item.fieldName">
|
||||
<FormField type="checkbox" :name="item.fieldName">
|
||||
<FormItem
|
||||
class="flex flex-row items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel class="text-base"> {{ item.label }} </FormLabel>
|
||||
<FormDescription>
|
||||
{{ item.description }}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="item.value"
|
||||
@update:model-value="handleChange(item.fieldName, $event)"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { VbenFormSchema } from '@vben-core/form-ui';
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenForm } from '@vben-core/form-ui';
|
||||
import { VbenButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
formSchema?: VbenFormSchema[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
formSchema: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [Recordable<any>];
|
||||
}>();
|
||||
|
||||
const [Form, formApi] = useVbenForm(
|
||||
reactive({
|
||||
commonConfig: {
|
||||
labelWidth: 130,
|
||||
// 所有表单项
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: computed(() => props.formSchema),
|
||||
showDefaultActions: false,
|
||||
}),
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
const { valid } = await formApi.validate();
|
||||
const values = await formApi.getValues();
|
||||
if (valid) {
|
||||
emit('submit', values);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getFormApi: () => formApi,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<Form />
|
||||
<VbenButton type="submit" class="mt-4" @click="handleSubmit">
|
||||
{{ $t('profile.updatePassword') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
</template>
|
||||
62
packages/effects/common-ui/src/ui/profile/profile.vue
Normal file
62
packages/effects/common-ui/src/ui/profile/profile.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { Props } from './types';
|
||||
|
||||
import { preferences } from '@vben-core/preferences';
|
||||
import {
|
||||
Card,
|
||||
Separator,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
VbenAvatar,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
import { Page } from '../../components';
|
||||
|
||||
defineOptions({
|
||||
name: 'ProfileUI',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: '关于项目',
|
||||
tabs: () => [],
|
||||
});
|
||||
|
||||
const tabsValue = defineModel<string>('modelValue');
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="flex size-full">
|
||||
<Card class="w-1/6 flex-none">
|
||||
<div class="mt-4 flex-col-center h-40 gap-4">
|
||||
<VbenAvatar
|
||||
:src="userInfo?.avatar ?? preferences.app.defaultAvatar"
|
||||
class="size-20"
|
||||
/>
|
||||
<span class="text-lg font-semibold">
|
||||
{{ userInfo?.realName ?? '' }}
|
||||
</span>
|
||||
<span class="text-sm text-foreground/80">
|
||||
{{ userInfo?.username ?? '' }}
|
||||
</span>
|
||||
</div>
|
||||
<Separator class="my-4" />
|
||||
<Tabs v-model="tabsValue" orientation="vertical" class="m-4">
|
||||
<TabsList class="grid w-full grid-cols-1 bg-card">
|
||||
<TabsTrigger
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
:value="tab.value"
|
||||
class="h-12 justify-start data-[state=active]:bg-primary data-[state=active]:text-primary-foreground"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</Card>
|
||||
<Card class="ml-4 w-5/6 flex-auto p-8">
|
||||
<slot name="content"></slot>
|
||||
</Card>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import type { SettingProps } from './types';
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
Switch,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
withDefaults(defineProps<SettingProps>(), {
|
||||
formSchema: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [Recordable<any>];
|
||||
}>();
|
||||
|
||||
function handleChange(fieldName: string, value: boolean) {
|
||||
emit('change', { fieldName, value });
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<form class="space-y-8">
|
||||
<div class="space-y-4">
|
||||
<template v-for="item in formSchema" :key="item.fieldName">
|
||||
<FormField type="checkbox" :name="item.fieldName">
|
||||
<FormItem
|
||||
class="flex flex-row items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div class="space-y-0.5">
|
||||
<FormLabel class="text-base"> {{ item.label }} </FormLabel>
|
||||
<FormDescription>
|
||||
{{ item.description }}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="item.value"
|
||||
@update:model-value="handleChange(item.fieldName, $event)"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
21
packages/effects/common-ui/src/ui/profile/types.ts
Normal file
21
packages/effects/common-ui/src/ui/profile/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { BasicUserInfo } from '@vben/types';
|
||||
|
||||
export interface Props {
|
||||
title?: string;
|
||||
userInfo: BasicUserInfo | null;
|
||||
tabs: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface FormSchemaItem {
|
||||
description: string;
|
||||
fieldName: string;
|
||||
label: string;
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
export interface SettingProps {
|
||||
formSchema: FormSchemaItem[];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/hooks",
|
||||
"version": "5.5.1",
|
||||
"version": "5.7.0",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
|
||||
@@ -15,9 +15,22 @@ export function useAppConfig(
|
||||
? window._VBEN_ADMIN_PRO_APP_CONF_
|
||||
: (env as VbenAdminProAppConfigRaw);
|
||||
|
||||
const { VITE_GLOB_API_URL } = config;
|
||||
const {
|
||||
VITE_GLOB_API_URL,
|
||||
VITE_GLOB_AUTH_DINGDING_CORP_ID,
|
||||
VITE_GLOB_AUTH_DINGDING_CLIENT_ID,
|
||||
} = config;
|
||||
|
||||
return {
|
||||
const applicationConfig: ApplicationConfig = {
|
||||
apiURL: VITE_GLOB_API_URL,
|
||||
auth: {},
|
||||
};
|
||||
if (VITE_GLOB_AUTH_DINGDING_CORP_ID && VITE_GLOB_AUTH_DINGDING_CLIENT_ID) {
|
||||
applicationConfig.auth.dingding = {
|
||||
clientId: VITE_GLOB_AUTH_DINGDING_CLIENT_ID,
|
||||
corpId: VITE_GLOB_AUTH_DINGDING_CORP_ID,
|
||||
};
|
||||
}
|
||||
|
||||
return applicationConfig;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function useAntdDesignTokens() {
|
||||
colorSuccess: '',
|
||||
colorTextBase: '',
|
||||
colorWarning: '',
|
||||
zIndexPopupBase: 2000, // 调整基础弹层层级,避免下拉等组件被弹窗或者最大化状态下的表格遮挡
|
||||
});
|
||||
|
||||
const getCssVariableValue = (variable: string, isColor: boolean = true) => {
|
||||
@@ -192,67 +193,107 @@ export function useElementPlusDesignTokens() {
|
||||
|
||||
'--el-border-radius-base': getCssVariableValue('--radius', false),
|
||||
'--el-color-danger': getCssVariableValue('--destructive-500'),
|
||||
'--el-color-danger-dark-2': getCssVariableValue('--destructive'),
|
||||
'--el-color-danger-light-3': getCssVariableValue('--destructive-400'),
|
||||
'--el-color-danger-light-5': getCssVariableValue('--destructive-300'),
|
||||
'--el-color-danger-light-7': getCssVariableValue('--destructive-200'),
|
||||
'--el-color-danger-dark-2': isDark.value
|
||||
? getCssVariableValue('--destructive-400')
|
||||
: getCssVariableValue('--destructive-600'),
|
||||
'--el-color-danger-light-3': isDark.value
|
||||
? getCssVariableValue('--destructive-600')
|
||||
: getCssVariableValue('--destructive-400'),
|
||||
'--el-color-danger-light-5': isDark.value
|
||||
? getCssVariableValue('--destructive-700')
|
||||
: getCssVariableValue('--destructive-300'),
|
||||
'--el-color-danger-light-7': isDark.value
|
||||
? getCssVariableValue('--destructive-800')
|
||||
: getCssVariableValue('--destructive-200'),
|
||||
'--el-color-danger-light-8': isDark.value
|
||||
? border
|
||||
? getCssVariableValue('--destructive-900')
|
||||
: getCssVariableValue('--destructive-100'),
|
||||
'--el-color-danger-light-9': isDark.value
|
||||
? accent
|
||||
? getCssVariableValue('--destructive-950')
|
||||
: getCssVariableValue('--destructive-50'),
|
||||
|
||||
'--el-color-error': getCssVariableValue('--destructive-500'),
|
||||
'--el-color-error-dark-2': getCssVariableValue('--destructive'),
|
||||
'--el-color-error-light-3': getCssVariableValue('--destructive-400'),
|
||||
'--el-color-error-light-5': getCssVariableValue('--destructive-300'),
|
||||
'--el-color-error-light-7': getCssVariableValue('--destructive-200'),
|
||||
'--el-color-error-dark-2': isDark.value
|
||||
? getCssVariableValue('--destructive-400')
|
||||
: getCssVariableValue('--destructive-600'),
|
||||
'--el-color-error-light-3': isDark.value
|
||||
? getCssVariableValue('--destructive-600')
|
||||
: getCssVariableValue('--destructive-400'),
|
||||
'--el-color-error-light-5': isDark.value
|
||||
? getCssVariableValue('--destructive-700')
|
||||
: getCssVariableValue('--destructive-300'),
|
||||
'--el-color-error-light-7': isDark.value
|
||||
? getCssVariableValue('--destructive-800')
|
||||
: getCssVariableValue('--destructive-200'),
|
||||
'--el-color-error-light-8': isDark.value
|
||||
? border
|
||||
? getCssVariableValue('--destructive-900')
|
||||
: getCssVariableValue('--destructive-100'),
|
||||
'--el-color-error-light-9': isDark.value
|
||||
? accent
|
||||
? getCssVariableValue('--destructive-950')
|
||||
: getCssVariableValue('--destructive-50'),
|
||||
|
||||
'--el-color-info-light-5': border,
|
||||
'--el-color-info-light-8': border,
|
||||
'--el-color-info-light-9': getCssVariableValue('--info'), // getCssVariableValue('--secondary'),
|
||||
|
||||
'--el-color-primary': getCssVariableValue('--primary-500'),
|
||||
'--el-color-primary-dark-2': getCssVariableValue('--primary'),
|
||||
'--el-color-primary-light-3': getCssVariableValue('--primary-400'),
|
||||
'--el-color-primary-light-5': getCssVariableValue('--primary-300'),
|
||||
'--el-color-primary-dark-2': isDark.value
|
||||
? getCssVariableValue('--primary-400')
|
||||
: getCssVariableValue('--primary-600'),
|
||||
'--el-color-primary-light-3': isDark.value
|
||||
? getCssVariableValue('--primary-600')
|
||||
: getCssVariableValue('--primary-400'),
|
||||
'--el-color-primary-light-5': isDark.value
|
||||
? getCssVariableValue('--primary-700')
|
||||
: getCssVariableValue('--primary-300'),
|
||||
'--el-color-primary-light-7': isDark.value
|
||||
? border
|
||||
? getCssVariableValue('--primary-800')
|
||||
: getCssVariableValue('--primary-200'),
|
||||
'--el-color-primary-light-8': isDark.value
|
||||
? border
|
||||
? getCssVariableValue('--primary-900')
|
||||
: getCssVariableValue('--primary-100'),
|
||||
'--el-color-primary-light-9': isDark.value
|
||||
? accent
|
||||
? getCssVariableValue('--primary-950')
|
||||
: getCssVariableValue('--primary-50'),
|
||||
|
||||
'--el-color-success': getCssVariableValue('--success-500'),
|
||||
'--el-color-success-dark-2': getCssVariableValue('--success'),
|
||||
'--el-color-success-light-3': getCssVariableValue('--success-400'),
|
||||
'--el-color-success-light-5': getCssVariableValue('--success-300'),
|
||||
'--el-color-success-light-7': getCssVariableValue('--success-200'),
|
||||
'--el-color-success-dark-2': isDark.value
|
||||
? getCssVariableValue('--success-400')
|
||||
: getCssVariableValue('--success-600'),
|
||||
'--el-color-success-light-3': isDark.value
|
||||
? getCssVariableValue('--success-600')
|
||||
: getCssVariableValue('--success-400'),
|
||||
'--el-color-success-light-5': isDark.value
|
||||
? getCssVariableValue('--success-700')
|
||||
: getCssVariableValue('--success-300'),
|
||||
'--el-color-success-light-7': isDark.value
|
||||
? getCssVariableValue('--success-800')
|
||||
: getCssVariableValue('--success-200'),
|
||||
'--el-color-success-light-8': isDark.value
|
||||
? border
|
||||
? getCssVariableValue('--success-900')
|
||||
: getCssVariableValue('--success-100'),
|
||||
'--el-color-success-light-9': isDark.value
|
||||
? accent
|
||||
? getCssVariableValue('--success-950')
|
||||
: getCssVariableValue('--success-50'),
|
||||
|
||||
'--el-color-warning': getCssVariableValue('--warning-500'),
|
||||
'--el-color-warning-dark-2': getCssVariableValue('--warning'),
|
||||
'--el-color-warning-light-3': getCssVariableValue('--warning-400'),
|
||||
'--el-color-warning-light-5': getCssVariableValue('--warning-300'),
|
||||
'--el-color-warning-light-7': getCssVariableValue('--warning-200'),
|
||||
'--el-color-warning-dark-2': isDark.value
|
||||
? getCssVariableValue('--warning-400')
|
||||
: getCssVariableValue('--warning-600'),
|
||||
'--el-color-warning-light-3': isDark.value
|
||||
? getCssVariableValue('--warning-600')
|
||||
: getCssVariableValue('--warning-400'),
|
||||
'--el-color-warning-light-5': isDark.value
|
||||
? getCssVariableValue('--warning-700')
|
||||
: getCssVariableValue('--warning-300'),
|
||||
'--el-color-warning-light-7': isDark.value
|
||||
? getCssVariableValue('--warning-800')
|
||||
: getCssVariableValue('--warning-200'),
|
||||
'--el-color-warning-light-8': isDark.value
|
||||
? border
|
||||
? getCssVariableValue('--warning-900')
|
||||
: getCssVariableValue('--warning-100'),
|
||||
'--el-color-warning-light-9': isDark.value
|
||||
? accent
|
||||
? getCssVariableValue('--warning-950')
|
||||
: getCssVariableValue('--warning-50'),
|
||||
|
||||
'--el-fill-color': getCssVariableValue('--accent'),
|
||||
@@ -278,3 +319,103 @@ export function useElementPlusDesignTokens() {
|
||||
{ immediate: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function useTDesignDesignTokens() {
|
||||
const { isDark } = usePreferences();
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
|
||||
const getCssVariableValue = (variable: string, isColor: boolean = true) => {
|
||||
const value = rootStyles.getPropertyValue(variable);
|
||||
return isColor ? convertToRgb(`hsl(${value})`) : value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成某个语义色对应的 TDesign 变量(语义变体 + 1~10 色阶)。
|
||||
* TDesign 的色阶在亮色模式下 1 最浅、10 最深,暗色模式下方向相反,
|
||||
* 这里根据当前模式把 Vben 的 50~900 色板映射到对应方向。
|
||||
* @param tdName TDesign 颜色名,如 `brand`、`error`
|
||||
* @param vbenName Vben 颜色名,如 `primary`、`destructive`
|
||||
*/
|
||||
const getColorTokens = (tdName: string, vbenName: string) => {
|
||||
const dark = isDark.value;
|
||||
const getColor = (level?: number) =>
|
||||
getCssVariableValue(
|
||||
level === undefined ? `--${vbenName}` : `--${vbenName}-${level}`,
|
||||
);
|
||||
|
||||
// 亮色模式 1 最浅、10 最深;暗色模式相反
|
||||
const lightScale = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
|
||||
const scaleLevels = dark ? [...lightScale].toReversed() : lightScale;
|
||||
const scale: Record<string, string> = {};
|
||||
scaleLevels.forEach((level, index) => {
|
||||
scale[`--td-${tdName}-color-${index + 1}`] = getColor(level);
|
||||
});
|
||||
|
||||
return {
|
||||
[`--td-${tdName}-color`]: getColor(),
|
||||
[`--td-${tdName}-color-active`]: getColor(dark ? 300 : 700),
|
||||
[`--td-${tdName}-color-disabled`]: getColor(dark ? 800 : 200),
|
||||
[`--td-${tdName}-color-focus`]: getColor(dark ? 900 : 100),
|
||||
[`--td-${tdName}-color-hover`]: getColor(dark ? 400 : 600),
|
||||
[`--td-${tdName}-color-light`]: getColor(dark ? 950 : 50),
|
||||
[`--td-${tdName}-color-light-hover`]: getColor(dark ? 900 : 100),
|
||||
...scale,
|
||||
};
|
||||
};
|
||||
|
||||
watch(
|
||||
() => preferences.theme,
|
||||
() => {
|
||||
const variables: Record<string, string> = {
|
||||
// 品牌色(主题色)、功能色
|
||||
...getColorTokens('brand', 'primary'),
|
||||
...getColorTokens('error', 'destructive'),
|
||||
...getColorTokens('warning', 'warning'),
|
||||
...getColorTokens('success', 'success'),
|
||||
|
||||
// 文字颜色
|
||||
'--td-text-color-anti': getCssVariableValue('--primary-foreground'),
|
||||
'--td-text-color-brand': getCssVariableValue('--primary'),
|
||||
'--td-text-color-disabled': getCssVariableValue('--muted-foreground'),
|
||||
'--td-text-color-link': getCssVariableValue('--primary'),
|
||||
'--td-text-color-placeholder': getCssVariableValue(
|
||||
'--input-placeholder',
|
||||
),
|
||||
'--td-text-color-primary': getCssVariableValue('--foreground'),
|
||||
'--td-text-color-secondary': getCssVariableValue('--muted-foreground'),
|
||||
|
||||
// 背景颜色
|
||||
'--td-bg-color-component': getCssVariableValue('--accent'),
|
||||
'--td-bg-color-component-active':
|
||||
getCssVariableValue('--accent-darker'),
|
||||
'--td-bg-color-component-disabled': getCssVariableValue('--muted'),
|
||||
'--td-bg-color-component-hover': getCssVariableValue('--accent-hover'),
|
||||
'--td-bg-color-container': getCssVariableValue('--background'),
|
||||
'--td-bg-color-container-active':
|
||||
getCssVariableValue('--accent-darker'),
|
||||
'--td-bg-color-container-hover': getCssVariableValue('--accent'),
|
||||
'--td-bg-color-container-select': getCssVariableValue('--accent'),
|
||||
'--td-bg-color-page': getCssVariableValue('--background-deep'),
|
||||
'--td-bg-color-secondarycontainer': getCssVariableValue('--card'),
|
||||
|
||||
// 边框颜色
|
||||
'--td-border-level-1-color': getCssVariableValue('--border'),
|
||||
'--td-border-level-2-color': getCssVariableValue('--border'),
|
||||
'--td-component-border': getCssVariableValue('--border'),
|
||||
'--td-component-stroke': getCssVariableValue('--border'),
|
||||
|
||||
// 圆角
|
||||
'--td-radius-default': getCssVariableValue('--radius', false),
|
||||
};
|
||||
|
||||
// TDesign 将亮/暗变量定义在 `:root[theme-mode='light'|'dark']` 下,
|
||||
// 需要写入相同优先级的选择器才能覆盖默认值
|
||||
const selector = isDark.value
|
||||
? `:root[theme-mode='dark']`
|
||||
: `:root[theme-mode='light']`;
|
||||
|
||||
updateCSSVariables(variables, `__vben_design_tdesign_styles__`, selector);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,140 @@
|
||||
import type { Arrayable, MaybeElementRef } from '@vueuse/core';
|
||||
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
|
||||
import { computed, effectScope, ref, unref, watch } from 'vue';
|
||||
|
||||
import { isFunction } from '@vben/utils';
|
||||
|
||||
import { useMouseInElement } from '@vueuse/core';
|
||||
import { tryOnScopeDispose, useElementHover } from '@vueuse/core';
|
||||
|
||||
interface HoverDelayOptions {
|
||||
/** 鼠标进入延迟时间 */
|
||||
enterDelay?: (() => number) | number;
|
||||
/** 鼠标离开延迟时间 */
|
||||
leaveDelay?: (() => number) | number;
|
||||
}
|
||||
|
||||
const DEFAULT_LEAVE_DELAY = 500; // 鼠标离开延迟时间,默认为 500ms
|
||||
const DEFAULT_ENTER_DELAY = 0; // 鼠标进入延迟时间,默认为 0(立即响应)
|
||||
|
||||
/**
|
||||
* 监测鼠标是否在元素内部,如果在元素内部则返回 true,否则返回 false
|
||||
* @param refElement 所有需要检测的元素。如果提供了一个数组,那么鼠标在任何一个元素内部都会返回 true
|
||||
* @param delay 延迟更新状态的时间
|
||||
* @param refElement 所有需要检测的元素。支持单个元素、元素数组或响应式引用的元素数组。如果鼠标在任何一个元素内部都会返回 true
|
||||
* @param delay 延迟更新状态的时间,可以是数字或包含进入/离开延迟的配置对象
|
||||
* @returns 返回一个数组,第一个元素是一个 ref,表示鼠标是否在元素内部,第二个元素是一个控制器,可以通过 enable 和 disable 方法来控制监听器的启用和禁用
|
||||
*/
|
||||
export function useHoverToggle(
|
||||
refElement: Arrayable<MaybeElementRef>,
|
||||
delay: (() => number) | number = 500,
|
||||
refElement: Arrayable<MaybeElementRef> | Ref<HTMLElement[] | null>,
|
||||
delay: (() => number) | HoverDelayOptions | number = DEFAULT_LEAVE_DELAY,
|
||||
) {
|
||||
const isOutsides: Array<Ref<boolean>> = [];
|
||||
const value = ref(false);
|
||||
const timer = ref<ReturnType<typeof setTimeout> | undefined>();
|
||||
const refs = Array.isArray(refElement) ? refElement : [refElement];
|
||||
refs.forEach((refEle) => {
|
||||
const listener = useMouseInElement(refEle, { handleOutside: true });
|
||||
isOutsides.push(listener.isOutside);
|
||||
});
|
||||
const isOutsideAll = computed(() => isOutsides.every((v) => v.value));
|
||||
// 兼容旧版本API
|
||||
const normalizedOptions: HoverDelayOptions =
|
||||
typeof delay === 'number' || isFunction(delay)
|
||||
? { enterDelay: DEFAULT_ENTER_DELAY, leaveDelay: delay }
|
||||
: {
|
||||
enterDelay: DEFAULT_ENTER_DELAY,
|
||||
leaveDelay: DEFAULT_LEAVE_DELAY,
|
||||
...delay,
|
||||
};
|
||||
|
||||
function setValueDelay(val: boolean) {
|
||||
timer.value && clearTimeout(timer.value);
|
||||
timer.value = setTimeout(
|
||||
() => {
|
||||
value.value = val;
|
||||
timer.value = undefined;
|
||||
},
|
||||
isFunction(delay) ? delay() : delay,
|
||||
);
|
||||
const value = ref(false);
|
||||
const enterTimer = ref<ReturnType<typeof setTimeout> | undefined>();
|
||||
const leaveTimer = ref<ReturnType<typeof setTimeout> | undefined>();
|
||||
const hoverScopes = ref<ReturnType<typeof effectScope>[]>([]);
|
||||
|
||||
// 使用计算属性包装 refElement,使其响应式变化
|
||||
const refs = computed(() => {
|
||||
const raw = unref(refElement);
|
||||
if (raw === null) return [];
|
||||
return Array.isArray(raw) ? raw : [raw];
|
||||
});
|
||||
// 存储所有 hover 状态
|
||||
const isHovers = ref<Array<Ref<boolean>>>([]);
|
||||
|
||||
// 更新 hover 监听的函数
|
||||
function updateHovers() {
|
||||
// 停止并清理之前的作用域
|
||||
hoverScopes.value.forEach((scope) => scope.stop());
|
||||
hoverScopes.value = [];
|
||||
|
||||
isHovers.value = refs.value.map((refEle) => {
|
||||
if (!refEle) {
|
||||
return ref(false);
|
||||
}
|
||||
const eleRef = computed(() => {
|
||||
const ele = unref(refEle);
|
||||
return ele instanceof Element ? ele : (ele?.$el as Element);
|
||||
});
|
||||
|
||||
// 为每个元素创建独立的作用域
|
||||
const scope = effectScope();
|
||||
const hoverRef = scope.run(() => useElementHover(eleRef)) || ref(false);
|
||||
hoverScopes.value.push(scope);
|
||||
|
||||
return hoverRef;
|
||||
});
|
||||
}
|
||||
|
||||
const watcher = watch(
|
||||
// 监听元素数量变化,避免过度执行
|
||||
const elementsCount = computed(() => {
|
||||
const raw = unref(refElement);
|
||||
if (raw === null) return 0;
|
||||
return Array.isArray(raw) ? raw.length : 1;
|
||||
});
|
||||
|
||||
// 初始设置
|
||||
updateHovers();
|
||||
|
||||
// 只在元素数量变化时重新设置监听器
|
||||
const stopWatcher = watch(elementsCount, updateHovers, { deep: false });
|
||||
|
||||
const isOutsideAll = computed(() => isHovers.value.every((v) => !v.value));
|
||||
|
||||
function clearTimers() {
|
||||
if (enterTimer.value) {
|
||||
clearTimeout(enterTimer.value);
|
||||
enterTimer.value = undefined;
|
||||
}
|
||||
if (leaveTimer.value) {
|
||||
clearTimeout(leaveTimer.value);
|
||||
leaveTimer.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function setValueDelay(val: boolean) {
|
||||
clearTimers();
|
||||
|
||||
if (val) {
|
||||
// 鼠标进入
|
||||
const enterDelay = normalizedOptions.enterDelay ?? DEFAULT_ENTER_DELAY;
|
||||
const delayTime = isFunction(enterDelay) ? enterDelay() : enterDelay;
|
||||
|
||||
if (delayTime <= 0) {
|
||||
value.value = true;
|
||||
} else {
|
||||
enterTimer.value = setTimeout(() => {
|
||||
value.value = true;
|
||||
enterTimer.value = undefined;
|
||||
}, delayTime);
|
||||
}
|
||||
} else {
|
||||
// 鼠标离开
|
||||
const leaveDelay = normalizedOptions.leaveDelay ?? DEFAULT_LEAVE_DELAY;
|
||||
const delayTime = isFunction(leaveDelay) ? leaveDelay() : leaveDelay;
|
||||
|
||||
if (delayTime <= 0) {
|
||||
value.value = false;
|
||||
} else {
|
||||
leaveTimer.value = setTimeout(() => {
|
||||
value.value = false;
|
||||
leaveTimer.value = undefined;
|
||||
}, delayTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hoverWatcher = watch(
|
||||
isOutsideAll,
|
||||
(val) => {
|
||||
setValueDelay(!val);
|
||||
@@ -48,15 +144,19 @@ export function useHoverToggle(
|
||||
|
||||
const controller = {
|
||||
enable() {
|
||||
watcher.resume();
|
||||
hoverWatcher.resume();
|
||||
},
|
||||
disable() {
|
||||
watcher.pause();
|
||||
hoverWatcher.pause();
|
||||
},
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
timer.value && clearTimeout(timer.value);
|
||||
tryOnScopeDispose(() => {
|
||||
clearTimers();
|
||||
// 停止监听器
|
||||
stopWatcher();
|
||||
// 停止所有剩余的作用域
|
||||
hoverScopes.value.forEach((scope) => scope.stop());
|
||||
});
|
||||
|
||||
return [value, controller] as [typeof value, typeof controller];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Ref } from 'vue';
|
||||
import { computed, ref, unref } from 'vue';
|
||||
|
||||
import { computed, ref, unref, watch } from 'vue';
|
||||
|
||||
/**
|
||||
* Paginates an array of items
|
||||
@@ -21,7 +22,11 @@ function pagination<T = any>(list: T[], pageNo: number, pageSize: number): T[] {
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function usePagination<T = any>(list: Ref<T[]>, pageSize: number) {
|
||||
export function usePagination<T = any>(
|
||||
list: Ref<T[]>,
|
||||
pageSize: number,
|
||||
totalChangeToFirstPage = true,
|
||||
) {
|
||||
const currentPage = ref(1);
|
||||
const pageSizeRef = ref(pageSize);
|
||||
|
||||
@@ -37,11 +42,21 @@ export function usePagination<T = any>(list: Ref<T[]>, pageSize: number) {
|
||||
return unref(list).length;
|
||||
});
|
||||
|
||||
if (totalChangeToFirstPage) {
|
||||
watch(total, () => {
|
||||
setCurrentPage(1);
|
||||
});
|
||||
}
|
||||
|
||||
function setCurrentPage(page: number) {
|
||||
if (page < 1 || page > unref(totalPages)) {
|
||||
throw new Error('Invalid page number');
|
||||
if (page === 1 && unref(totalPages) === 0) {
|
||||
currentPage.value = 1;
|
||||
} else {
|
||||
if (page < 1 || page > unref(totalPages)) {
|
||||
throw new Error('Invalid page number');
|
||||
}
|
||||
currentPage.value = page;
|
||||
}
|
||||
currentPage.value = page;
|
||||
}
|
||||
|
||||
function setPageSize(pageSize: number) {
|
||||
@@ -53,5 +68,5 @@ export function usePagination<T = any>(list: Ref<T[]>, pageSize: number) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
return { setCurrentPage, total, setPageSize, paginationList };
|
||||
return { setCurrentPage, total, setPageSize, paginationList, currentPage };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { type RouteLocationNormalized, useRoute, useRouter } from 'vue-router';
|
||||
import type { ComputedRef } from 'vue';
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useTabbarStore } from '@vben/stores';
|
||||
|
||||
@@ -39,19 +42,36 @@ export function useTabs() {
|
||||
await tabbarStore.toggleTabPin(tab || route);
|
||||
}
|
||||
|
||||
async function refreshTab() {
|
||||
await tabbarStore.refresh(router);
|
||||
async function refreshTab(name?: string) {
|
||||
await tabbarStore.refresh(name || router);
|
||||
}
|
||||
|
||||
async function openTabInNewWindow(tab?: RouteLocationNormalized) {
|
||||
await tabbarStore.openTabInNewWindow(tab || route);
|
||||
await tabbarStore.openTabInNewWindow(tab || route, router);
|
||||
}
|
||||
|
||||
async function closeTabByKey(key: string) {
|
||||
await tabbarStore.closeTabByKey(key, router);
|
||||
}
|
||||
|
||||
async function setTabTitle(title: string) {
|
||||
/**
|
||||
* 设置当前标签页的标题
|
||||
*
|
||||
* @description 支持设置静态标题字符串或动态计算标题
|
||||
* @description 动态标题会在每次渲染时重新计算,适用于多语言或状态相关的标题
|
||||
*
|
||||
* @param title - 标题内容
|
||||
* - 静态标题: 直接传入字符串
|
||||
* - 动态标题: 传入 ComputedRef
|
||||
*
|
||||
* @example
|
||||
* // 静态标题
|
||||
* setTabTitle('标签页')
|
||||
*
|
||||
* // 动态标题(多语言)
|
||||
* setTabTitle(computed(() => t('page.title')))
|
||||
*/
|
||||
async function setTabTitle(title: ComputedRef<string> | string) {
|
||||
tabbarStore.setUpdateTime();
|
||||
await tabbarStore.setTabTitle(route, title);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { Watermark, WatermarkOptions } from 'watermark-js-plus';
|
||||
|
||||
import { nextTick, onUnmounted, readonly, ref } from 'vue';
|
||||
|
||||
import { updatePreferences } from '@vben/preferences';
|
||||
|
||||
const watermark = ref<Watermark>();
|
||||
const unmountedHooked = ref<boolean>(false);
|
||||
const cachedOptions = ref<Partial<WatermarkOptions>>({
|
||||
@@ -50,7 +48,6 @@ export function useWatermark() {
|
||||
// 这里填写水印颜色
|
||||
cachedOptions.value.globalAlpha = 0.15;
|
||||
watermark.value = new Watermark(cachedOptions.value);
|
||||
updatePreferences({ app: { watermark: true } });
|
||||
await watermark.value?.create();
|
||||
}
|
||||
|
||||
@@ -71,7 +68,6 @@ export function useWatermark() {
|
||||
watermark.value.destroy();
|
||||
watermark.value = undefined;
|
||||
}
|
||||
updatePreferences({ app: { watermark: false } });
|
||||
}
|
||||
|
||||
// 只在第一次调用时注册卸载钩子,防止重复注册以致于在路由切换时销毁了水印
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@vben/layouts",
|
||||
"version": "5.5.1",
|
||||
"version": "5.7.0",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
@@ -20,7 +20,9 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/sortablejs": "catalog:",
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/design": "workspace:*",
|
||||
"@vben-core/form-ui": "workspace:*",
|
||||
"@vben-core/layout-ui": "workspace:*",
|
||||
"@vben-core/menu-ui": "workspace:*",
|
||||
@@ -37,6 +39,7 @@
|
||||
"@vben/types": "workspace:*",
|
||||
"@vben/utils": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"sortablejs": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import type { ToolbarType } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
|
||||
import { Copyright } from '../basic/copyright';
|
||||
@@ -11,6 +13,7 @@ import Toolbar from './toolbar.vue';
|
||||
interface Props {
|
||||
appName?: string;
|
||||
logo?: string;
|
||||
logoDark?: string;
|
||||
pageTitle?: string;
|
||||
pageDescription?: string;
|
||||
sloganImage?: string;
|
||||
@@ -18,28 +21,43 @@ interface Props {
|
||||
toolbar?: boolean;
|
||||
copyright?: boolean;
|
||||
toolbarList?: ToolbarType[];
|
||||
clickLogo?: () => void;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
appName: '',
|
||||
copyright: true,
|
||||
logo: '',
|
||||
logoDark: '',
|
||||
pageDescription: '',
|
||||
pageTitle: '',
|
||||
sloganImage: '',
|
||||
version: '',
|
||||
toolbar: true,
|
||||
toolbarList: () => ['color', 'language', 'layout', 'theme'],
|
||||
clickLogo: () => {},
|
||||
});
|
||||
|
||||
const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
usePreferences();
|
||||
|
||||
/**
|
||||
* @zh_CN 根据主题选择合适的 logo 图标
|
||||
*/
|
||||
const logoSrc = computed(() => {
|
||||
// 如果是暗色主题且提供了 logoDark,则使用暗色主题的 logo
|
||||
if (isDark.value && props.logoDark) {
|
||||
return props.logoDark;
|
||||
}
|
||||
// 否则使用默认的 logo
|
||||
return props.logo;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[isDark]"
|
||||
class="flex min-h-full flex-1 select-none overflow-x-hidden"
|
||||
:class="[isDark ? 'dark' : '']"
|
||||
class="flex min-h-full flex-1 overflow-x-hidden select-none"
|
||||
>
|
||||
<template v-if="toolbar">
|
||||
<slot name="toolbar">
|
||||
@@ -50,7 +68,7 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
<AuthenticationFormView
|
||||
v-if="authPanelLeft"
|
||||
class="min-h-full w-2/5 flex-1"
|
||||
transition-name="slide-left"
|
||||
data-side="left"
|
||||
>
|
||||
<template v-if="copyright" #copyright>
|
||||
<slot name="copyright">
|
||||
@@ -62,40 +80,62 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
</template>
|
||||
</AuthenticationFormView>
|
||||
|
||||
<!-- 头部 Logo 和应用名称 -->
|
||||
<div v-if="logo || appName" class="absolute left-0 top-0 z-10 flex flex-1">
|
||||
<slot name="logo">
|
||||
<!-- 头部 Logo 和应用名称 -->
|
||||
<div
|
||||
class="text-foreground lg:text-foreground ml-4 mt-4 flex flex-1 items-center sm:left-6 sm:top-6"
|
||||
v-if="logoSrc || appName"
|
||||
class="absolute top-0 left-0 z-10 flex flex-1"
|
||||
@click="clickLogo"
|
||||
>
|
||||
<img v-if="logo" :alt="appName" :src="logo" class="mr-2" width="42" />
|
||||
<p v-if="appName" class="app-version text-xl font-medium">
|
||||
{{ appName }}
|
||||
<b>
|
||||
<i>V {{ version }}</i>
|
||||
</b>
|
||||
</p>
|
||||
<div
|
||||
class="mt-4 ml-4 flex flex-1 items-center text-foreground sm:top-6 sm:left-6 lg:text-foreground"
|
||||
>
|
||||
<img
|
||||
v-if="logoSrc"
|
||||
:key="logoSrc"
|
||||
:alt="appName"
|
||||
:src="logoSrc"
|
||||
class="mr-2"
|
||||
width="42"
|
||||
/>
|
||||
<!-- 暂时适应远程LOGO-->
|
||||
<!-- <img v-if="logo" :alt="appName" :src="logo" class="mr-2" width="42" />-->
|
||||
<p v-if="appName" class="m-0 text-xl font-medium">
|
||||
{{ appName }}
|
||||
<b>
|
||||
<i>V {{ version }}</i>
|
||||
</b>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- 系统介绍 -->
|
||||
<div v-if="!authPanelCenter" class="relative hidden w-0 flex-1 lg:block">
|
||||
<div
|
||||
class="bg-background-deep absolute inset-0 h-full w-full dark:bg-[#070709]"
|
||||
class="absolute inset-0 size-full bg-background-deep dark:bg-[#070709]"
|
||||
>
|
||||
<div class="login-background absolute left-0 top-0 size-full"></div>
|
||||
<div class="flex-col-center -enter-x mr-20 h-full">
|
||||
<div class="login-background absolute top-0 left-0 size-full"></div>
|
||||
<div
|
||||
:key="authPanelLeft ? 'left' : authPanelRight ? 'right' : 'center'"
|
||||
class="mr-20 flex-col-center h-full"
|
||||
:class="{
|
||||
'enter-x': authPanelLeft,
|
||||
'-enter-x': authPanelRight,
|
||||
}"
|
||||
>
|
||||
<template v-if="sloganImage">
|
||||
<img
|
||||
:alt="appName"
|
||||
:src="sloganImage"
|
||||
class="animate-float h-64 w-2/5"
|
||||
class="h-64 w-2/5 animate-float"
|
||||
/>
|
||||
</template>
|
||||
<SloganIcon v-else :alt="appName" class="animate-float h-64 w-2/5" />
|
||||
<div class="text-1xl text-foreground mt-6 font-sans lg:text-2xl">
|
||||
<SloganIcon v-else :alt="appName" class="h-64 w-2/5 animate-float" />
|
||||
<div class="text-1xl mt-6 font-sans text-foreground lg:text-2xl">
|
||||
{{ pageTitle }}
|
||||
</div>
|
||||
<div class="dark:text-muted-foreground mt-2">
|
||||
<div class="mt-2 dark:text-muted-foreground">
|
||||
{{ pageDescription }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,10 +143,11 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
</div>
|
||||
|
||||
<!-- 中心认证面板 -->
|
||||
<div v-if="authPanelCenter" class="flex-center relative w-full">
|
||||
<div class="login-background absolute left-0 top-0 size-full"></div>
|
||||
<div v-if="authPanelCenter" class="relative flex-center w-full">
|
||||
<div class="login-background absolute top-0 left-0 size-full"></div>
|
||||
<AuthenticationFormView
|
||||
class="md:bg-background shadow-primary/5 shadow-float w-full rounded-3xl pb-20 md:w-2/3 lg:w-1/2 xl:w-[36%]"
|
||||
class="w-full rounded-3xl pb-20 shadow-float shadow-primary/5 md:w-2/3 md:bg-background lg:w-1/2 xl:w-[36%]"
|
||||
data-side="bottom"
|
||||
>
|
||||
<template v-if="copyright" #copyright>
|
||||
<slot name="copyright">
|
||||
@@ -122,7 +163,8 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
<!-- 右侧认证面板 -->
|
||||
<AuthenticationFormView
|
||||
v-if="authPanelRight"
|
||||
class="min-h-full w-[34%] flex-1"
|
||||
class="min-h-full w-2/5 flex-1"
|
||||
data-side="right"
|
||||
>
|
||||
<template v-if="copyright" #copyright>
|
||||
<slot name="copyright">
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
defineOptions({
|
||||
name: 'AuthenticationFormView',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
dataSide?: 'bottom' | 'left' | 'right' | 'top';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-col-center dark:bg-background-deep bg-background relative px-6 py-10 lg:flex-initial lg:px-8"
|
||||
class="relative flex-col-center bg-background px-6 py-10 lg:flex-initial lg:px-8 dark:bg-background-deep"
|
||||
>
|
||||
<slot></slot>
|
||||
<!-- Router View with Transition and KeepAlive -->
|
||||
@@ -16,7 +20,8 @@ defineOptions({
|
||||
<component
|
||||
:is="Component"
|
||||
:key="route.fullPath"
|
||||
class="enter-x mt-6 w-full sm:mx-auto md:max-w-md"
|
||||
class="side-content mt-6 w-full sm:mx-auto md:max-w-md"
|
||||
:data-side="dataSide"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
@@ -25,7 +30,7 @@ defineOptions({
|
||||
<!-- Footer Copyright -->
|
||||
|
||||
<div
|
||||
class="text-muted-foreground absolute bottom-3 flex text-center text-xs"
|
||||
class="absolute right-0 bottom-3 left-0 flex justify-center text-center text-xs text-muted-foreground"
|
||||
>
|
||||
<slot name="copyright"> </slot>
|
||||
</div>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as AuthPageLayout } from './authentication.vue';
|
||||
export * from './types';
|
||||
|
||||
@@ -33,9 +33,9 @@ const showTheme = computed(() => props.toolbarList.includes('theme'));
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'bg-accent rounded-3xl px-3 py-1': toolbarList.length > 1,
|
||||
'rounded-3xl bg-accent px-3 py-1': toolbarList.length > 1,
|
||||
}"
|
||||
class="flex-center absolute right-2 top-4 z-10"
|
||||
class="absolute top-4 right-2 z-10 flex-center"
|
||||
>
|
||||
<!-- Only show on medium and larger screens -->
|
||||
<div class="hidden md:flex">
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { App } from 'vue';
|
||||
import type { NavigationGuard, NavigationHookAfter, Router } from 'vue-router';
|
||||
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import { ELEMENT_ID_LAYOUT_SCROLL } from '@vben-core/shared/constants';
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useLayoutScroll } from '../use-layout-scroll';
|
||||
|
||||
let activeApp: App | undefined;
|
||||
|
||||
function createRouterMock() {
|
||||
let afterHook: NavigationHookAfter | undefined;
|
||||
let beforeGuard: NavigationGuard | undefined;
|
||||
const removeAfterHook = vi.fn();
|
||||
const removeBeforeGuard = vi.fn();
|
||||
|
||||
const router = {
|
||||
afterEach: vi.fn((hook: NavigationHookAfter) => {
|
||||
afterHook = hook;
|
||||
return removeAfterHook;
|
||||
}),
|
||||
beforeEach: vi.fn((guard: NavigationGuard) => {
|
||||
beforeGuard = guard;
|
||||
return removeBeforeGuard;
|
||||
}),
|
||||
} as unknown as Router;
|
||||
|
||||
function getHooks() {
|
||||
if (!afterHook || !beforeGuard) {
|
||||
throw new Error('Router hooks were not registered');
|
||||
}
|
||||
return { afterHook, beforeGuard };
|
||||
}
|
||||
|
||||
return {
|
||||
getHooks,
|
||||
removeAfterHook,
|
||||
removeBeforeGuard,
|
||||
router,
|
||||
};
|
||||
}
|
||||
|
||||
function createScrollElement() {
|
||||
const element = document.createElement('div');
|
||||
element.id = ELEMENT_ID_LAYOUT_SCROLL;
|
||||
element.scrollTo = vi.fn();
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function mountLayoutScroll(router: Router) {
|
||||
const host = document.createElement('div');
|
||||
document.body.append(host);
|
||||
activeApp = createApp({
|
||||
setup() {
|
||||
useLayoutScroll(router);
|
||||
return () => null;
|
||||
},
|
||||
});
|
||||
activeApp.mount(host);
|
||||
}
|
||||
|
||||
async function runBeforeGuard(guard: NavigationGuard) {
|
||||
await guard({} as never, {} as never, vi.fn());
|
||||
}
|
||||
|
||||
async function runAfterHook(hook: NavigationHookAfter, hash = '') {
|
||||
await hook({ hash } as never, {} as never, undefined);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
activeApp?.unmount();
|
||||
activeApp = undefined;
|
||||
document.body.innerHTML = '';
|
||||
window.history.replaceState({}, '');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('useLayoutScroll', () => {
|
||||
it('should scroll to top after a normal navigation', async () => {
|
||||
window.history.replaceState({ position: 0 }, '');
|
||||
const element = createScrollElement();
|
||||
element.scrollTop = 240;
|
||||
const routerMock = createRouterMock();
|
||||
mountLayoutScroll(routerMock.router);
|
||||
const { afterHook, beforeGuard } = routerMock.getHooks();
|
||||
|
||||
await runBeforeGuard(beforeGuard);
|
||||
window.history.replaceState({ position: 1 }, '');
|
||||
await runAfterHook(afterHook);
|
||||
|
||||
expect(element.scrollTo).toHaveBeenCalledWith({ top: 0 });
|
||||
});
|
||||
|
||||
it('should scroll a hash target into view', async () => {
|
||||
window.history.replaceState({ position: 0 }, '');
|
||||
const element = createScrollElement();
|
||||
const hashTarget = document.createElement('div');
|
||||
hashTarget.id = 'section';
|
||||
hashTarget.scrollIntoView = vi.fn();
|
||||
element.append(hashTarget);
|
||||
const routerMock = createRouterMock();
|
||||
mountLayoutScroll(routerMock.router);
|
||||
const { afterHook } = routerMock.getHooks();
|
||||
|
||||
window.history.replaceState({ position: 1 }, '');
|
||||
await runAfterHook(afterHook, '#section');
|
||||
|
||||
expect(hashTarget.scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
expect(element.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore matching hash targets outside the layout', async () => {
|
||||
window.history.replaceState({ position: 0 }, '');
|
||||
const hostTarget = document.createElement('div');
|
||||
hostTarget.id = 'section';
|
||||
hostTarget.scrollIntoView = vi.fn();
|
||||
document.body.append(hostTarget);
|
||||
const element = createScrollElement();
|
||||
const layoutTarget = document.createElement('div');
|
||||
layoutTarget.id = 'section';
|
||||
layoutTarget.scrollIntoView = vi.fn();
|
||||
element.append(layoutTarget);
|
||||
const routerMock = createRouterMock();
|
||||
mountLayoutScroll(routerMock.router);
|
||||
const { afterHook } = routerMock.getHooks();
|
||||
|
||||
window.history.replaceState({ position: 1 }, '');
|
||||
await runAfterHook(afterHook, '#section');
|
||||
|
||||
expect(layoutTarget.scrollIntoView).toHaveBeenCalledWith({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
expect(hostTarget.scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should restore a saved position on history navigation', async () => {
|
||||
window.history.replaceState({ position: 0 }, '');
|
||||
const element = createScrollElement();
|
||||
element.scrollTop = 240;
|
||||
const routerMock = createRouterMock();
|
||||
mountLayoutScroll(routerMock.router);
|
||||
const { afterHook, beforeGuard } = routerMock.getHooks();
|
||||
|
||||
await runBeforeGuard(beforeGuard);
|
||||
window.history.replaceState({ position: 1 }, '');
|
||||
await runAfterHook(afterHook);
|
||||
|
||||
element.scrollTop = 80;
|
||||
window.history.replaceState({ position: 0 }, '');
|
||||
await runBeforeGuard(beforeGuard);
|
||||
await runAfterHook(afterHook);
|
||||
|
||||
expect(element.scrollTo).toHaveBeenLastCalledWith({ top: 240 });
|
||||
});
|
||||
|
||||
it('should remove router hooks when the scope is disposed', () => {
|
||||
window.history.replaceState({ position: 0 }, '');
|
||||
createScrollElement();
|
||||
const routerMock = createRouterMock();
|
||||
mountLayoutScroll(routerMock.router);
|
||||
|
||||
activeApp?.unmount();
|
||||
activeApp = undefined;
|
||||
|
||||
expect(routerMock.removeBeforeGuard).toHaveBeenCalledOnce();
|
||||
expect(routerMock.removeAfterHook).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
RouteLocationNormalizedLoaded,
|
||||
RouteLocationNormalizedLoadedGeneric,
|
||||
} from 'vue-router';
|
||||
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
|
||||
|
||||
import { type VNode } from 'vue';
|
||||
import { unref } from 'vue';
|
||||
import { RouterView } from 'vue-router';
|
||||
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { storeToRefs, useTabbarStore } from '@vben/stores';
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
import { getTabKey, storeToRefs, useTabbarStore } from '@vben/stores';
|
||||
|
||||
import { transformComponent, useLayoutHook } from '../../hooks';
|
||||
import { IFrameRouterView } from '../../iframe';
|
||||
import { RouteCachedPage, RouteCachedView } from '../../route-cached';
|
||||
|
||||
defineOptions({ name: 'LayoutContent' });
|
||||
|
||||
@@ -20,77 +19,33 @@ const { keepAlive } = usePreferences();
|
||||
const { getCachedTabs, getExcludeCachedTabs, renderRouteView } =
|
||||
storeToRefs(tabbarStore);
|
||||
|
||||
// 页面切换动画
|
||||
function getTransitionName(_route: RouteLocationNormalizedLoaded) {
|
||||
// 如果偏好设置未设置,则不使用动画
|
||||
const { tabbar, transition } = preferences;
|
||||
const transitionName = transition.name;
|
||||
if (!transitionName || !transition.enable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标签页未启用或者未开启缓存,则使用全局配置动画
|
||||
if (!tabbar.enable || !keepAlive) {
|
||||
return transitionName;
|
||||
}
|
||||
|
||||
// 如果页面已经加载过,则不使用动画
|
||||
// if (route.meta.loaded) {
|
||||
// return;
|
||||
// }
|
||||
// 已经打开且已经加载过的页面不使用动画
|
||||
// const inTabs = getCachedTabs.value.includes(route.name as string);
|
||||
|
||||
// return inTabs && route.meta.loaded ? undefined : transitionName;
|
||||
return transitionName;
|
||||
}
|
||||
const { getEnabledTransition, getTransitionName } = useLayoutHook();
|
||||
|
||||
/**
|
||||
* 转换组件,自动添加 name
|
||||
* @param component
|
||||
* 是否显示component
|
||||
* @param route
|
||||
*/
|
||||
function transformComponent(
|
||||
component: VNode,
|
||||
route: RouteLocationNormalizedLoadedGeneric,
|
||||
) {
|
||||
// 组件视图未找到,如果有设置后备视图,则返回后备视图,如果没有,则抛出错误
|
||||
if (!component) {
|
||||
console.error(
|
||||
'Component view not found,please check the route configuration',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const routeName = route.name as string;
|
||||
// 如果组件没有 name,则直接返回
|
||||
if (!routeName) {
|
||||
return component;
|
||||
}
|
||||
const componentName = (component?.type as any)?.name;
|
||||
|
||||
// 已经设置过 name,则直接返回
|
||||
if (componentName) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// componentName 与 routeName 一致,则直接返回
|
||||
if (componentName === routeName) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// 设置 name
|
||||
component.type ||= {};
|
||||
(component.type as any).name = routeName;
|
||||
|
||||
return component;
|
||||
}
|
||||
const showComponent = (route: RouteLocationNormalizedLoadedGeneric) => {
|
||||
return !route.meta.domCached && unref(renderRouteView);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<div class="page-route-container relative h-full">
|
||||
<IFrameRouterView />
|
||||
<RouteCachedView />
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition :name="getTransitionName(route)" appear mode="out-in">
|
||||
<RouteCachedPage
|
||||
:component="Component"
|
||||
:route="route"
|
||||
v-if="route.meta.domCached"
|
||||
/>
|
||||
<Transition
|
||||
v-if="getEnabledTransition"
|
||||
:name="getTransitionName(route)"
|
||||
appear
|
||||
:leave-active-class="`${getTransitionName(route)}-leave-active page-route-leave-active`"
|
||||
>
|
||||
<KeepAlive
|
||||
v-if="keepAlive"
|
||||
:exclude="getExcludeCachedTabs"
|
||||
@@ -98,17 +53,60 @@ function transformComponent(
|
||||
>
|
||||
<component
|
||||
:is="transformComponent(Component, route)"
|
||||
v-if="renderRouteView"
|
||||
v-if="showComponent(route)"
|
||||
v-show="!route.meta.iframeSrc"
|
||||
:key="route.fullPath"
|
||||
:key="getTabKey(route)"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component
|
||||
:is="Component"
|
||||
v-else-if="renderRouteView"
|
||||
:key="route.fullPath"
|
||||
v-else-if="showComponent(route)"
|
||||
:key="getTabKey(route)"
|
||||
/>
|
||||
</Transition>
|
||||
<template v-else>
|
||||
<KeepAlive
|
||||
v-if="keepAlive"
|
||||
:exclude="getExcludeCachedTabs"
|
||||
:include="getCachedTabs"
|
||||
>
|
||||
<component
|
||||
:is="transformComponent(Component, route)"
|
||||
v-if="showComponent(route)"
|
||||
v-show="!route.meta.iframeSrc"
|
||||
:key="getTabKey(route)"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component
|
||||
:is="Component"
|
||||
v-else-if="showComponent(route)"
|
||||
:key="getTabKey(route)"
|
||||
/>
|
||||
</template>
|
||||
</RouterView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 优化router动画切换切换重叠问题 */
|
||||
.page-route-leave-active {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 移除动画切换的横向滚动条 translateX(-30px) */
|
||||
.page-route-container:has(> .fade-slide-enter-active),
|
||||
.page-route-container:has(> .fade-slide-leave-active) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* 移除动画切换的纵向滚动条 */
|
||||
.page-route-container:has(> .fade-up-enter-active),
|
||||
.page-route-container:has(> .fade-up-leave-active),
|
||||
.page-route-container:has(> .fade-down-enter-active),
|
||||
.page-route-container:has(> .fade-down-leave-active) {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as LayoutContent } from './content.vue';
|
||||
export { default as LayoutContentSpinner } from './content-spinner.vue';
|
||||
export { default as LayoutContent } from './content.vue';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
companyName: string;
|
||||
companyName?: string;
|
||||
companySiteLink?: string;
|
||||
date: string;
|
||||
date?: string;
|
||||
icp?: string;
|
||||
icpLink?: string;
|
||||
}
|
||||
@@ -25,8 +25,9 @@ withDefaults(defineProps<Props>(), {
|
||||
<!-- ICP Link -->
|
||||
<a
|
||||
v-if="icp"
|
||||
:href="icpLink || 'javascript:void(0)'"
|
||||
class="hover:text-primary-hover mx-1"
|
||||
:href="icpLink || 'https://beian.miit.gov.cn/'"
|
||||
class="mx-1 hover:text-primary-hover"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ icp }}
|
||||
@@ -39,7 +40,7 @@ withDefaults(defineProps<Props>(), {
|
||||
<a
|
||||
v-if="companyName"
|
||||
:href="companySiteLink || 'javascript:void(0)'"
|
||||
class="hover:text-primary-hover mx-1"
|
||||
class="mx-1 hover:text-primary-hover"
|
||||
target="_blank"
|
||||
>
|
||||
{{ companyName }}
|
||||
|
||||
@@ -5,7 +5,7 @@ defineOptions({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-center text-muted-foreground relative h-full w-full text-xs">
|
||||
<div class="relative flex-center size-full text-xs text-muted-foreground">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,23 +2,43 @@
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
import { useRefresh } from '@vben/hooks';
|
||||
import { RotateCw } from '@vben/icons';
|
||||
import { LockKeyhole, LogOut, RotateCw } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
import { VbenFullScreen, VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useVbenModal } from '@vben-core/popup-ui';
|
||||
import {
|
||||
VbenFullScreen,
|
||||
VbenIconButton,
|
||||
VbenTooltip,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useMagicKeys, whenever } from '@vueuse/core';
|
||||
|
||||
import {
|
||||
GlobalSearch,
|
||||
LanguageToggle,
|
||||
LockScreenModal,
|
||||
Notification,
|
||||
PreferencesButton,
|
||||
ThemeToggle,
|
||||
TimezoneButton,
|
||||
} from '../../widgets';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 头像
|
||||
*/
|
||||
avatar?: string;
|
||||
/**
|
||||
* Logo 主题
|
||||
*/
|
||||
theme?: string;
|
||||
/**
|
||||
* 用户文本
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
@@ -26,92 +46,240 @@ defineOptions({
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
avatar: '',
|
||||
theme: 'light',
|
||||
text: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
|
||||
const emit = defineEmits<{
|
||||
clearPreferencesAndLogout: [];
|
||||
logout: [];
|
||||
openLockScreen: [];
|
||||
}>();
|
||||
|
||||
const REFERENCE_VALUE = 50;
|
||||
const REFERENCE_VALUE = 100;
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const { globalSearchShortcutKey, preferencesButtonPosition } = usePreferences();
|
||||
const {
|
||||
globalLockScreenShortcutKey,
|
||||
globalLogoutShortcutKey,
|
||||
globalSearchShortcutKey,
|
||||
preferencesButtonPosition,
|
||||
} = usePreferences();
|
||||
const slots = useSlots();
|
||||
const { refresh } = useRefresh();
|
||||
|
||||
const showLockInHeader = computed(
|
||||
() =>
|
||||
preferences.widget.lockScreen &&
|
||||
preferences.widget.lockScreenButtonPosition === 'header',
|
||||
);
|
||||
|
||||
const showLogoutInHeader = computed(
|
||||
() => preferences.widget.logoutButtonPosition === 'header',
|
||||
);
|
||||
|
||||
const enableLockScreenShortcutKey = computed(() => {
|
||||
return showLockInHeader.value && globalLockScreenShortcutKey.value;
|
||||
});
|
||||
|
||||
const enableLogoutShortcutKey = computed(() => {
|
||||
return showLogoutInHeader.value && globalLogoutShortcutKey.value;
|
||||
});
|
||||
|
||||
const [LockModal, lockModalApi] = useVbenModal({
|
||||
connectedComponent: LockScreenModal,
|
||||
});
|
||||
const [LogoutModal, logoutModalApi] = useVbenModal({
|
||||
onConfirm() {
|
||||
handleSubmitLogout();
|
||||
},
|
||||
});
|
||||
|
||||
function handleOpenLock() {
|
||||
lockModalApi.open();
|
||||
}
|
||||
|
||||
function handleSubmitLock(lockScreenPassword: string) {
|
||||
lockModalApi.close();
|
||||
accessStore.lockScreen(lockScreenPassword);
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logoutModalApi.open();
|
||||
}
|
||||
|
||||
function handleSubmitLogout() {
|
||||
emit('logout');
|
||||
logoutModalApi.close();
|
||||
}
|
||||
|
||||
// 快捷键
|
||||
if (preferences.shortcutKeys.enable) {
|
||||
const keys = useMagicKeys();
|
||||
const lockKey = keys['Alt+KeyL'];
|
||||
const logoutKey = keys['Alt+KeyQ'];
|
||||
|
||||
if (lockKey) {
|
||||
whenever(lockKey, () => {
|
||||
if (enableLockScreenShortcutKey.value) {
|
||||
handleOpenLock();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (logoutKey) {
|
||||
whenever(logoutKey, () => {
|
||||
if (enableLogoutShortcutKey.value) {
|
||||
handleLogout();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 插槽列表类型
|
||||
*/
|
||||
type SlotItem = { index: number; name: string };
|
||||
|
||||
const rightSlots = computed(() => {
|
||||
const list = [{ index: REFERENCE_VALUE + 100, name: 'user-dropdown' }];
|
||||
if (preferences.widget.globalSearch) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE,
|
||||
name: 'global-search',
|
||||
});
|
||||
}
|
||||
|
||||
if (preferencesButtonPosition.value.header) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 10,
|
||||
name: 'preferences',
|
||||
});
|
||||
}
|
||||
if (preferences.widget.themeToggle) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 20,
|
||||
name: 'theme-toggle',
|
||||
});
|
||||
}
|
||||
if (preferences.widget.languageToggle) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 30,
|
||||
name: 'language-toggle',
|
||||
});
|
||||
}
|
||||
if (preferences.widget.fullscreen) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 40,
|
||||
name: 'fullscreen',
|
||||
});
|
||||
}
|
||||
if (preferences.widget.notification) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 50,
|
||||
name: 'notification',
|
||||
});
|
||||
const list: Array<SlotItem> = [];
|
||||
|
||||
// 按 widget.order 顺序遍历,检查每个部件是否应在 header 中显示
|
||||
const widgetChecks: Record<string, { slotName: string; visible: boolean }> = {
|
||||
globalSearch: {
|
||||
visible:
|
||||
preferences.widget.globalSearch &&
|
||||
preferences.widget.globalSearchButtonPosition === 'header',
|
||||
slotName: 'global-search',
|
||||
},
|
||||
preferences: {
|
||||
visible: preferencesButtonPosition.value.header,
|
||||
slotName: 'preferences',
|
||||
},
|
||||
themeToggle: {
|
||||
visible:
|
||||
preferences.widget.themeToggle &&
|
||||
preferences.widget.themeToggleButtonPosition === 'header',
|
||||
slotName: 'theme-toggle',
|
||||
},
|
||||
languageToggle: {
|
||||
visible:
|
||||
preferences.widget.languageToggle &&
|
||||
preferences.widget.languageToggleButtonPosition === 'header',
|
||||
slotName: 'language-toggle',
|
||||
},
|
||||
timezone: {
|
||||
visible:
|
||||
preferences.widget.timezone &&
|
||||
preferences.widget.timezoneButtonPosition === 'header',
|
||||
slotName: 'timezone',
|
||||
},
|
||||
fullscreen: {
|
||||
visible:
|
||||
preferences.widget.fullscreen &&
|
||||
preferences.widget.fullscreenButtonPosition === 'header',
|
||||
slotName: 'fullscreen',
|
||||
},
|
||||
refresh: {
|
||||
visible:
|
||||
preferences.widget.refresh &&
|
||||
preferences.widget.refreshButtonPosition === 'header',
|
||||
slotName: 'refresh',
|
||||
},
|
||||
notification: {
|
||||
visible:
|
||||
preferences.widget.notification &&
|
||||
preferences.widget.notificationButtonPosition === 'header',
|
||||
slotName: 'notification',
|
||||
},
|
||||
lockScreenBtn: {
|
||||
visible:
|
||||
preferences.widget.lockScreen &&
|
||||
preferences.widget.lockScreenButtonPosition === 'header',
|
||||
slotName: 'lock-screen-btn',
|
||||
},
|
||||
logoutBtn: {
|
||||
visible: preferences.widget.logoutButtonPosition === 'header',
|
||||
slotName: 'logout-btn',
|
||||
},
|
||||
};
|
||||
|
||||
for (const key of preferences.widget.order) {
|
||||
const check = widgetChecks[key];
|
||||
if (check?.visible) {
|
||||
list.push({ index: REFERENCE_VALUE + list.length, name: check.slotName });
|
||||
}
|
||||
}
|
||||
|
||||
// 用户插槽(header-right-N)追加在后面
|
||||
Object.keys(slots).forEach((key) => {
|
||||
const name = key.split('-');
|
||||
if (key.startsWith('header-right')) {
|
||||
list.push({ index: Number(name[2]), name: key });
|
||||
const slotIndex = Number(key.split('-')[2]);
|
||||
const index = Number.isNaN(slotIndex) ? nextIndex(list) : slotIndex;
|
||||
list.push({ index, name: key });
|
||||
}
|
||||
});
|
||||
return list.sort((a, b) => a.index - b.index);
|
||||
// 最后追加用户下拉框,若是索引值超过1000时则固定在1000(适配用户按钮不在最后的场景)
|
||||
const userDropdownIndex = Math.min(1000, nextIndex(list));
|
||||
list.push({ index: userDropdownIndex, name: 'user-dropdown' });
|
||||
// 按照索引排序,保证插槽顺序
|
||||
return list.toSorted((a, b) => a.index - b.index);
|
||||
});
|
||||
|
||||
const leftSlots = computed(() => {
|
||||
const list: Array<{ index: number; name: string }> = [];
|
||||
|
||||
if (preferences.widget.refresh) {
|
||||
list.push({
|
||||
index: 0,
|
||||
name: 'refresh',
|
||||
});
|
||||
}
|
||||
const list: Array<SlotItem> = [];
|
||||
|
||||
Object.keys(slots).forEach((key) => {
|
||||
const name = key.split('-');
|
||||
// 适配插槽名称,例如第一个插槽名:header-left-1
|
||||
if (key.startsWith('header-left')) {
|
||||
list.push({ index: Number(name[2]), name: key });
|
||||
// 取第三个占位的数字,若是第三个占位不是数字,则自动分配排序索引
|
||||
const slotIndex = Number(key.split('-')[2]);
|
||||
const index = Number.isNaN(slotIndex) ? nextIndex(list) : slotIndex;
|
||||
list.push({ index, name: key });
|
||||
}
|
||||
});
|
||||
return list.sort((a, b) => a.index - b.index);
|
||||
// 按照索引排序,保证插槽顺序
|
||||
return list.toSorted((a, b) => a.index - b.index);
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取列表下一个索引值(用于排序)
|
||||
* @param list 列表
|
||||
*/
|
||||
function nextIndex(list: Array<SlotItem>) {
|
||||
const index =
|
||||
list.length > 0 ? Math.max(...list.map((item) => item.index)) : 0;
|
||||
return index + 1;
|
||||
}
|
||||
|
||||
function clearPreferencesAndLogout() {
|
||||
emit('clearPreferencesAndLogout');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LockModal
|
||||
v-if="showLockInHeader"
|
||||
:avatar="avatar"
|
||||
:text="text"
|
||||
@submit="handleSubmitLock"
|
||||
/>
|
||||
|
||||
<LogoutModal
|
||||
v-if="showLogoutInHeader"
|
||||
:cancel-text="$t('common.cancel')"
|
||||
:confirm-text="$t('common.confirm')"
|
||||
:fullscreen-button="false"
|
||||
:title="$t('common.prompt')"
|
||||
centered
|
||||
content-class="px-8 min-h-10"
|
||||
footer-class="border-none mb-3 mr-3"
|
||||
header-class="border-none"
|
||||
>
|
||||
{{ $t('ui.widgets.logoutTip') }}
|
||||
</LogoutModal>
|
||||
|
||||
<template
|
||||
v-for="slot in leftSlots.filter((item) => item.index < REFERENCE_VALUE)"
|
||||
:key="slot.name"
|
||||
@@ -133,10 +301,13 @@ function clearPreferencesAndLogout() {
|
||||
>
|
||||
<slot :name="slot.name"></slot>
|
||||
</template>
|
||||
<div class="flex h-full min-w-0 flex-1 items-center">
|
||||
<div
|
||||
:class="`menu-align-${preferences.header.menuAlign}`"
|
||||
class="flex h-full min-w-0 flex-1 items-center"
|
||||
>
|
||||
<slot name="menu"></slot>
|
||||
</div>
|
||||
<div class="flex h-full min-w-0 flex-shrink-0 items-center">
|
||||
<div class="flex h-full min-w-0 shrink-0 items-center">
|
||||
<template v-for="slot in rightSlots" :key="slot.name">
|
||||
<slot :name="slot.name">
|
||||
<template v-if="slot.name === 'global-search'">
|
||||
@@ -148,21 +319,90 @@ function clearPreferencesAndLogout() {
|
||||
</template>
|
||||
|
||||
<template v-else-if="slot.name === 'preferences'">
|
||||
<PreferencesButton
|
||||
class="mr-1"
|
||||
@clear-preferences-and-logout="clearPreferencesAndLogout"
|
||||
/>
|
||||
<VbenTooltip side="bottom">
|
||||
<template #trigger>
|
||||
<PreferencesButton
|
||||
class="mr-1"
|
||||
@clear-preferences-and-logout="clearPreferencesAndLogout"
|
||||
/>
|
||||
</template>
|
||||
{{ $t('preferences.title') }}
|
||||
</VbenTooltip>
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'theme-toggle'">
|
||||
<ThemeToggle class="mr-1 mt-[2px]" />
|
||||
<VbenTooltip side="bottom">
|
||||
<template #trigger>
|
||||
<ThemeToggle class="mt-0.5 mr-1" />
|
||||
</template>
|
||||
{{ $t('preferences.theme.title') }}
|
||||
</VbenTooltip>
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'language-toggle'">
|
||||
<LanguageToggle class="mr-1" />
|
||||
<VbenTooltip side="bottom">
|
||||
<template #trigger>
|
||||
<LanguageToggle class="mr-1" />
|
||||
</template>
|
||||
{{ $t('preferences.widget.languageToggle') }}
|
||||
</VbenTooltip>
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'fullscreen'">
|
||||
<VbenFullScreen class="mr-1" />
|
||||
<VbenFullScreen
|
||||
class="mr-1"
|
||||
:tooltip="$t('preferences.widget.fullscreen')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'timezone'">
|
||||
<TimezoneButton class="mt-0.5 mr-1" />
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'lock-screen-btn'">
|
||||
<VbenIconButton
|
||||
class="mr-1"
|
||||
:tooltip="$t('ui.widgets.lockScreen.title')"
|
||||
@click="handleOpenLock"
|
||||
>
|
||||
<LockKeyhole class="size-4" />
|
||||
</VbenIconButton>
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'logout-btn'">
|
||||
<VbenIconButton
|
||||
class="mr-1"
|
||||
:tooltip="$t('common.logout')"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut class="size-4" />
|
||||
</VbenIconButton>
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'notification'">
|
||||
<VbenTooltip side="bottom">
|
||||
<template #trigger>
|
||||
<Notification class="mr-1" />
|
||||
</template>
|
||||
{{ $t('preferences.widget.notification') }}
|
||||
</VbenTooltip>
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'refresh'">
|
||||
<VbenIconButton
|
||||
class="my-0 mr-1 rounded-md"
|
||||
:tooltip="$t('preferences.widget.refresh')"
|
||||
@click="refresh"
|
||||
>
|
||||
<RotateCw class="size-4" />
|
||||
</VbenIconButton>
|
||||
</template>
|
||||
</slot>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.menu-align-start {
|
||||
--menu-align: start;
|
||||
}
|
||||
|
||||
.menu-align-center {
|
||||
--menu-align: center;
|
||||
}
|
||||
|
||||
.menu-align-end {
|
||||
--menu-align: end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SetupContext } from 'vue';
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import { computed, useSlots, watch } from 'vue';
|
||||
import { computed, onMounted, useSlots, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { useRefresh } from '@vben/hooks';
|
||||
import { $t } from '@vben/locales';
|
||||
import { $t, i18n } from '@vben/locales';
|
||||
import {
|
||||
preferences,
|
||||
updatePreferences,
|
||||
usePreferences,
|
||||
} from '@vben/preferences';
|
||||
import { useLockStore } from '@vben/stores';
|
||||
import { useAccessStore, useTabbarStore, useTimezoneStore } from '@vben/stores';
|
||||
import { cloneDeep, mapTree } from '@vben/utils';
|
||||
|
||||
import { VbenAdminLayout } from '@vben-core/layout-ui';
|
||||
import { VbenBackTop, VbenLogo } from '@vben-core/shadcn-ui';
|
||||
import { ELEMENT_ID_LAYOUT_SCROLL } from '@vben-core/shared/constants';
|
||||
|
||||
import { Breadcrumb, CheckUpdates, Preferences } from '../widgets';
|
||||
import { LayoutContent, LayoutContentSpinner } from './content';
|
||||
@@ -28,30 +34,57 @@ import {
|
||||
useMixedMenu,
|
||||
} from './menu';
|
||||
import { LayoutTabbar } from './tabbar';
|
||||
import { useLayoutScroll } from './use-layout-scroll';
|
||||
|
||||
defineOptions({ name: 'BasicLayout' });
|
||||
|
||||
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
|
||||
withDefaults(defineProps<Props>(), {
|
||||
avatar: '',
|
||||
text: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
clearPreferencesAndLogout: [];
|
||||
clickLogo: [];
|
||||
logout: [];
|
||||
}>();
|
||||
|
||||
interface Props {
|
||||
avatar?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
isDark,
|
||||
isHeaderNav,
|
||||
isMixedNav,
|
||||
isMobile,
|
||||
isSideMode,
|
||||
isSideMixedNav,
|
||||
isHeaderMixedNav,
|
||||
isHeaderSidebarNav,
|
||||
layout,
|
||||
preferencesButtonPosition,
|
||||
sidebarCollapsed,
|
||||
theme,
|
||||
} = usePreferences();
|
||||
const lockStore = useLockStore();
|
||||
const accessStore = useAccessStore();
|
||||
const timezoneStore = useTimezoneStore();
|
||||
const { refresh } = useRefresh();
|
||||
const layoutScrollTarget = `#${ELEMENT_ID_LAYOUT_SCROLL}`;
|
||||
|
||||
useLayoutScroll();
|
||||
|
||||
const sidebarTheme = computed(() => {
|
||||
const dark = isDark.value || preferences.theme.semiDarkSidebar;
|
||||
return dark ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
const sidebarThemeSub = computed(() => {
|
||||
const dark = isDark.value || preferences.theme.semiDarkSidebarSub;
|
||||
return dark ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
const headerTheme = computed(() => {
|
||||
const dark = isDark.value || preferences.theme.semiDarkHeader;
|
||||
return dark ? 'dark' : 'light';
|
||||
@@ -80,16 +113,50 @@ const logoCollapsed = computed(() => {
|
||||
if (isMobile.value && sidebarCollapsed.value) {
|
||||
return true;
|
||||
}
|
||||
if (isHeaderNav.value || isMixedNav.value) {
|
||||
if (isHeaderNav.value || isMixedNav.value || isHeaderSidebarNav.value) {
|
||||
return false;
|
||||
}
|
||||
return sidebarCollapsed.value || isSideMixedNav.value;
|
||||
return (
|
||||
sidebarCollapsed.value || isSideMixedNav.value || isHeaderMixedNav.value
|
||||
);
|
||||
});
|
||||
|
||||
const showHeaderNav = computed(() => {
|
||||
return !isMobile.value && (isHeaderNav.value || isMixedNav.value);
|
||||
return (
|
||||
!isMobile.value &&
|
||||
(isHeaderNav.value || isMixedNav.value || isHeaderMixedNav.value)
|
||||
);
|
||||
});
|
||||
|
||||
const logoTheme = computed(() => {
|
||||
const showLogoInHeader =
|
||||
!isSideMode.value ||
|
||||
isHeaderSidebarNav.value ||
|
||||
isMixedNav.value ||
|
||||
isMobile.value;
|
||||
return showLogoInHeader ? headerTheme.value : sidebarTheme.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* layout-sidebar扩展区域插槽extra-title的高度
|
||||
*/
|
||||
const sidebarExtraTitleHeight = computed<number | undefined>(() => {
|
||||
const showSideExtraTitle =
|
||||
preferences.logo.enable && preferences.logo.showText;
|
||||
return showSideExtraTitle ? undefined : 0;
|
||||
});
|
||||
|
||||
const {
|
||||
handleMenuSelect,
|
||||
handleMenuOpen,
|
||||
headerActive,
|
||||
headerMenus,
|
||||
sidebarActive,
|
||||
sidebarMenus,
|
||||
mixHeaderMenus,
|
||||
sidebarVisible,
|
||||
} = useMixedMenu();
|
||||
|
||||
// 侧边多列菜单
|
||||
const {
|
||||
extraActiveMenu,
|
||||
@@ -99,17 +166,7 @@ const {
|
||||
handleMixedMenuSelect,
|
||||
handleSideMouseLeave,
|
||||
sidebarExtraVisible,
|
||||
} = useExtraMenu();
|
||||
|
||||
const {
|
||||
handleMenuSelect,
|
||||
handleMenuOpen,
|
||||
headerActive,
|
||||
headerMenus,
|
||||
sidebarActive,
|
||||
sidebarMenus,
|
||||
sidebarVisible,
|
||||
} = useMixedMenu();
|
||||
} = useExtraMenu(mixHeaderMenus);
|
||||
|
||||
/**
|
||||
* 包装菜单,翻译菜单名称
|
||||
@@ -138,6 +195,33 @@ function clearPreferencesAndLogout() {
|
||||
emit('clearPreferencesAndLogout');
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
emit('logout');
|
||||
}
|
||||
|
||||
function clickLogo() {
|
||||
emit('clickLogo');
|
||||
}
|
||||
|
||||
function autoCollapseMenuByRouteMeta(route: RouteLocationNormalizedLoaded) {
|
||||
// 只在双列模式下生效
|
||||
if (
|
||||
['header-mixed-nav', 'sidebar-mixed-nav'].includes(
|
||||
preferences.app.layout,
|
||||
) &&
|
||||
route.meta &&
|
||||
route.meta.hideInMenu
|
||||
) {
|
||||
sidebarExtraVisible.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
autoCollapseMenuByRouteMeta(route);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => preferences.app.layout,
|
||||
async (val) => {
|
||||
@@ -151,10 +235,21 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
// 语言更新后,刷新页面
|
||||
watch(() => preferences.app.locale, refresh, { flush: 'post' });
|
||||
const tabbarStore = useTabbarStore();
|
||||
|
||||
const slots = useSlots();
|
||||
function refreshAll() {
|
||||
tabbarStore.cachedTabs.clear();
|
||||
refresh();
|
||||
}
|
||||
|
||||
// 语言更新后,刷新页面
|
||||
// i18n.global.locale会在preference.app.locale变更之后才会更新,因此watchpreference.app.locale是不合适的,刷新页面时可能语言配置尚未完全加载完成
|
||||
watch(i18n.global.locale, refreshAll, { flush: 'post' });
|
||||
|
||||
// 时区更新后,刷新页面
|
||||
watch(() => timezoneStore.timezone, refreshAll, { flush: 'post' });
|
||||
|
||||
const slots: SetupContext['slots'] = useSlots();
|
||||
const headerSlots = computed(() => {
|
||||
return Object.keys(slots).filter((key) => key.startsWith('header-'));
|
||||
});
|
||||
@@ -164,8 +259,16 @@ const headerSlots = computed(() => {
|
||||
<VbenAdminLayout
|
||||
v-model:sidebar-extra-visible="sidebarExtraVisible"
|
||||
:content-compact="preferences.app.contentCompact"
|
||||
:content-compact-width="preferences.app.contentCompactWidth"
|
||||
:content-padding="preferences.app.contentPadding"
|
||||
:content-padding-bottom="preferences.app.contentPaddingBottom"
|
||||
:content-padding-left="preferences.app.contentPaddingLeft"
|
||||
:content-padding-right="preferences.app.contentPaddingRight"
|
||||
:content-padding-top="preferences.app.contentPaddingTop"
|
||||
:footer-enable="preferences.footer.enable"
|
||||
:footer-fixed="preferences.footer.fixed"
|
||||
:footer-height="preferences.footer.height"
|
||||
:header-height="preferences.header.height"
|
||||
:header-hidden="preferences.header.hidden"
|
||||
:header-mode="preferences.header.mode"
|
||||
:header-theme="headerTheme"
|
||||
@@ -173,16 +276,26 @@ const headerSlots = computed(() => {
|
||||
:header-visible="preferences.header.enable"
|
||||
:is-mobile="preferences.app.isMobile"
|
||||
:layout="layout"
|
||||
:sidebar-draggable="preferences.sidebar.draggable"
|
||||
:sidebar-collapse="preferences.sidebar.collapsed"
|
||||
:sidebar-collapse-show-title="preferences.sidebar.collapsedShowTitle"
|
||||
:sidebar-enable="sidebarVisible"
|
||||
:sidebar-collapsed-button="preferences.sidebar.collapsedButton"
|
||||
:sidebar-fixed-button="preferences.sidebar.fixedButton"
|
||||
:sidebar-expand-on-hover="preferences.sidebar.expandOnHover"
|
||||
:sidebar-extra-collapse="preferences.sidebar.extraCollapse"
|
||||
:sidebar-extra-collapsed-width="preferences.sidebar.extraCollapsedWidth"
|
||||
:sidebar-extra-title-height="sidebarExtraTitleHeight"
|
||||
:sidebar-hidden="preferences.sidebar.hidden"
|
||||
:sidebar-mixed-width="preferences.sidebar.mixedWidth"
|
||||
:sidebar-theme="sidebarTheme"
|
||||
:sidebar-theme-sub="sidebarThemeSub"
|
||||
:sidebar-width="preferences.sidebar.width"
|
||||
:side-collapse-width="preferences.sidebar.collapseWidth"
|
||||
:sidebar-logo-visible="preferences.logo.enable"
|
||||
:tabbar-enable="preferences.tabbar.enable"
|
||||
:tabbar-height="preferences.tabbar.height"
|
||||
:z-index="preferences.app.zIndex"
|
||||
@side-mouse-leave="handleSideMouseLeave"
|
||||
@toggle-sidebar="toggleSidebar"
|
||||
@update:sidebar-collapse="
|
||||
@@ -199,23 +312,39 @@ const headerSlots = computed(() => {
|
||||
(value: boolean) =>
|
||||
updatePreferences({ sidebar: { extraCollapse: value } })
|
||||
"
|
||||
@update:sidebar-width="
|
||||
(value: number) => updatePreferences({ sidebar: { width: value } })
|
||||
"
|
||||
>
|
||||
<!-- logo -->
|
||||
<template #logo>
|
||||
<VbenLogo
|
||||
v-if="preferences.logo.enable"
|
||||
:fit="preferences.logo.fit"
|
||||
:class="logoClass"
|
||||
:collapsed="logoCollapsed"
|
||||
:src="preferences.logo.source"
|
||||
:src-dark="preferences.logo.sourceDark"
|
||||
:text="preferences.app.name"
|
||||
:theme="showHeaderNav ? headerTheme : theme"
|
||||
/>
|
||||
:show-text="preferences.logo.showText"
|
||||
:logo-mode="preferences.logo.logoMode"
|
||||
:full-logo-height="preferences.logo.fullLogoHeight"
|
||||
:theme="logoTheme"
|
||||
@click="clickLogo"
|
||||
>
|
||||
<template v-if="$slots['logo-text']" #text>
|
||||
<slot name="logo-text"></slot>
|
||||
</template>
|
||||
</VbenLogo>
|
||||
</template>
|
||||
<!-- 头部区域 -->
|
||||
<template #header>
|
||||
<LayoutHeader
|
||||
:avatar="avatar"
|
||||
:theme="theme"
|
||||
:text="text"
|
||||
@clear-preferences-and-logout="clearPreferencesAndLogout"
|
||||
@logout="handleLogout"
|
||||
>
|
||||
<template
|
||||
v-if="!showHeaderNav && preferences.breadcrumb.enable"
|
||||
@@ -268,7 +397,7 @@ const headerSlots = computed(() => {
|
||||
<template #mixed-menu>
|
||||
<LayoutMixedMenu
|
||||
:active-path="extraActiveMenu"
|
||||
:menus="wrapperMenus(headerMenus, false)"
|
||||
:menus="wrapperMenus(mixHeaderMenus, false)"
|
||||
:rounded="isMenuRounded"
|
||||
:theme="sidebarTheme"
|
||||
@default-select="handleDefaultSelect"
|
||||
@@ -283,16 +412,23 @@ const headerSlots = computed(() => {
|
||||
:collapse="preferences.sidebar.extraCollapse"
|
||||
:menus="wrapperMenus(extraMenus)"
|
||||
:rounded="isMenuRounded"
|
||||
:theme="sidebarTheme"
|
||||
:theme="sidebarThemeSub"
|
||||
/>
|
||||
</template>
|
||||
<template #side-extra-title>
|
||||
<VbenLogo
|
||||
v-if="preferences.logo.enable"
|
||||
:fit="preferences.logo.fit"
|
||||
:show-text="preferences.logo.showText"
|
||||
:text="preferences.app.name"
|
||||
:theme="theme"
|
||||
:version="preferences.app.version"
|
||||
/>
|
||||
>
|
||||
:theme="sidebarThemeSub"
|
||||
<template v-if="$slots['logo-text']" #text>
|
||||
<slot name="logo-text"></slot>
|
||||
</template>
|
||||
</VbenLogo>
|
||||
</template>
|
||||
|
||||
<template #tabbar>
|
||||
@@ -330,16 +466,16 @@ const headerSlots = computed(() => {
|
||||
/>
|
||||
|
||||
<Transition v-if="preferences.widget.lockScreen" name="slide-up">
|
||||
<slot v-if="lockStore.isLockScreen" name="lock-screen"></slot>
|
||||
<slot v-if="accessStore.isLockScreen" name="lock-screen"></slot>
|
||||
</Transition>
|
||||
|
||||
<template v-if="preferencesButtonPosition.fixed">
|
||||
<Preferences
|
||||
class="z-100 fixed bottom-20 right-0"
|
||||
class="fixed top-1/2 right-0 z-100 -translate-y-1/2 transform"
|
||||
@clear-preferences-and-logout="clearPreferencesAndLogout"
|
||||
/>
|
||||
</template>
|
||||
<VbenBackTop />
|
||||
<VbenBackTop :target="layoutScrollTarget" />
|
||||
</template>
|
||||
</VbenAdminLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import type { MenuProps } from '@vben-core/menu-ui';
|
||||
|
||||
import { useRoute } from 'vue-router';
|
||||
@@ -10,7 +11,7 @@ import { useNavigation } from './use-navigation';
|
||||
|
||||
interface Props extends MenuProps {
|
||||
collapse?: boolean;
|
||||
menus: MenuRecordRaw[];
|
||||
menus?: MenuRecordRaw[];
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import type { MenuProps } from '@vben-core/menu-ui';
|
||||
|
||||
import { Menu } from '@vben-core/menu-ui';
|
||||
|
||||
interface Props extends MenuProps {
|
||||
menus: MenuRecordRaw[];
|
||||
menus?: MenuRecordRaw[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -36,6 +37,7 @@ function handleMenuOpen(key: string, path: string[]) {
|
||||
:menus="menus"
|
||||
:mode="mode"
|
||||
:rounded="rounded"
|
||||
scroll-to-active
|
||||
:theme="theme"
|
||||
@open="handleMenuOpen"
|
||||
@select="handleMenuSelect"
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import type { NormalMenuProps } from '@vben-core/menu-ui';
|
||||
|
||||
import { onBeforeMount } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { findMenuByPath } from '@vben/utils';
|
||||
|
||||
import { NormalMenu } from '@vben-core/menu-ui';
|
||||
|
||||
interface Props extends NormalMenuProps {}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ComputedRef } from 'vue';
|
||||
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
@@ -9,30 +11,37 @@ import { findRootMenuByPath } from '@vben/utils';
|
||||
|
||||
import { useNavigation } from './use-navigation';
|
||||
|
||||
function useExtraMenu() {
|
||||
function useExtraMenu(useRootMenus?: ComputedRef<MenuRecordRaw[]>) {
|
||||
const accessStore = useAccessStore();
|
||||
const { navigation } = useNavigation();
|
||||
const { navigation, willOpenedByWindow } = useNavigation();
|
||||
|
||||
const menus = computed(() => accessStore.accessMenus);
|
||||
const menus = computed(() => useRootMenus?.value ?? accessStore.accessMenus);
|
||||
|
||||
/** 记录当前顶级菜单下哪个子菜单最后激活 */
|
||||
const defaultSubMap = new Map<string, string>();
|
||||
|
||||
const extraRootMenus = ref<MenuRecordRaw[]>([]);
|
||||
const route = useRoute();
|
||||
const extraMenus = ref<MenuRecordRaw[]>([]);
|
||||
const sidebarExtraVisible = ref<boolean>(false);
|
||||
const extraActiveMenu = ref('');
|
||||
const parentLevel = computed(() =>
|
||||
preferences.app.layout === 'header-mixed-nav' ? 1 : 0,
|
||||
);
|
||||
|
||||
/**
|
||||
* 选择混合菜单事件
|
||||
* @param menu
|
||||
*/
|
||||
const handleMixedMenuSelect = async (menu: MenuRecordRaw) => {
|
||||
extraMenus.value = menu?.children ?? [];
|
||||
extraActiveMenu.value = menu.parents?.[0] ?? menu.path;
|
||||
const hasChildren = extraMenus.value.length > 0;
|
||||
const _extraMenus = menu?.children ?? [];
|
||||
const hasChildren = _extraMenus.length > 0;
|
||||
|
||||
if (!willOpenedByWindow(menu.path)) {
|
||||
extraMenus.value = _extraMenus ?? [];
|
||||
extraActiveMenu.value = menu.parents?.[parentLevel.value] ?? menu.path;
|
||||
sidebarExtraVisible.value = hasChildren;
|
||||
}
|
||||
|
||||
sidebarExtraVisible.value = hasChildren;
|
||||
if (!hasChildren) {
|
||||
await navigation(menu.path);
|
||||
} else if (preferences.sidebar.autoActivateChild) {
|
||||
@@ -49,12 +58,12 @@ function useExtraMenu() {
|
||||
* @param menu
|
||||
* @param rootMenu
|
||||
*/
|
||||
const handleDefaultSelect = (
|
||||
const handleDefaultSelect = async (
|
||||
menu: MenuRecordRaw,
|
||||
rootMenu?: MenuRecordRaw,
|
||||
) => {
|
||||
extraMenus.value = rootMenu?.children ?? [];
|
||||
extraActiveMenu.value = menu.parents?.[0] ?? menu.path;
|
||||
extraMenus.value = rootMenu?.children ?? extraRootMenus.value ?? [];
|
||||
extraActiveMenu.value = menu.parents?.[parentLevel.value] ?? menu.path;
|
||||
|
||||
if (preferences.sidebar.expandOnHover) {
|
||||
sidebarExtraVisible.value = extraMenus.value.length > 0;
|
||||
@@ -68,7 +77,6 @@ function useExtraMenu() {
|
||||
if (preferences.sidebar.expandOnHover) {
|
||||
return;
|
||||
}
|
||||
sidebarExtraVisible.value = false;
|
||||
|
||||
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
|
||||
menus.value,
|
||||
@@ -82,25 +90,31 @@ function useExtraMenu() {
|
||||
if (!preferences.sidebar.expandOnHover) {
|
||||
const { findMenu } = findRootMenuByPath(menus.value, menu.path);
|
||||
extraMenus.value = findMenu?.children ?? [];
|
||||
extraActiveMenu.value = menu.parents?.[0] ?? menu.path;
|
||||
extraActiveMenu.value = menu.parents?.[parentLevel.value] ?? menu.path;
|
||||
sidebarExtraVisible.value = extraMenus.value.length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
function calcExtraMenus(path: string) {
|
||||
const currentPath = route.meta?.activePath || path;
|
||||
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
|
||||
menus.value,
|
||||
currentPath,
|
||||
parentLevel.value,
|
||||
);
|
||||
extraRootMenus.value = rootMenu?.children ?? [];
|
||||
if (rootMenuPath) defaultSubMap.set(rootMenuPath, currentPath);
|
||||
extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? '';
|
||||
extraMenus.value = rootMenu?.children ?? [];
|
||||
if (preferences.sidebar.expandOnHover) {
|
||||
sidebarExtraVisible.value = extraMenus.value.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
const currentPath = route.meta?.activePath || path;
|
||||
// if (preferences.sidebar.expandOnHover) {
|
||||
// return;
|
||||
// }
|
||||
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
|
||||
menus.value,
|
||||
currentPath,
|
||||
);
|
||||
if (rootMenuPath) defaultSubMap.set(rootMenuPath, currentPath);
|
||||
extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? '';
|
||||
extraMenus.value = rootMenu?.children ?? [];
|
||||
() => [route.path, preferences.app.layout],
|
||||
([path]) => {
|
||||
calcExtraMenus(path || '');
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
@@ -10,17 +10,22 @@ import { findRootMenuByPath } from '@vben/utils';
|
||||
import { useNavigation } from './use-navigation';
|
||||
|
||||
function useMixedMenu() {
|
||||
const { navigation } = useNavigation();
|
||||
const { navigation, willOpenedByWindow } = useNavigation();
|
||||
const accessStore = useAccessStore();
|
||||
const route = useRoute();
|
||||
const splitSideMenus = ref<MenuRecordRaw[]>([]);
|
||||
const rootMenuPath = ref<string>('');
|
||||
const mixedRootMenuPath = ref<string>('');
|
||||
const mixExtraMenus = ref<MenuRecordRaw[]>([]);
|
||||
/** 记录当前顶级菜单下哪个子菜单最后激活 */
|
||||
const defaultSubMap = new Map<string, string>();
|
||||
const { isMixedNav } = usePreferences();
|
||||
const { isMixedNav, isHeaderMixedNav, isMobile } = usePreferences();
|
||||
|
||||
const needSplit = computed(
|
||||
() => preferences.navigation.split && isMixedNav.value,
|
||||
() =>
|
||||
!isMobile.value &&
|
||||
((preferences.navigation.split && isMixedNav.value) ||
|
||||
isHeaderMixedNav.value),
|
||||
);
|
||||
|
||||
const sidebarVisible = computed(() => {
|
||||
@@ -54,6 +59,10 @@ function useMixedMenu() {
|
||||
return needSplit.value ? splitSideMenus.value : menus.value;
|
||||
});
|
||||
|
||||
const mixHeaderMenus = computed(() => {
|
||||
return isHeaderMixedNav.value ? sidebarMenus.value : headerMenus.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边菜单激活路径
|
||||
*/
|
||||
@@ -66,7 +75,7 @@ function useMixedMenu() {
|
||||
*/
|
||||
const headerActive = computed(() => {
|
||||
if (!needSplit.value) {
|
||||
return route.path;
|
||||
return route.meta?.activePath ?? route.path;
|
||||
}
|
||||
return rootMenuPath.value;
|
||||
});
|
||||
@@ -81,11 +90,15 @@ function useMixedMenu() {
|
||||
navigation(key);
|
||||
return;
|
||||
}
|
||||
|
||||
const rootMenu = menus.value.find((item) => item.path === key);
|
||||
rootMenuPath.value = rootMenu?.path ?? '';
|
||||
splitSideMenus.value = rootMenu?.children ?? [];
|
||||
if (splitSideMenus.value.length === 0) {
|
||||
const _splitSideMenus = rootMenu?.children ?? [];
|
||||
|
||||
if (!willOpenedByWindow(key)) {
|
||||
rootMenuPath.value = rootMenu?.path ?? '';
|
||||
splitSideMenus.value = _splitSideMenus;
|
||||
}
|
||||
|
||||
if (_splitSideMenus.length === 0) {
|
||||
navigation(key);
|
||||
} else if (rootMenu && preferences.sidebar.autoActivateChild) {
|
||||
navigation(
|
||||
@@ -118,6 +131,9 @@ function useMixedMenu() {
|
||||
if (!rootMenu) {
|
||||
rootMenu = menus.value.find((item) => item.path === path);
|
||||
}
|
||||
const result = findRootMenuByPath(rootMenu?.children || [], path, 1);
|
||||
mixedRootMenuPath.value = result.rootMenuPath ?? '';
|
||||
mixExtraMenus.value = result.rootMenu?.children ?? [];
|
||||
rootMenuPath.value = rootMenu?.path ?? '';
|
||||
splitSideMenus.value = rootMenu?.children ?? [];
|
||||
}
|
||||
@@ -125,7 +141,10 @@ function useMixedMenu() {
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
const currentPath = (route?.meta?.activePath as string) ?? path;
|
||||
const currentPath = route?.meta?.activePath ?? route?.meta?.link ?? path;
|
||||
if (willOpenedByWindow(currentPath)) {
|
||||
return;
|
||||
}
|
||||
calcSideMenus(currentPath);
|
||||
if (rootMenuPath.value)
|
||||
defaultSubMap.set(rootMenuPath.value, currentPath);
|
||||
@@ -145,6 +164,8 @@ function useMixedMenu() {
|
||||
headerMenus,
|
||||
sidebarActive,
|
||||
sidebarMenus,
|
||||
mixHeaderMenus,
|
||||
mixExtraMenus,
|
||||
sidebarVisible,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,33 +1,74 @@
|
||||
import { type RouteRecordNormalized, useRouter } from 'vue-router';
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { isHttpUrl, openRouteInNewWindow, openWindow } from '@vben/utils';
|
||||
|
||||
function useNavigation() {
|
||||
const router = useRouter();
|
||||
const routes = router.getRoutes();
|
||||
|
||||
const routeMetaMap = new Map<string, RouteRecordNormalized>();
|
||||
|
||||
routes.forEach((route) => {
|
||||
routeMetaMap.set(route.path, route);
|
||||
// 初始化路由映射
|
||||
const initRouteMetaMap = () => {
|
||||
const routes = router.getRoutes();
|
||||
routes.forEach((route) => {
|
||||
routeMetaMap.set(route.path, route);
|
||||
});
|
||||
};
|
||||
|
||||
initRouteMetaMap();
|
||||
|
||||
// 监听路由变化
|
||||
router.afterEach(() => {
|
||||
initRouteMetaMap();
|
||||
});
|
||||
|
||||
const navigation = async (path: string) => {
|
||||
const route = routeMetaMap.get(path);
|
||||
const { openInNewWindow = false, query = {} } = route?.meta ?? {};
|
||||
// 检查是否应该在新窗口打开
|
||||
const shouldOpenInNewWindow = (path: string): boolean => {
|
||||
if (isHttpUrl(path)) {
|
||||
openWindow(path, { target: '_blank' });
|
||||
} else if (openInNewWindow) {
|
||||
openRouteInNewWindow(path);
|
||||
} else {
|
||||
await router.push({
|
||||
path,
|
||||
query,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
const route = routeMetaMap.get(path);
|
||||
// 如果有外链或者设置了在新窗口打开,返回 true
|
||||
return !!(route?.meta?.link || route?.meta?.openInNewWindow);
|
||||
};
|
||||
|
||||
const resolveHref = (path: string): string => {
|
||||
return router.resolve(path).href;
|
||||
};
|
||||
|
||||
const navigation = async (path: string) => {
|
||||
try {
|
||||
const route = routeMetaMap.get(path);
|
||||
const { openInNewWindow = false, query = {}, link } = route?.meta ?? {};
|
||||
|
||||
// 检查是否有外链
|
||||
if (link && typeof link === 'string') {
|
||||
openWindow(link, { target: '_blank' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHttpUrl(path)) {
|
||||
openWindow(path, { target: '_blank' });
|
||||
} else if (openInNewWindow) {
|
||||
openRouteInNewWindow(resolveHref(path));
|
||||
} else {
|
||||
await router.push({
|
||||
path,
|
||||
query,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Navigation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return { navigation };
|
||||
const willOpenedByWindow = (path: string) => {
|
||||
return shouldOpenInNewWindow(path);
|
||||
};
|
||||
|
||||
return { navigation, willOpenedByWindow };
|
||||
}
|
||||
|
||||
export { useNavigation };
|
||||
|
||||
@@ -5,7 +5,13 @@ import { useRoute } from 'vue-router';
|
||||
import { useContentMaximize, useTabs } from '@vben/hooks';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useTabbarStore } from '@vben/stores';
|
||||
import { TabsToolMore, TabsToolScreen, TabsView } from '@vben-core/tabs-ui';
|
||||
|
||||
import {
|
||||
TabsToolMore,
|
||||
TabsToolRefresh,
|
||||
TabsToolScreen,
|
||||
TabsView,
|
||||
} from '@vben-core/tabs-ui';
|
||||
|
||||
import { useTabbar } from './use-tabbar';
|
||||
|
||||
@@ -18,7 +24,7 @@ defineProps<{ showIcon?: boolean; theme?: string }>();
|
||||
const route = useRoute();
|
||||
const tabbarStore = useTabbarStore();
|
||||
const { contentIsMaximize, toggleMaximize } = useContentMaximize();
|
||||
const { unpinTab } = useTabs();
|
||||
const { refreshTab, unpinTab } = useTabs();
|
||||
|
||||
const {
|
||||
createContextMenus,
|
||||
@@ -29,7 +35,7 @@ const {
|
||||
} = useTabbar();
|
||||
|
||||
const menus = computed(() => {
|
||||
const tab = tabbarStore.getTabByPath(currentActive.value);
|
||||
const tab = tabbarStore.getTabByKey(currentActive.value);
|
||||
const menus = createContextMenus(tab);
|
||||
return menus.map((item) => {
|
||||
return {
|
||||
@@ -56,6 +62,7 @@ if (!preferences.tabbar.persist) {
|
||||
:style-type="preferences.tabbar.styleType"
|
||||
:tabs="currentTabs"
|
||||
:wheelable="preferences.tabbar.wheelable"
|
||||
:middle-click-to-close="preferences.tabbar.middleClickToClose"
|
||||
@close="handleClose"
|
||||
@sort-tabs="tabbarStore.sortTabs"
|
||||
@unpin="unpinTab"
|
||||
@@ -63,6 +70,10 @@ if (!preferences.tabbar.persist) {
|
||||
/>
|
||||
<div class="flex-center h-full">
|
||||
<TabsToolMore v-if="preferences.tabbar.showMore" :menus="menus" />
|
||||
<TabsToolRefresh
|
||||
v-if="preferences.tabbar.showRefresh"
|
||||
@refresh="refreshTab"
|
||||
/>
|
||||
<TabsToolScreen
|
||||
v-if="preferences.tabbar.showMaximize"
|
||||
:screen="contentIsMaximize"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user