Merge branch 'main' into form

pull/168/head^2
Jin Mao 2025-07-07 09:16:54 +08:00 committed by GitHub
commit b8bf482c6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
132 changed files with 6340 additions and 4090 deletions

View File

@ -19,11 +19,9 @@ Project maintainers have the right and responsibility to remove, edit, or reject
- Checkout a topic branch from the relevant branch, e.g. main, and merge back against that branch.
- If adding a new feature:
- Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first and have it approved before working on it.
- If fixing bug:
- Provide a detailed description of the bug in the PR. Live demo preferred.
- It's OK to have multiple small commits as you work on the PR - GitHub can automatically squash them before merging.

View File

@ -0,0 +1,28 @@
export default eventHandler(async (event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
const data = `
{
"code": 0,
"message": "success",
"data": [
{
"id": 123456789012345678901234567890123456789012345678901234567890,
"name": "John Doe",
"age": 30,
"email": "john-doe@demo.com"
},
{
"id": 987654321098765432109876543210987654321098765432109876543210,
"name": "Jane Smith",
"age": 25,
"email": "jane@demo.com"
}
]
}
`;
setHeader(event, 'Content-Type', 'application/json');
return data;
});

View File

@ -1,6 +1,6 @@
{
"name": "@vben/web-antd",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -8,13 +8,7 @@ import type { Component } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import {
defineAsyncComponent,
defineComponent,
getCurrentInstance,
h,
ref,
} from 'vue';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { $t } from '@vben/locales';
@ -82,16 +76,15 @@ const withDefaultPlaceholder = <T extends Component>(
$t(`ui.placeholder.${type}`);
// 透传组件暴露的方法
const innerRef = ref();
const publicApi: Recordable<any> = {};
expose(publicApi);
const instance = getCurrentInstance();
instance?.proxy?.$nextTick(() => {
for (const key in innerRef.value) {
if (typeof innerRef.value[key] === 'function') {
publicApi[key] = innerRef.value[key];
}
}
});
expose(
new Proxy(
{},
{
get: (_target, key) => innerRef.value?.[key],
has: (_target, key) => key in (innerRef.value || {}),
},
),
);
return () =>
h(
component,

View File

@ -8,40 +8,42 @@ import type { ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
setupVbenForm<ComponentType>({
config: {
// ant design vue组件库默认都是 v-model:value
baseModelPropName: 'value',
async function initSetupVbenForm() {
setupVbenForm<ComponentType>({
config: {
// ant design vue组件库默认都是 v-model:value
baseModelPropName: 'value',
// 一些组件是 v-model:checked 或者 v-model:fileList
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
Switch: 'checked',
Upload: 'fileList',
// 一些组件是 v-model:checked 或者 v-model:fileList
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
Switch: 'checked',
Upload: 'fileList',
},
},
},
defineRules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
defineRules: {
// 输入项目必填国际化适配
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
// 选择项目必填国际化适配
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
// 选择项目必填国际化适配
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
});
}
const useVbenForm = useForm<ComponentType>;
export { useVbenForm, z };
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };

View File

@ -1,3 +1,5 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
@ -33,7 +35,7 @@ setupVbenVxeTable({
round: true,
showOverflow: true,
size: 'small',
},
} as VxeTableGridOptions,
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },

View File

@ -12,6 +12,7 @@ import { useTitle } from '@vueuse/core';
import { $t, setupI18n } from '#/locales';
import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm } from './adapter/form';
import App from './app.vue';
import { router } from './router';
@ -19,6 +20,9 @@ async function bootstrap(namespace: string) {
// 初始化组件适配器
await initComponentAdapter();
// 初始化表单组件
await initSetupVbenForm();
// // 设置弹窗的默认配置
// setDefaultModalProps({
// fullscreenButton: false,

View File

@ -1,6 +1,6 @@
{
"name": "@vben/web-ele",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -8,13 +8,7 @@ import type { Component } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import {
defineAsyncComponent,
defineComponent,
getCurrentInstance,
h,
ref,
} from 'vue';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { $t } from '@vben/locales';
@ -139,16 +133,15 @@ const withDefaultPlaceholder = <T extends Component>(
$t(`ui.placeholder.${type}`);
// 透传组件暴露的方法
const innerRef = ref();
const publicApi: Recordable<any> = {};
expose(publicApi);
const instance = getCurrentInstance();
instance?.proxy?.$nextTick(() => {
for (const key in innerRef.value) {
if (typeof innerRef.value[key] === 'function') {
publicApi[key] = innerRef.value[key];
}
}
});
expose(
new Proxy(
{},
{
get: (_target, key) => innerRef.value?.[key],
has: (_target, key) => key in (innerRef.value || {}),
},
),
);
return () =>
h(
component,

View File

@ -8,32 +8,34 @@ import type { ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
setupVbenForm<ComponentType>({
config: {
modelPropNameMap: {
Upload: 'fileList',
CheckboxGroup: 'model-value',
async function initSetupVbenForm() {
setupVbenForm<ComponentType>({
config: {
modelPropNameMap: {
Upload: 'fileList',
CheckboxGroup: 'model-value',
},
},
},
defineRules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
defineRules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
});
}
const useVbenForm = useForm<ComponentType>;
export { useVbenForm, z };
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };

View File

@ -1,3 +1,5 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
@ -33,7 +35,7 @@ setupVbenVxeTable({
round: true,
showOverflow: true,
size: 'small',
},
} as VxeTableGridOptions,
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },

View File

@ -13,12 +13,17 @@ import { ElLoading } from 'element-plus';
import { $t, setupI18n } from '#/locales';
import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm } from './adapter/form';
import App from './app.vue';
import { router } from './router';
async function bootstrap(namespace: string) {
// 初始化组件适配器
await initComponentAdapter();
// 初始化表单组件
await initSetupVbenForm();
// // 设置弹窗的默认配置
// setDefaultModalProps({
// fullscreenButton: false,

View File

@ -1,6 +1,6 @@
{
"name": "@vben/web-naive",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -8,13 +8,7 @@ import type { Component } from 'vue';
import type { BaseFormComponentType } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import {
defineAsyncComponent,
defineComponent,
getCurrentInstance,
h,
ref,
} from 'vue';
import { defineAsyncComponent, defineComponent, h, ref } from 'vue';
import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
import { $t } from '@vben/locales';
@ -85,16 +79,15 @@ const withDefaultPlaceholder = <T extends Component>(
$t(`ui.placeholder.${type}`);
// 透传组件暴露的方法
const innerRef = ref();
const publicApi: Recordable<any> = {};
expose(publicApi);
const instance = getCurrentInstance();
instance?.proxy?.$nextTick(() => {
for (const key in innerRef.value) {
if (typeof innerRef.value[key] === 'function') {
publicApi[key] = innerRef.value[key];
}
}
});
expose(
new Proxy(
{},
{
get: (_target, key) => innerRef.value?.[key],
has: (_target, key) => key in (innerRef.value || {}),
},
),
);
return () =>
h(
component,

View File

@ -8,36 +8,38 @@ import type { ComponentType } from './component';
import { setupVbenForm, useVbenForm as useForm, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
setupVbenForm<ComponentType>({
config: {
// naive-ui组件的空值为null,不能是undefined否则重置表单时不生效
emptyStateValue: null,
baseModelPropName: 'value',
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
Upload: 'fileList',
async function initSetupVbenForm() {
setupVbenForm<ComponentType>({
config: {
// naive-ui组件的空值为null,不能是undefined否则重置表单时不生效
emptyStateValue: null,
baseModelPropName: 'value',
modelPropNameMap: {
Checkbox: 'checked',
Radio: 'checked',
Upload: 'fileList',
},
},
},
defineRules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
defineRules: {
required: (value, _params, ctx) => {
if (value === undefined || value === null || value.length === 0) {
return $t('ui.formRules.required', [ctx.label]);
}
return true;
},
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
selectRequired: (value, _params, ctx) => {
if (value === undefined || value === null) {
return $t('ui.formRules.selectRequired', [ctx.label]);
}
return true;
},
},
});
});
}
const useVbenForm = useForm<ComponentType>;
export { useVbenForm, z };
export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema = FormSchema<ComponentType>;
export type { VbenFormProps };

View File

@ -1,3 +1,5 @@
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import { h } from 'vue';
import { setupVbenVxeTable, useVbenVxeGrid } from '@vben/plugins/vxe-table';
@ -33,7 +35,7 @@ setupVbenVxeTable({
round: true,
showOverflow: true,
size: 'small',
},
} as VxeTableGridOptions,
});
// 表格配置项可以用 cellRender: { name: 'CellImage' },

View File

@ -12,12 +12,16 @@ import { useTitle } from '@vueuse/core';
import { $t, setupI18n } from '#/locales';
import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm } from './adapter/form';
import App from './app.vue';
import { router } from './router';
async function bootstrap(namespace: string) {
// 初始化组件适配器
initComponentAdapter();
await initComponentAdapter();
// 初始化表单组件
await initSetupVbenForm();
// // 设置弹窗的默认配置
// setDefaultModalProps({

View File

@ -1,11 +1,13 @@
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { Page, useVbenModal } from '@vben/common-ui';
import { NButton, NCard, useMessage } from 'naive-ui';
import { useVbenForm } from '#/adapter/form';
import { getAllMenusApi } from '#/api';
import modalDemo from './modal.vue';
const message = useMessage();
const [Form, formApi] = useVbenForm({
commonConfig: {
@ -143,6 +145,10 @@ function setFormValues() {
date: Date.now(),
});
}
const [Modal, modalApi] = useVbenModal({
connectedComponent: modalDemo,
});
</script>
<template>
<Page
@ -152,8 +158,12 @@ function setFormValues() {
<NCard title="基础表单">
<template #header-extra>
<NButton type="primary" @click="setFormValues"></NButton>
<NButton type="primary" @click="modalApi.open()" class="ml-2">
打开弹窗
</NButton>
</template>
<Form />
</NCard>
<Modal />
</Page>
</template>

View File

@ -0,0 +1,71 @@
<script lang="ts" setup>
import { useVbenModal } from '@vben/common-ui';
import { useVbenForm } from '#/adapter/form';
defineOptions({
name: 'FormModelDemo',
});
const [Form, formApi] = useVbenForm({
schema: [
{
component: 'Input',
componentProps: {
placeholder: '请输入',
},
fieldName: 'field1',
label: '字段1',
rules: 'required',
},
{
component: 'Input',
componentProps: {
placeholder: '请输入',
},
fieldName: 'field2',
label: '字段2',
rules: 'required',
},
{
component: 'Select',
componentProps: {
options: [
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
],
placeholder: '请输入',
},
fieldName: 'field3',
label: '字段3',
rules: 'required',
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
await formApi.validateAndSubmitForm();
// modalApi.close();
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const { values } = modalApi.getData<Record<string, any>>();
if (values) {
formApi.setValues(values);
}
}
},
title: '内嵌表单示例',
});
</script>
<template>
<Modal>
<Form />
</Modal>
</template>

View File

@ -1,6 +1,6 @@
{
"name": "@vben/docs",
"version": "5.5.6",
"version": "5.5.7",
"private": true,
"scripts": {
"build": "vitepress build",

View File

@ -22,7 +22,7 @@ outline: deep
## 基础用法
使用 `useVbenDrawer` 创建最基础的模态框
使用 `useVbenDrawer` 创建最基础的抽屉
<DemoPreview dir="demos/vben-drawer/basic" />
@ -52,7 +52,7 @@ Drawer 内的内容一般业务中,会比较复杂,所以我们可以将 dra
::: info 注意
- `VbenDrawer` 组件对参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenDrawer参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
- `VbenDrawer` 组件对参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenDrawer参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
- 如果你使用到了 `connectedComponent` 参数,那么会存在 2 个`useVbenDrawer`, 此时,如果同时设置了相同的参数,那么以内部为准(也就是没有设置 connectedComponent 的代码)。比如 同时设置了 `onConfirm`,那么以内部的 `onConfirm` 为准。`onOpenChange`事件除外,内外都会触发。
- 使用了`connectedComponent`参数时,可以配置`destroyOnClose`属性来决定当关闭弹窗时,是否要销毁`connectedComponent`组件(重新创建`connectedComponent`组件,这将会把其内部所有的变量、状态、数据等恢复到初始状态。)。
- 如果抽屉的默认行为不符合你的预期,可以在`src\bootstrap.ts`中修改`setDefaultDrawerProps`的参数来设置默认的属性如默认隐藏全屏按钮修改默认ZIndex等。
@ -77,7 +77,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
| 属性名 | 描述 | 类型 | 默认值 |
| --- | --- | --- | --- |
| appendToMain | 是否挂载到内容区域默认挂载到body | `boolean` | `false` |
| connectedComponent | 连接另一个Modal组件 | `Component` | - |
| connectedComponent | 连接另一个Drawer组件 | `Component` | - |
| destroyOnClose | 关闭时销毁 | `boolean` | `false` |
| title | 标题 | `string\|slot` | - |
| titleTooltip | 标题提示信息 | `string\|slot` | - |
@ -96,7 +96,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
| cancelText | 取消按钮文本 | `string\|slot` | `取消` |
| placement | 抽屉弹出位置 | `'left'\|'right'\|'top'\|'bottom'` | `right` |
| showCancelButton | 显示取消按钮 | `boolean` | `true` |
| showConfirmButton | 显示确认按钮文本 | `boolean` | `true` |
| showConfirmButton | 显示确认按钮 | `boolean` | `true` |
| class | modal的class宽度通过这个配置 | `string` | - |
| contentClass | modal内容区域的class | `string` | - |
| footerClass | modal底部区域的class | `string` | - |

View File

@ -26,6 +26,12 @@ outline: deep
<DemoPreview dir="demos/vben-ellipsis-text/tooltip" />
## 自动显示 tooltip
通过`tooltip-when-ellipsis`设置,仅在文本长度超出导致省略号出现时才触发 tooltip。
<DemoPreview dir="demos/vben-ellipsis-text/auto-display" />
## API
### Props
@ -37,6 +43,8 @@ outline: deep
| maxWidth | 文本区域最大宽度 | `number \| string` | `'100%'` |
| placement | 提示浮层的位置 | `'bottom'\|'left'\|'right'\|'top'` | `'top'` |
| tooltip | 启用文本提示 | `boolean` | `true` |
| tooltipWhenEllipsis | 内容超出,自动启用文本提示 | `boolean` | `false` |
| ellipsisThreshold | 设置 tooltipWhenEllipsis 后才生效,文本截断检测的像素差异阈值,越大则判断越严格,如果碰见异常情况可以自己设置阈值 | `number` | `3` |
| tooltipBackgroundColor | 提示文本的背景颜色 | `string` | - |
| tooltipColor | 提示文本的颜色 | `string` | - |
| tooltipFontSize | 提示文本的大小 | `string` | - |

View File

@ -324,6 +324,7 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| submitOnEnter | 按下回车健时提交表单 | `boolean` | false |
| submitOnChange | 字段值改变时提交表单(内部防抖,这个属性一般用于表格的搜索表单) | `boolean` | false |
| compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false |
| scrollToFirstError | 表单验证失败时是否自动滚动到第一个错误字段 | `boolean` | false |
::: tip handleValuesChange

View File

@ -0,0 +1,16 @@
<script lang="ts" setup>
import { EllipsisText } from '@vben/common-ui';
const text = `
Vben Admin 是一个基于 Vue3.0Vite TypeScript 的后台解决方案目标是为开发中大型项目提供开箱即用的解决方案
`;
</script>
<template>
<EllipsisText :line="2" :tooltip-when-ellipsis="true">
{{ text }}
</EllipsisText>
<EllipsisText :line="3" :tooltip-when-ellipsis="true">
{{ text }}
</EllipsisText>
</template>

View File

@ -15,6 +15,7 @@ const [Form] = useVbenForm({
handleSubmit: onSubmit,
// labelinputvertical
// labelinput
scrollToFirstError: true,
layout: 'horizontal',
schema: [
{

View File

@ -150,8 +150,8 @@ export async function saveUserApi(user: UserInfo) {
```ts
import { requestClient } from '#/api/request';
export async function deleteUserApi(user: UserInfo) {
return requestClient.delete<boolean>(`/user/${user.id}`, user);
export async function deleteUserApi(userId: number) {
return requestClient.delete<boolean>(`/user/${userId}`);
}
```

View File

@ -21,7 +21,7 @@ The rules are consistent with [Vite Env Variables and Modes](https://vitejs.dev/
console.log(import.meta.env.VITE_PROT);
```
- Variables starting with `VITE_GLOB_*` will be added to the `_app.config.js` configuration file during packaging. :::
- Variables starting with `VITE_GLOB_*` will be added to the `_app.config.js` configuration file during packaging.
:::
@ -138,6 +138,27 @@ To add a new dynamically modifiable configuration item, simply follow the steps
}
```
- In `packages/effects/hooks/src/use-app-config.ts`, add the corresponding configuration item, such as:
```ts
export function useAppConfig(
env: Record<string, any>,
isProduction: boolean,
): ApplicationConfig {
// In production environment, directly use the window._VBEN_ADMIN_PRO_APP_CONF_ global variable
const config = isProduction
? window._VBEN_ADMIN_PRO_APP_CONF_
: (env as VbenAdminProAppConfigRaw);
const { VITE_GLOB_API_URL, VITE_GLOB_OTHER_API_URL } = config; // [!code ++]
return {
apiURL: VITE_GLOB_API_URL,
otherApiURL: VITE_GLOB_OTHER_API_URL, // [!code ++]
};
}
```
At this point, you can use the `useAppConfig` method within the project to access the newly added configuration item.
```ts
@ -238,6 +259,7 @@ const defaultPreferences: Preferences = {
},
logo: {
enable: true,
fit: 'contain',
source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
},
navigation: {
@ -431,6 +453,8 @@ interface HeaderPreferences {
interface LogoPreferences {
/** Whether the logo is visible */
enable: boolean;
/** Logo image fitting method */
fit: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
/** Logo URL */
source: string;
}

View File

@ -4,10 +4,11 @@ outline: deep
# Access Control
The framework has built-in two types of access control methods:
The framework has built-in three types of access control methods:
- Determining whether a menu or button can be accessed based on user roles
- Determining whether a menu or button can be accessed through an API
- Mixed mode: Using both frontend and backend access control simultaneously
## Frontend Access Control
@ -151,6 +152,43 @@ const dashboardMenus = [
At this point, the configuration is complete. You need to ensure that after logging in, the format of the menu returned by the interface is correct; otherwise, access will not be possible.
## Mixed Access Control
**Implementation Principle**: Mixed mode combines both frontend access control and backend access control methods. The system processes frontend fixed route permissions and backend dynamic menu data in parallel, ultimately merging both parts of routes to provide a more flexible access control solution.
**Advantages**: Combines the performance advantages of frontend control with the flexibility of backend control, suitable for complex business scenarios requiring permission management.
### Steps
- Ensure the current mode is set to mixed access control
Adjust `preferences.ts` in the corresponding application directory to ensure `accessMode='mixed'`.
```ts
import { defineOverridesPreferences } from '@vben/preferences';
export const overridesPreferences = defineOverridesPreferences({
// overrides
app: {
accessMode: 'mixed',
},
});
```
- Configure frontend route permissions
Same as the route permission configuration method in [Frontend Access Control](#frontend-access-control) mode.
- Configure backend menu interface
Same as the interface configuration method in [Backend Access Control](#backend-access-control) mode.
- Ensure roles and permissions match
Must satisfy both frontend route permission configuration and backend menu data return requirements, ensuring user roles match the permission configurations of both modes.
At this point, the configuration is complete. Mixed mode will automatically merge frontend and backend routes, providing complete access control functionality.
## Fine-grained Control of Buttons
In some cases, we need to control the display of buttons with fine granularity. We can control the display of buttons through interfaces or roles.

View File

@ -4,7 +4,6 @@
- If you want to contribute code to the project, please ensure your code complies with the project's coding standards.
- If you are using `vscode`, you need to install the following plugins:
- [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) - Script code checking
- [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) - Code formatting
- [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker) - Word syntax checking
@ -157,7 +156,6 @@ The most effective solution is to perform Lint checks locally before committing.
The project defines corresponding hooks inside `lefthook.yml`:
- `pre-commit`: Runs before commit, used for code formatting and checking
- `code-workspace`: Updates VSCode workspace configuration
- `lint-md`: Formats Markdown files
- `lint-vue`: Formats and checks Vue files
@ -167,7 +165,6 @@ The project defines corresponding hooks inside `lefthook.yml`:
- `lint-json`: Formats other JSON files
- `post-merge`: Runs after merge, used for automatic dependency installation
- `install`: Runs `pnpm install` to install new dependencies
- `commit-msg`: Runs during commit, used for checking commit message format

View File

@ -18,7 +18,6 @@
### 友情链接
- 在您的网站上添加我们的友情链接,链接如下:
- 名称Vben Admin
- 链接https://www.vben.pro
- 描述Vben Admin 企业级开箱即用的中后台前端解决方案

View File

@ -180,8 +180,8 @@ export async function saveUserApi(user: UserInfo) {
```ts
import { requestClient } from '#/api/request';
export async function deleteUserApi(user: UserInfo) {
return requestClient.delete<boolean>(`/user/${user.id}`, user);
export async function deleteUserApi(userId: number) {
return requestClient.delete<boolean>(`/user/${userId}`);
}
```

View File

@ -21,7 +21,7 @@
console.log(import.meta.env.VITE_PROT);
```
- 以 `VITE_GLOB_*` 开头的的变量,在打包的时候,会被加入 `_app.config.js`配置文件当中. :::
- 以 `VITE_GLOB_*` 开头的的变量,在打包的时候,会被加入 `_app.config.js`配置文件当中.
:::
@ -137,6 +137,27 @@ const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
}
```
- 在 `packages/effects/hooks/src/use-app-config.ts` 中,新增对应的配置项,如:
```ts
export function useAppConfig(
env: Record<string, any>,
isProduction: boolean,
): ApplicationConfig {
// 生产环境下,直接使用 window._VBEN_ADMIN_PRO_APP_CONF_ 全局变量
const config = isProduction
? window._VBEN_ADMIN_PRO_APP_CONF_
: (env as VbenAdminProAppConfigRaw);
const { VITE_GLOB_API_URL, VITE_GLOB_OTHER_API_URL } = config; // [!code ++]
return {
apiURL: VITE_GLOB_API_URL,
otherApiURL: VITE_GLOB_OTHER_API_URL, // [!code ++]
};
}
```
到这里,就可以在项目内使用 `useAppConfig`方法获取到新增的配置项了。
```ts
@ -237,6 +258,7 @@ const defaultPreferences: Preferences = {
},
logo: {
enable: true,
fit: 'contain',
source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
},
navigation: {
@ -431,6 +453,8 @@ interface HeaderPreferences {
interface LogoPreferences {
/** logo是否可见 */
enable: boolean;
/** logo图片适应方式 */
fit: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
/** logo地址 */
source: string;
}

View File

@ -4,10 +4,11 @@ outline: deep
# 权限
框架内置了种权限控制方式:
框架内置了种权限控制方式:
- 通过用户角色来判断菜单或者按钮是否可以访问
- 通过接口来判断菜单或者按钮是否可以访问
- 混合模式:同时使用前端和后端权限控制
## 前端访问控制
@ -159,6 +160,43 @@ const dashboardMenus = [
到这里,就已经配置完成,你需要确保登录后,接口返回的菜单格式正确,否则无法访问。
## 混合访问控制
**实现原理**: 混合模式同时结合了前端访问控制和后端访问控制两种方式。系统会并行处理前端固定路由权限和后端动态菜单数据,最终将两部分路由合并,提供更灵活的权限控制方案。
**优点**: 兼具前端控制的性能优势和后端控制的灵活性,适合复杂业务场景下的权限管理。
### 步骤
- 确保当前模式为混合访问控制模式
调整对应应用目录下的`preferences.ts`,确保`accessMode='mixed'`。
```ts
import { defineOverridesPreferences } from '@vben/preferences';
export const overridesPreferences = defineOverridesPreferences({
// overrides
app: {
accessMode: 'mixed',
},
});
```
- 配置前端路由权限
同[前端访问控制](#前端访问控制)模式的路由权限配置方式。
- 配置后端菜单接口
同[后端访问控制](#后端访问控制)模式的接口配置方式。
- 确保角色和权限匹配
需要同时满足前端路由权限配置和后端菜单数据返回的要求,确保用户角色与两种模式的权限配置都匹配。
到这里,就已经配置完成,混合模式会自动合并前端和后端的路由,提供完整的权限控制功能。
## 按钮细粒度控制
在某些情况下,我们需要对按钮进行细粒度的控制,我们可以借助接口或者角色来控制按钮的显示。

View File

@ -4,7 +4,6 @@
- 如果你想向项目贡献代码,请确保你的代码符合项目的代码规范。
- 如果你使用的是 `vscode`,需要安装以下插件:
- [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) - 脚本代码检查
- [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) - 代码格式化
- [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker) - 单词语法检查
@ -157,7 +156,6 @@ git hook 一般结合各种 lint在 git 提交代码的时候进行代码风
项目在 `lefthook.yml` 内部定义了相应的 hooks
- `pre-commit`: 在提交前运行,用于代码格式化和检查
- `code-workspace`: 更新 VSCode 工作区配置
- `lint-md`: 格式化 Markdown 文件
- `lint-vue`: 格式化并检查 Vue 文件
@ -167,7 +165,6 @@ git hook 一般结合各种 lint在 git 提交代码的时候进行代码风
- `lint-json`: 格式化其他 JSON 文件
- `post-merge`: 在合并后运行,用于自动安装依赖
- `install`: 运行 `pnpm install` 安装新依赖
- `commit-msg`: 在提交时运行,用于检查提交信息格式

View File

@ -1,6 +1,6 @@
{
"name": "@vben/commitlint-config",
"version": "5.5.6",
"version": "5.5.7",
"private": true,
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",

View File

@ -1,6 +1,6 @@
{
"name": "@vben/stylelint-config",
"version": "5.5.6",
"version": "5.5.7",
"private": true,
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",

View File

@ -1,6 +1,6 @@
{
"name": "@vben/node-utils",
"version": "5.5.6",
"version": "5.5.7",
"private": true,
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",

View File

@ -1,6 +1,6 @@
{
"name": "@vben/tailwind-config",
"version": "5.5.6",
"version": "5.5.7",
"private": true,
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",

View File

@ -1,6 +1,6 @@
{
"name": "@vben/tsconfig",
"version": "5.5.6",
"version": "5.5.7",
"private": true,
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",

View File

@ -1,6 +1,6 @@
{
"name": "vben-admin-monorepo",
"version": "5.5.6",
"version": "5.5.7",
"private": true,
"keywords": [
"monorepo",
@ -98,7 +98,7 @@
"node": ">=20.10.0",
"pnpm": ">=9.12.0"
},
"packageManager": "pnpm@10.10.0",
"packageManager": "pnpm@10.12.4",
"pnpm": {
"peerDependencyRules": {
"allowedVersions": {

View File

@ -1,6 +1,6 @@
{
"name": "@vben-core/design",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -1,6 +1,6 @@
{
"name": "@vben-core/icons",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -1,6 +1,6 @@
{
"name": "@vben-core/shared",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -1,6 +1,6 @@
{
"name": "@vben-core/typings",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -60,8 +60,9 @@ type BreadcrumbStyleType = 'background' | 'normal';
*
* backend
* frontend
* mixed
*/
type AccessModeType = 'backend' | 'frontend';
type AccessModeType = 'backend' | 'frontend' | 'mixed';
/**
*

View File

@ -1,6 +1,6 @@
{
"name": "@vben-core/composables",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -61,6 +61,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj
},
"logo": {
"enable": true,
"fit": "contain",
"source": "https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp",
},
"navigation": {

View File

@ -1,6 +1,6 @@
{
"name": "@vben-core/preferences",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -62,6 +62,7 @@ const defaultPreferences: Preferences = {
logo: {
enable: true,
fit: 'contain',
source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
},
navigation: {

View File

@ -134,6 +134,8 @@ interface HeaderPreferences {
interface LogoPreferences {
/** logo是否可见 */
enable: boolean;
/** logo图片适应方式 */
fit: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
/** logo地址 */
source: string;
}

View File

@ -1,6 +1,6 @@
{
"name": "@vben-core/form-ui",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -48,13 +48,18 @@ const queryFormStyle = computed(() => {
async function handleSubmit(e: Event) {
e?.preventDefault();
e?.stopPropagation();
const { valid } = await form.validate();
const props = unref(rootProps);
if (!props.formApi) {
return;
}
const { valid } = await props.formApi.validate();
if (!valid) {
return;
}
const values = toRaw(await unref(rootProps).formApi?.getValues());
await unref(rootProps).handleSubmit?.(values);
const values = toRaw(await props.formApi.getValues());
await props.handleSubmit?.(values);
}
async function handleReset(e: Event) {

View File

@ -11,7 +11,7 @@ import type { Recordable } from '@vben-core/typings';
import type { FormActions, FormSchema, VbenFormProps } from './types';
import { toRaw } from 'vue';
import { isRef, toRaw } from 'vue';
import { Store } from '@vben-core/shared/store';
import {
@ -39,6 +39,7 @@ function getDefaultState(): VbenFormProps {
layout: 'horizontal',
resetButtonOptions: {},
schema: [],
scrollToFirstError: false,
showCollapseButton: false,
showDefaultActions: true,
submitButtonOptions: {},
@ -100,9 +101,26 @@ export class FormApi {
getFieldComponentRef<T = ComponentPublicInstance>(
fieldName: string,
): T | undefined {
return this.componentRefMap.has(fieldName)
? (this.componentRefMap.get(fieldName) as T)
let target = this.componentRefMap.has(fieldName)
? (this.componentRefMap.get(fieldName) as ComponentPublicInstance)
: undefined;
if (
target &&
target.$.type.name === 'AsyncComponentWrapper' &&
target.$.subTree.ref
) {
if (Array.isArray(target.$.subTree.ref)) {
if (
target.$.subTree.ref.length > 0 &&
isRef(target.$.subTree.ref[0]?.r)
) {
target = target.$.subTree.ref[0]?.r.value as ComponentPublicInstance;
}
} else if (isRef(target.$.subTree.ref.r)) {
target = target.$.subTree.ref.r.value as ComponentPublicInstance;
}
}
return target as T;
}
/**
@ -236,6 +254,41 @@ export class FormApi {
});
}
/**
*
* @param errors
*/
scrollToFirstError(errors: Record<string, any> | string) {
// https://github.com/logaretm/vee-validate/discussions/3835
const firstErrorFieldName =
typeof errors === 'string' ? errors : Object.keys(errors)[0];
if (!firstErrorFieldName) {
return;
}
let el = document.querySelector(
`[name="${firstErrorFieldName}"]`,
) as HTMLElement;
// 如果通过 name 属性找不到,尝试通过组件引用查找, 正常情况下不会走到这,怕哪天 vee-validate 改了 name 属性有个兜底的
if (!el) {
const componentRef = this.getFieldComponentRef(firstErrorFieldName);
if (componentRef && componentRef.$el instanceof HTMLElement) {
el = componentRef.$el;
}
}
if (el) {
// 滚动到错误字段,添加一些偏移量以确保字段完全可见
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
});
}
}
async setFieldValue(field: string, value: any, shouldValidate?: boolean) {
const form = await this.getForm();
form.setFieldValue(field, value, shouldValidate);
@ -360,14 +413,21 @@ export class FormApi {
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
console.error('validate error', validateResult?.errors);
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(validateResult.errors);
}
}
return validateResult;
}
async validateAndSubmitForm() {
const form = await this.getForm();
const { valid } = await form.validate();
const { valid, errors } = await form.validate();
if (!valid) {
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(errors);
}
return;
}
return await this.submitForm();
@ -379,6 +439,10 @@ export class FormApi {
if (Object.keys(validateResult?.errors ?? {}).length > 0) {
console.error('validate error', validateResult?.errors);
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(fieldName);
}
}
return validateResult;
}

View File

@ -387,6 +387,12 @@ export interface VbenFormProps<
*/
resetButtonOptions?: ActionButtonOptions;
/**
*
* @default false
*/
scrollToFirstError?: boolean;
/**
*
* @default true

View File

@ -10,7 +10,7 @@ import { createContext } from '@vben-core/shadcn-ui';
import { isString, mergeWithArrayOverride, set } from '@vben-core/shared/utils';
import { useForm } from 'vee-validate';
import { object } from 'zod';
import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod';
import { getDefaultsForSchema } from 'zod-defaults';
type ExtendFormProps = VbenFormProps & { formApi: ExtendedFormApi };
@ -52,7 +52,12 @@ export function useFormInitial(
if (Reflect.has(item, 'defaultValue')) {
set(initialValues, item.fieldName, item.defaultValue);
} else if (item.rules && !isString(item.rules)) {
// 检查规则是否适合提取默认值
const customDefaultValue = getCustomDefaultValue(item.rules);
zodObject[item.fieldName] = item.rules;
if (customDefaultValue !== undefined) {
initialValues[item.fieldName] = customDefaultValue;
}
}
});
@ -64,6 +69,38 @@ export function useFormInitial(
}
return mergeWithArrayOverride(initialValues, zodDefaults);
}
// 自定义默认值提取逻辑
function getCustomDefaultValue(rule: any): any {
if (rule instanceof ZodString) {
return ''; // 默认为空字符串
} else if (rule instanceof ZodNumber) {
return null; // 默认为 null避免显示 0
} else if (rule instanceof ZodObject) {
// 递归提取嵌套对象的默认值
const defaultValues: Record<string, any> = {};
for (const [key, valueSchema] of Object.entries(rule.shape)) {
defaultValues[key] = getCustomDefaultValue(valueSchema);
}
return defaultValues;
} else if (rule instanceof ZodIntersection) {
// 对于交集类型从schema 提取默认值
const leftDefaultValue = getCustomDefaultValue(rule._def.left);
const rightDefaultValue = getCustomDefaultValue(rule._def.right);
// 如果左右两边都能提取默认值,合并它们
if (
typeof leftDefaultValue === 'object' &&
typeof rightDefaultValue === 'object'
) {
return { ...leftDefaultValue, ...rightDefaultValue };
}
// 否则优先使用左边的默认值
return leftDefaultValue ?? rightDefaultValue;
} else {
return undefined; // 其他类型不提供默认值
}
}
return {
delegatedSlots,

View File

@ -1,6 +1,6 @@
{
"name": "@vben-core/layout-ui",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -1,6 +1,6 @@
{
"name": "@vben-core/menu-ui",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -34,7 +34,6 @@ const props = withDefaults(defineProps<AlertProps>(), {
bordered: true,
buttonAlign: 'end',
centered: true,
containerClass: 'w-[520px]',
});
const emits = defineEmits(['closed', 'confirm', 'opened']);
const open = defineModel<boolean>('open', { default: false });
@ -148,7 +147,7 @@ async function handleOpenChange(val: boolean) {
:class="
cn(
containerClass,
'left-0 right-0 mx-auto flex max-h-[80%] flex-col p-0 duration-300 sm:rounded-[var(--radius)] md:w-[520px] md:max-w-[80%]',
'left-0 right-0 mx-auto flex max-h-[80%] flex-col p-0 duration-300 sm:w-[520px] sm:max-w-[80%] sm:rounded-[var(--radius)]',
{
'border-border border': bordered,
'shadow-3xl': !bordered,

View File

@ -1,7 +1,15 @@
<script lang="ts" setup>
import type { DrawerProps, ExtendedDrawerApi } from './drawer';
import { computed, provide, ref, unref, useId, watch } from 'vue';
import {
computed,
onDeactivated,
provide,
ref,
unref,
useId,
watch,
} from 'vue';
import {
useIsMobile,
@ -94,6 +102,16 @@ const {
// },
// );
/**
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
*/
onDeactivated(() => {
//
if (!appendToMain.value) {
props.drawerApi?.close();
}
});
function interactOutside(e: Event) {
if (!closeOnClickModal.value || submitting.value) {
e.preventDefault();

View File

@ -9,7 +9,6 @@ import {
h,
inject,
nextTick,
onDeactivated,
provide,
reactive,
ref,
@ -72,13 +71,6 @@ export function useVbenDrawer<
},
);
/**
* keepAlive /
*/
onDeactivated(() => {
(extendedApi as ExtendedDrawerApi)?.close?.();
});
return [Drawer, extendedApi as ExtendedDrawerApi] as const;
}

View File

@ -1,7 +1,16 @@
<script lang="ts" setup>
import type { ExtendedModalApi, ModalProps } from './modal';
import { computed, nextTick, provide, ref, unref, useId, watch } from 'vue';
import {
computed,
nextTick,
onDeactivated,
provide,
ref,
unref,
useId,
watch,
} from 'vue';
import {
useIsMobile,
@ -96,10 +105,17 @@ const shouldDraggable = computed(
() => draggable.value && !shouldFullscreen.value && header.value,
);
const getAppendTo = computed(() => {
return appendToMain.value
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
: undefined;
});
const { dragging, transform } = useModalDraggable(
dialogRef,
headerRef,
shouldDraggable,
getAppendTo,
);
const firstOpened = ref(false);
@ -135,6 +151,16 @@ watch(
// },
// );
/**
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
*/
onDeactivated(() => {
//
if (!appendToMain.value) {
props.modalApi?.close();
}
});
function handleFullscreen() {
props.modalApi?.setState((prev) => {
// if (prev.fullscreen) {
@ -179,11 +205,6 @@ function handleFocusOutside(e: Event) {
e.preventDefault();
e.stopPropagation();
}
const getAppendTo = computed(() => {
return appendToMain.value
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
: undefined;
});
const getForceMount = computed(() => {
return !unref(destroyOnClose) && unref(firstOpened);
@ -205,7 +226,8 @@ function handleClosed() {
:append-to="getAppendTo"
:class="
cn(
'left-0 right-0 top-[10vh] mx-auto flex max-h-[80%] w-[520px] flex-col p-0 sm:rounded-[var(--radius)]',
'left-0 right-0 top-[10vh] mx-auto flex max-h-[80%] w-[520px] flex-col p-0',
shouldFullscreen ? 'sm:rounded-none' : 'sm:rounded-[var(--radius)]',
modalClass,
{
'border-border border': bordered,

View File

@ -13,6 +13,7 @@ export function useModalDraggable(
targetRef: Ref<HTMLElement | undefined>,
dragRef: Ref<HTMLElement | undefined>,
draggable: ComputedRef<boolean>,
containerSelector?: ComputedRef<string | undefined>,
) {
const transform = reactive({
offsetX: 0,
@ -30,20 +31,36 @@ export function useModalDraggable(
}
const targetRect = targetRef.value.getBoundingClientRect();
const { offsetX, offsetY } = transform;
const targetLeft = targetRect.left;
const targetTop = targetRect.top;
const targetWidth = targetRect.width;
const targetHeight = targetRect.height;
const docElement = document.documentElement;
const clientWidth = docElement.clientWidth;
const clientHeight = docElement.clientHeight;
const minLeft = -targetLeft + offsetX;
const minTop = -targetTop + offsetY;
const maxLeft = clientWidth - targetLeft - targetWidth + offsetX;
const maxTop = clientHeight - targetTop - targetHeight + offsetY;
let containerRect: DOMRect | null = null;
if (containerSelector?.value) {
const container = document.querySelector(containerSelector.value);
if (container) {
containerRect = container.getBoundingClientRect();
}
}
let maxLeft, maxTop, minLeft, minTop;
if (containerRect) {
minLeft = containerRect.left - targetLeft + offsetX;
maxLeft = containerRect.right - targetLeft - targetWidth + offsetX;
minTop = containerRect.top - targetTop + offsetY;
maxTop = containerRect.bottom - targetTop - targetHeight + offsetY;
} else {
const docElement = document.documentElement;
const clientWidth = docElement.clientWidth;
const clientHeight = docElement.clientHeight;
minLeft = -targetLeft + offsetX;
minTop = -targetTop + offsetY;
maxLeft = clientWidth - targetLeft - targetWidth + offsetX;
maxTop = clientHeight - targetTop - targetHeight + offsetY;
}
const onMousemove = (e: MouseEvent) => {
let moveX = offsetX + e.clientX - downX;

View File

@ -5,7 +5,6 @@ import {
h,
inject,
nextTick,
onDeactivated,
provide,
reactive,
ref,
@ -71,13 +70,6 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
},
);
/**
* keepAlive /
*/
onDeactivated(() => {
(extendedApi as ExtendedModalApi)?.close?.();
});
return [Modal, extendedApi as ExtendedModalApi] as const;
}
@ -94,8 +86,9 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
injectData.options?.onOpenChange?.(isOpen);
};
const onClosed = mergedOptions.onClosed;
mergedOptions.onClosed = () => {
options.onClosed?.();
onClosed?.();
if (mergedOptions.destroyOnClose) {
injectData.reCreateModal?.();
}
@ -129,6 +122,7 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
},
);
injectData.extendApi?.(extendedApi);
return [Modal, extendedApi] as const;
}

View File

@ -1,6 +1,6 @@
{
"name": "@vben-core/shadcn-ui",
"version": "5.5.6",
"version": "5.5.7",
"#main": "./dist/index.mjs",
"#module": "./dist/index.mjs",
"homepage": "https://github.com/vbenjs/vue-vben-admin",

View File

@ -5,6 +5,8 @@ import type {
AvatarRootProps,
} from 'radix-vue';
import type { CSSProperties } from 'vue';
import type { ClassType } from '@vben-core/typings';
import { computed } from 'vue';
@ -16,6 +18,7 @@ interface Props extends AvatarFallbackProps, AvatarImageProps, AvatarRootProps {
class?: ClassType;
dot?: boolean;
dotClass?: ClassType;
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
size?: number;
}
@ -28,6 +31,15 @@ const props = withDefaults(defineProps<Props>(), {
as: 'button',
dot: false,
dotClass: 'bg-green-500',
fit: 'cover',
});
const imageStyle = computed<CSSProperties>(() => {
const { fit } = props;
if (fit) {
return { objectFit: fit };
}
return {};
});
const text = computed(() => {
@ -51,7 +63,7 @@ const rootStyle = computed(() => {
class="relative flex flex-shrink-0 items-center"
>
<Avatar :class="props.class" class="size-full">
<AvatarImage :alt="alt" :src="src" />
<AvatarImage :alt="alt" :src="src" :style="imageStyle" />
<AvatarFallback>{{ text }}</AvatarFallback>
</Avatar>
<span

View File

@ -29,14 +29,25 @@ export type ValueType = boolean | number | string;
export interface VbenButtonGroupProps
extends Pick<VbenButtonProps, 'disabled'> {
/** 单选模式下允许清除选中 */
allowClear?: boolean;
/** 值改变前的回调 */
beforeChange?: (
value: ValueType,
isChecked: boolean,
) => boolean | PromiseLike<boolean | undefined> | undefined;
/** 按钮样式 */
btnClass?: any;
/** 按钮间隔距离 */
gap?: number;
/** 多选模式下限制最多选择的数量。0表示不限制 */
maxCount?: number;
/** 是否允许多选 */
multiple?: boolean;
/** 选项 */
options?: { [key: string]: any; label: CustomRenderType; value: ValueType }[];
/** 显示图标 */
showIcon?: boolean;
/** 尺寸 */
size?: 'large' | 'middle' | 'small';
}

View File

@ -19,6 +19,8 @@ const props = withDefaults(defineProps<VbenButtonGroupProps>(), {
multiple: false,
showIcon: true,
size: 'middle',
allowClear: false,
maxCount: 0,
});
const emit = defineEmits(['btnClick']);
const btnDefaultProps = computed(() => {
@ -82,12 +84,22 @@ async function onBtnClick(value: ValueType) {
if (innerValue.value.includes(value)) {
innerValue.value = innerValue.value.filter((item) => item !== value);
} else {
if (props.maxCount > 0 && innerValue.value.length >= props.maxCount) {
innerValue.value = innerValue.value.slice(0, props.maxCount - 1);
}
innerValue.value.push(value);
}
modelValue.value = innerValue.value;
} else {
innerValue.value = [value];
modelValue.value = value;
if (props.allowClear && innerValue.value.includes(value)) {
innerValue.value = [];
modelValue.value = undefined;
emit('btnClick', undefined);
return;
} else {
innerValue.value = [value];
modelValue.value = value;
}
}
emit('btnClick', value);
}
@ -110,14 +122,21 @@ async function onBtnClick(value: ValueType) {
v-bind="btnDefaultProps"
:variant="innerValue.includes(btn.value) ? 'default' : 'outline'"
@click="onBtnClick(btn.value)"
type="button"
>
<div class="icon-wrapper" v-if="props.showIcon">
<LoaderCircle
class="animate-spin"
v-if="loadingValues.includes(btn.value)"
/>
<CircleCheckBig v-else-if="innerValue.includes(btn.value)" />
<Circle v-else />
<slot
name="icon"
:loading="loadingValues.includes(btn.value)"
:checked="innerValue.includes(btn.value)"
>
<LoaderCircle
class="animate-spin"
v-if="loadingValues.includes(btn.value)"
/>
<CircleCheckBig v-else-if="innerValue.includes(btn.value)" />
<Circle v-else />
</slot>
</div>
<slot name="option" :label="btn.label" :value="btn.value" :data="btn">
<VbenRenderContent :content="btn.label" />

View File

@ -6,6 +6,10 @@ interface Props {
* @zh_CN 是否收起文本
*/
collapsed?: boolean;
/**
* @zh_CN Logo 图片适应方式
*/
fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
/**
* @zh_CN Logo 跳转地址
*/
@ -38,6 +42,7 @@ withDefaults(defineProps<Props>(), {
logoSize: 32,
src: '',
theme: 'light',
fit: 'cover',
});
</script>
@ -53,6 +58,7 @@ withDefaults(defineProps<Props>(), {
:alt="text"
:src="src"
:size="logoSize"
:fit="fit"
class="relative rounded-none bg-transparent"
/>
<template v-if="!collapsed">

View File

@ -80,7 +80,7 @@ defineExpose({
v-bind="forwarded"
:class="
cn(
'z-popup bg-background w-full p-6 shadow-lg outline-none sm:rounded-xl',
'z-popup bg-background p-6 shadow-lg outline-none sm:rounded-xl',
'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
{

View File

@ -23,6 +23,7 @@ const props = withDefaults(defineProps<TreeProps>(), {
defaultExpandedKeys: () => [],
defaultExpandedLevel: 0,
disabled: false,
disabledField: 'disabled',
expanded: () => [],
iconField: 'icon',
labelField: 'label',
@ -101,16 +102,37 @@ function updateTreeValue() {
if (val === undefined) {
treeValue.value = undefined;
} else {
treeValue.value = Array.isArray(val)
? val.map((v) => getItemByValue(v))
: getItemByValue(val);
if (Array.isArray(val)) {
const filteredValues = val.filter((v) => {
const item = getItemByValue(v);
return item && !get(item, props.disabledField);
});
treeValue.value = filteredValues.map((v) => getItemByValue(v));
if (filteredValues.length !== val.length) {
modelValue.value = filteredValues;
}
} else {
const item = getItemByValue(val);
if (item && !get(item, props.disabledField)) {
treeValue.value = item;
} else {
treeValue.value = undefined;
modelValue.value = undefined;
}
}
}
}
function updateModelValue(val: Arrayable<Recordable<any>>) {
modelValue.value = Array.isArray(val)
? val.map((v) => get(v, props.valueField))
: get(val, props.valueField);
if (Array.isArray(val)) {
const filteredVal = val.filter((v) => !get(v, props.disabledField));
modelValue.value = filteredVal.map((v) => get(v, props.valueField));
} else {
if (val && !get(val, props.disabledField)) {
modelValue.value = get(val, props.valueField);
}
}
}
function expandToLevel(level: number) {
@ -149,10 +171,18 @@ function collapseAll() {
expanded.value = [];
}
function isNodeDisabled(item: FlattenedItem<Recordable<any>>) {
return props.disabled || get(item.value, props.disabledField);
}
function onToggle(item: FlattenedItem<Recordable<any>>) {
emits('expand', item);
}
function onSelect(item: FlattenedItem<Recordable<any>>, isSelected: boolean) {
if (isNodeDisabled(item)) {
return;
}
if (
!props.checkStrictly &&
props.multiple &&
@ -224,34 +254,44 @@ defineExpose({
:class="
cn('cursor-pointer', getNodeClass?.(item), {
'data-[selected]:bg-accent': !multiple,
'cursor-not-allowed': disabled,
'cursor-not-allowed': isNodeDisabled(item),
})
"
v-bind="
Object.assign(item.bind, {
onfocus: disabled ? 'this.blur()' : undefined,
onfocus: isNodeDisabled(item) ? 'this.blur()' : undefined,
disabled: isNodeDisabled(item),
})
"
@select="
(event) => {
(event: any) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
}
if (event.detail.originalEvent.type === 'click') {
event.preventDefault();
}
!disabled && onSelect(item, event.detail.isSelected);
onSelect(item, event.detail.isSelected);
}
"
@toggle="
(event) => {
(event: any) => {
if (event.detail.originalEvent.type === 'click') {
event.preventDefault();
}
!disabled && onToggle(item);
!isNodeDisabled(item) && onToggle(item);
}
"
class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2"
>
<ChevronRight
v-if="item.hasChildren"
v-if="
item.hasChildren &&
Array.isArray(item.value[childrenField]) &&
item.value[childrenField].length > 0
"
class="size-4 cursor-pointer transition"
:class="{ 'rotate-90': isExpanded }"
@click.stop="
@ -266,24 +306,32 @@ defineExpose({
</div>
<Checkbox
v-if="multiple"
:checked="isSelected"
:disabled="disabled"
:indeterminate="isIndeterminate"
:checked="isSelected && !isNodeDisabled(item)"
:disabled="isNodeDisabled(item)"
:indeterminate="isIndeterminate && !isNodeDisabled(item)"
@click="
() => {
!disabled && handleSelect();
// onSelect(item, !isSelected);
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
}
handleSelect();
}
"
/>
<div
class="flex items-center gap-1 pl-2"
@click="
(_event) => {
// $event.stopPropagation();
// $event.preventDefault();
!disabled && handleSelect();
// onSelect(item, !isSelected);
(event: MouseEvent) => {
if (isNodeDisabled(item)) {
event.preventDefault();
event.stopPropagation();
return;
}
event.stopPropagation();
event.preventDefault();
handleSelect();
}
"
>

View File

@ -22,6 +22,8 @@ export interface TreeProps {
defaultValue?: Arrayable<number | string>;
/** 禁用 */
disabled?: boolean;
/** 禁用字段名 */
disabledField?: string;
/** 自定义节点类名 */
getNodeClass?: (item: FlattenedItem<Recordable<any>>) => string;
iconField?: string;

View File

@ -1,6 +1,6 @@
{
"name": "@vben-core/tabs-ui",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -1,6 +1,6 @@
{
"name": "@vben/constants",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -1,6 +1,6 @@
{
"name": "@vben/access",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -96,6 +96,15 @@ async function generateRoutes(
);
break;
}
case 'mixed': {
const [frontend_resultRoutes, backend_resultRoutes] = await Promise.all([
generateRoutesByFrontend(routes, roles || [], forbiddenComponent),
generateRoutesByBackend(options),
]);
resultRoutes = [...frontend_resultRoutes, ...backend_resultRoutes];
break;
}
}
/**

View File

@ -1,6 +1,6 @@
{
"name": "@vben/common-ui",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {
@ -40,6 +40,7 @@
"@vben/types": "workspace:*",
"@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:",
"json-bigint": "catalog:",
"qrcode": "catalog:",
"tippy.js": "catalog:",
"vue": "catalog:",

View File

@ -3,11 +3,11 @@ import type { Component } from 'vue';
import type { AnyPromiseFunction } from '@vben/types';
import { 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';
@ -104,6 +104,8 @@ 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;
@ -146,18 +148,26 @@ const bindProps = computed(() => {
});
async function fetchApi() {
let { api, beforeFetch, afterFetch, params, resultField } = props;
const { api, beforeFetch, 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);
let res = await api(finalParams);
if (afterFetch && isFunction(afterFetch)) {
res = (await afterFetch(res)) || res;
}
@ -177,6 +187,13 @@ async function fetchApi() {
isFirstLoaded.value = false;
} finally {
loading.value = false;
//
if (hasPendingRequest.value) {
hasPendingRequest.value = false;
// 使 nextTick
await nextTick();
fetchApi();
}
}
}
@ -190,7 +207,7 @@ async function handleFetchForVisible(visible: boolean) {
}
}
const params = computed(() => {
const mergedParams = computed(() => {
return {
...props.params,
...unref(innerParams),
@ -198,7 +215,7 @@ const params = computed(() => {
});
watch(
params,
mergedParams,
(value, oldValue) => {
if (isEqual(value, oldValue)) {
return;

View File

@ -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';

View File

@ -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;
enum CanvasOpr {
// eslint-disable-next-line no-unused-vars
Clip = 'clip',
// eslint-disable-next-line no-unused-vars
Fill = 'fill',
}
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="border-border relative flex cursor-pointer overflow-hidden 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="h-15 absolute bottom-3 left-0 z-10 block 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>

View File

@ -159,6 +159,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;

View File

@ -1,7 +1,14 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue';
import { computed, ref, watchEffect } from 'vue';
import {
computed,
onBeforeUnmount,
onMounted,
onUpdated,
ref,
watchEffect,
} from 'vue';
import { VbenTooltip } from '@vben-core/shadcn-ui';
@ -33,6 +40,16 @@ interface Props {
* @default true
*/
tooltip?: boolean;
/**
* 是否只在文本被截断时显示提示框
* @default false
*/
tooltipWhenEllipsis?: boolean;
/**
* 文本截断检测的像素差异阈值越大则判断越严格
* @default 3
*/
ellipsisThreshold?: number;
/**
* 提示框背景颜色优先级高于 overlayStyle
*/
@ -62,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(() => {
@ -79,9 +99,67 @@ 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 && eleWidth.value) {
@ -91,9 +169,13 @@ watchEffect(
},
{ flush: 'post' },
);
function onExpand() {
isExpand.value = !isExpand.value;
emit('expandChange', isExpand.value);
if (props.tooltipWhenEllipsis) {
checkEllipsis();
}
}
function handleExpand() {
@ -110,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">

View File

@ -76,6 +76,12 @@ const keyword = ref('');
const keywordDebounce = refDebounced(keyword, 300);
const innerIcons = ref<string[]>([]);
/* 当检索关键词变化时,重置分页 */
watch(keywordDebounce, () => {
currentPage.value = 1;
setCurrentPage(1);
});
watchDebounced(
() => props.prefix,
async (prefix) => {

View File

@ -18,6 +18,9 @@ import { $t } from '@vben/locales';
import { isBoolean } from '@vben-core/shared/utils';
// @ts-ignore
import JsonBigint from 'json-bigint';
defineOptions({ name: 'JsonViewer' });
const props = withDefaults(defineProps<JsonViewerProps>(), {
@ -68,6 +71,20 @@ function handleClick(event: MouseEvent) {
emit('click', event);
}
// 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 copyable = {
copyText: $t('ui.jsonViewer.copy'),
@ -79,6 +96,7 @@ const bindProps = computed<Recordable<any>>(() => {
return {
...props,
...attrs,
value: jsonData.value,
onCopied: (event: JsonViewerAction) => emit('copied', event),
onKeyclick: (key: string) => emit('keyClick', key),
onClick: (event: MouseEvent) => handleClick(event),

View File

@ -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';

View File

@ -1,6 +1,6 @@
{
"name": "@vben/hooks",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -2,48 +2,139 @@ import type { Arrayable, MaybeElementRef } from '@vueuse/core';
import type { Ref } from 'vue';
import { computed, onUnmounted, ref, unref, watch } from 'vue';
import { computed, effectScope, onUnmounted, ref, unref, watch } from 'vue';
import { isFunction } from '@vben/utils';
import { 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 isHovers: 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 eleRef = computed(() => {
const ele = unref(refEle);
return ele instanceof Element ? ele : (ele?.$el as Element);
});
const isHover = useElementHover(eleRef);
isHovers.push(isHover);
});
const isOutsideAll = computed(() => isHovers.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);
@ -53,15 +144,19 @@ export function useHoverToggle(
const controller = {
enable() {
watcher.resume();
hoverWatcher.resume();
},
disable() {
watcher.pause();
hoverWatcher.pause();
},
};
onUnmounted(() => {
timer.value && clearTimeout(timer.value);
clearTimers();
// 停止监听器
stopWatcher();
// 停止所有剩余的作用域
hoverScopes.value.forEach((scope) => scope.stop());
});
return [value, controller] as [typeof value, typeof controller];

View File

@ -1,6 +1,6 @@
{
"name": "@vben/layouts",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -62,21 +62,23 @@ const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
</template>
</AuthenticationFormView>
<!-- 头部 Logo 和应用名称 -->
<div
v-if="logo || appName"
class="absolute left-0 top-0 z-10 flex flex-1"
@click="clickLogo"
>
<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="logo || appName"
class="absolute left-0 top-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="m-0 text-xl font-medium">
{{ appName }}
</p>
<div
class="text-foreground lg:text-foreground ml-4 mt-4 flex flex-1 items-center sm:left-6 sm:top-6"
>
<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 }}
</p>
</div>
</div>
</div>
</slot>
<!-- 系统介绍 -->
<div v-if="!authPanelCenter" class="relative hidden w-0 flex-1 lg:block">

View File

@ -234,6 +234,7 @@ const headerSlots = computed(() => {
<template #logo>
<VbenLogo
v-if="preferences.logo.enable"
:fit="preferences.logo.fit"
:class="logoClass"
:collapsed="logoCollapsed"
:src="preferences.logo.source"
@ -324,6 +325,7 @@ const headerSlots = computed(() => {
<template #side-extra-title>
<VbenLogo
v-if="preferences.logo.enable"
:fit="preferences.logo.fit"
:text="preferences.app.name"
:theme="theme"
>

View File

@ -6,7 +6,7 @@ import { $t } from '@vben/locales';
import { useVbenModal } from '@vben-core/popup-ui';
interface Props {
//
//
checkUpdatesInterval?: number;
//
checkUpdateUrl?: string;
@ -46,6 +46,7 @@ async function getVersionTag() {
const response = await fetch(props.checkUpdateUrl, {
cache: 'no-cache',
method: 'HEAD',
redirect: 'manual',
});
return (

View File

@ -46,7 +46,11 @@ interface Props {
/**
* 菜单数组
*/
menus?: Array<{ handler: AnyFunction; icon?: Component; text: string }>;
menus?: Array<{
handler: AnyFunction;
icon?: Component | Function | string;
text: string;
}>;
/**
* 标签文本

View File

@ -26,14 +26,14 @@ function getDefaultState(): VxeGridProps {
};
}
export class VxeGridApi {
export class VxeGridApi<T extends Record<string, any> = any> {
public formApi = {} as ExtendedFormApi;
// private prevState: null | VxeGridProps = null;
public grid = {} as VxeGridInstance;
public state: null | VxeGridProps = null;
public grid = {} as VxeGridInstance<T>;
public state: null | VxeGridProps<T> = null;
public store: Store<VxeGridProps>;
public store: Store<VxeGridProps<T>>;
private isMounted = false;
@ -99,8 +99,8 @@ export class VxeGridApi {
setState(
stateOrFn:
| ((prev: VxeGridProps) => Partial<VxeGridProps>)
| Partial<VxeGridProps>,
| ((prev: VxeGridProps<T>) => Partial<VxeGridProps<T>>)
| Partial<VxeGridProps<T>>,
) {
if (isFunction(stateOrFn)) {
this.store.setState((prev) => {

View File

@ -3,4 +3,8 @@ export type { VxeTableGridOptions } from './types';
export * from './use-vxe-grid';
export { default as VbenVxeGrid } from './use-vxe-grid.vue';
export type { VxeGridListeners, VxeGridProps } from 'vxe-table';
export type {
VxeGridListeners,
VxeGridProps,
VxeGridPropTypes,
} from 'vxe-table';

View File

@ -9,7 +9,7 @@ import type { Ref } from 'vue';
import type { ClassType, DeepPartial } from '@vben/types';
import type { VbenFormProps } from '@vben-core/form-ui';
import type { BaseFormComponentType, VbenFormProps } from '@vben-core/form-ui';
import type { VxeGridApi } from './api';
@ -35,7 +35,11 @@ export interface SeparatorOptions {
show?: boolean;
backgroundColor?: string;
}
export interface VxeGridProps {
export interface VxeGridProps<
T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType,
> {
/**
*
*/
@ -55,15 +59,15 @@ export interface VxeGridProps {
/**
* vxe-grid
*/
gridOptions?: DeepPartial<VxeTableGridOptions>;
gridOptions?: DeepPartial<VxeTableGridOptions<T>>;
/**
* vxe-grid
*/
gridEvents?: DeepPartial<VxeGridListeners>;
gridEvents?: DeepPartial<VxeGridListeners<T>>;
/**
*
*/
formOptions?: VbenFormProps;
formOptions?: VbenFormProps<D>;
/**
*
*/
@ -74,9 +78,12 @@ export interface VxeGridProps {
separator?: boolean | SeparatorOptions;
}
export type ExtendedVxeGridApi = VxeGridApi & {
useStore: <T = NoInfer<VxeGridProps>>(
selector?: (state: NoInfer<VxeGridProps>) => T,
export type ExtendedVxeGridApi<
D extends Record<string, any> = any,
F extends BaseFormComponentType = BaseFormComponentType,
> = VxeGridApi<D> & {
useStore: <T = NoInfer<VxeGridProps<D, F>>>(
selector?: (state: NoInfer<VxeGridProps<any, any>>) => T,
) => Readonly<Ref<T>>;
};

View File

@ -1,3 +1,9 @@
import type { VxeGridSlots, VxeGridSlotTypes } from 'vxe-table';
import type { SlotsType } from 'vue';
import type { BaseFormComponentType } from '@vben-core/form-ui';
import type { ExtendedVxeGridApi, VxeGridProps } from './types';
import { defineComponent, h, onBeforeUnmount } from 'vue';
@ -7,16 +13,25 @@ import { useStore } from '@vben-core/shared/store';
import { VxeGridApi } from './api';
import VxeGrid from './use-vxe-grid.vue';
export function useVbenVxeGrid(options: VxeGridProps) {
type FilteredSlots<T> = {
[K in keyof VxeGridSlots<T> as K extends 'form'
? never
: K]: VxeGridSlots<T>[K];
};
export function useVbenVxeGrid<
T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType,
>(options: VxeGridProps<T, D>) {
// const IS_REACTIVE = isReactive(options);
const api = new VxeGridApi(options);
const extendedApi: ExtendedVxeGridApi = api as ExtendedVxeGridApi;
const extendedApi: ExtendedVxeGridApi<T, D> = api as ExtendedVxeGridApi<T, D>;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
};
const Grid = defineComponent(
(props: VxeGridProps, { attrs, slots }) => {
(props: VxeGridProps<T>, { attrs, slots }) => {
onBeforeUnmount(() => {
api.unmount();
});
@ -26,6 +41,16 @@ export function useVbenVxeGrid(options: VxeGridProps) {
{
name: 'VbenVxeGrid',
inheritAttrs: false,
slots: Object as SlotsType<
{
// 表格标题
'table-title': undefined;
// 工具栏左侧部分
'toolbar-actions': VxeGridSlotTypes.DefaultSlotParams<T>;
// 工具栏右侧部分
'toolbar-tools': VxeGridSlotTypes.DefaultSlotParams<T>;
} & FilteredSlots<T>
>,
},
);
// Add reactivity support

View File

@ -59,6 +59,7 @@ const FORM_SLOT_PREFIX = 'form-';
const TOOLBAR_ACTIONS = 'toolbar-actions';
const TOOLBAR_TOOLS = 'toolbar-tools';
const TABLE_TITLE = 'table-title';
const gridRef = useTemplateRef<VxeGridInstance>('gridRef');
@ -129,7 +130,7 @@ const [Form, formApi] = useTableForm({
});
const showTableTitle = computed(() => {
return !!slots.tableTitle?.() || tableTitle.value;
return !!slots[TABLE_TITLE]?.() || tableTitle.value;
});
const showToolbar = computed(() => {
@ -277,6 +278,15 @@ const delegatedFormSlots = computed(() => {
return resultSlots.map((key) => key.replace(FORM_SLOT_PREFIX, ''));
});
const showDefaultEmpty = computed(() => {
// VXE Table
const hasEmptyText = options.value.emptyText !== undefined;
const hasEmptyRender = options.value.emptyRender !== undefined;
//
return !hasEmptyText && !hasEmptyRender;
});
async function init() {
await nextTick();
const globalGridConfig = VxeUI?.getConfig()?.grid ?? {};
@ -458,7 +468,7 @@ onUnmounted(() => {
</slot>
</template>
<!-- 统一控状态 -->
<template #empty>
<template v-if="showDefaultEmpty" #empty>
<slot name="empty">
<EmptyIcon class="mx-auto" />
<div class="mt-2">{{ $t('common.noData') }}</div>

View File

@ -1,6 +1,6 @@
{
"name": "@vben/request",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -1,6 +1,8 @@
import type { RequestClient } from '../request-client';
import type { RequestClientConfig } from '../types';
import { isUndefined } from '@vben/utils';
class FileUploader {
private client: RequestClient;
@ -18,10 +20,10 @@ class FileUploader {
Object.entries(data).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item, index) => {
formData.append(`${key}[${index}]`, item);
!isUndefined(item) && formData.append(`${key}[${index}]`, item);
});
} else {
formData.append(key, value);
!isUndefined(value) && formData.append(key, value);
}
});

View File

@ -1,6 +1,6 @@
{
"name": "@vben/icons",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

View File

@ -1,6 +1,6 @@
{
"name": "@vben/locales",
"version": "5.5.6",
"version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": {

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