Merge branch 'main' into form

This commit is contained in:
Jin Mao
2025-07-07 09:16:54 +08:00
committed by GitHub
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. - Checkout a topic branch from the relevant branch, e.g. main, and merge back against that branch.
- If adding a new feature: - 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. - 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: - If fixing bug:
- Provide a detailed description of the bug in the PR. Live demo preferred. - 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. - 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", "name": "@vben/web-antd",
"version": "5.5.6", "version": "5.5.7",
"homepage": "https://vben.pro", "homepage": "https://vben.pro",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": { "repository": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
import { EllipsisText } from '@vben/common-ui';
const text = `
Vben Admin 是一个基于 Vue3.0、Vite、 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, handleSubmit: onSubmit,
// 垂直布局label和input在不同行值为vertical // 垂直布局label和input在不同行值为vertical
// 水平布局label和input在同一行 // 水平布局label和input在同一行
scrollToFirstError: true,
layout: 'horizontal', layout: 'horizontal',
schema: [ schema: [
{ {

View File

@@ -150,8 +150,8 @@ export async function saveUserApi(user: UserInfo) {
```ts ```ts
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
export async function deleteUserApi(user: UserInfo) { export async function deleteUserApi(userId: number) {
return requestClient.delete<boolean>(`/user/${user.id}`, user); 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); 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. At this point, you can use the `useAppConfig` method within the project to access the newly added configuration item.
```ts ```ts
@@ -238,6 +259,7 @@ const defaultPreferences: Preferences = {
}, },
logo: { logo: {
enable: true, enable: true,
fit: 'contain',
source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp', source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
}, },
navigation: { navigation: {
@@ -431,6 +453,8 @@ interface HeaderPreferences {
interface LogoPreferences { interface LogoPreferences {
/** Whether the logo is visible */ /** Whether the logo is visible */
enable: boolean; enable: boolean;
/** Logo image fitting method */
fit: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
/** Logo URL */ /** Logo URL */
source: string; source: string;
} }

View File

@@ -4,10 +4,11 @@ outline: deep
# Access Control # 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 based on user roles
- Determining whether a menu or button can be accessed through an API - 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 ## 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. 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 ## 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. 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 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: - 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 - [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 - [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 - [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`: The project defines corresponding hooks inside `lefthook.yml`:
- `pre-commit`: Runs before commit, used for code formatting and checking - `pre-commit`: Runs before commit, used for code formatting and checking
- `code-workspace`: Updates VSCode workspace configuration - `code-workspace`: Updates VSCode workspace configuration
- `lint-md`: Formats Markdown files - `lint-md`: Formats Markdown files
- `lint-vue`: Formats and checks Vue 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 - `lint-json`: Formats other JSON files
- `post-merge`: Runs after merge, used for automatic dependency installation - `post-merge`: Runs after merge, used for automatic dependency installation
- `install`: Runs `pnpm install` to install new dependencies - `install`: Runs `pnpm install` to install new dependencies
- `commit-msg`: Runs during commit, used for checking commit message format - `commit-msg`: Runs during commit, used for checking commit message format

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
console.log(import.meta.env.VITE_PROT); 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`方法获取到新增的配置项了。 到这里,就可以在项目内使用 `useAppConfig`方法获取到新增的配置项了。
```ts ```ts
@@ -237,6 +258,7 @@ const defaultPreferences: Preferences = {
}, },
logo: { logo: {
enable: true, enable: true,
fit: 'contain',
source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp', source: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
}, },
navigation: { navigation: {
@@ -431,6 +453,8 @@ interface HeaderPreferences {
interface LogoPreferences { interface LogoPreferences {
/** logo是否可见 */ /** logo是否可见 */
enable: boolean; enable: boolean;
/** logo图片适应方式 */
fit: 'contain' | 'cover' | 'fill' | 'none' | 'scale-down';
/** logo地址 */ /** logo地址 */
source: string; 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`,需要安装以下插件: - 如果你使用的是 `vscode`,需要安装以下插件:
- [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) - 脚本代码检查 - [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) - 脚本代码检查
- [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) - 代码格式化 - [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) - 代码格式化
- [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker) - 单词语法检查 - [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker) - 单词语法检查
@@ -157,7 +156,6 @@ git hook 一般结合各种 lint在 git 提交代码的时候进行代码风
项目在 `lefthook.yml` 内部定义了相应的 hooks 项目在 `lefthook.yml` 内部定义了相应的 hooks
- `pre-commit`: 在提交前运行,用于代码格式化和检查 - `pre-commit`: 在提交前运行,用于代码格式化和检查
- `code-workspace`: 更新 VSCode 工作区配置 - `code-workspace`: 更新 VSCode 工作区配置
- `lint-md`: 格式化 Markdown 文件 - `lint-md`: 格式化 Markdown 文件
- `lint-vue`: 格式化并检查 Vue 文件 - `lint-vue`: 格式化并检查 Vue 文件
@@ -167,7 +165,6 @@ git hook 一般结合各种 lint在 git 提交代码的时候进行代码风
- `lint-json`: 格式化其他 JSON 文件 - `lint-json`: 格式化其他 JSON 文件
- `post-merge`: 在合并后运行,用于自动安装依赖 - `post-merge`: 在合并后运行,用于自动安装依赖
- `install`: 运行 `pnpm install` 安装新依赖 - `install`: 运行 `pnpm install` 安装新依赖
- `commit-msg`: 在提交时运行,用于检查提交信息格式 - `commit-msg`: 在提交时运行,用于检查提交信息格式

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -60,8 +60,9 @@ type BreadcrumbStyleType = 'background' | 'normal';
* 权限模式 * 权限模式
* backend 后端权限模式 * backend 后端权限模式
* frontend 前端权限模式 * frontend 前端权限模式
* mixed 混合权限模式
*/ */
type AccessModeType = 'backend' | 'frontend'; type AccessModeType = 'backend' | 'frontend' | 'mixed';
/** /**
* 导航风格 * 导航风格

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,13 +48,18 @@ const queryFormStyle = computed(() => {
async function handleSubmit(e: Event) { async function handleSubmit(e: Event) {
e?.preventDefault(); e?.preventDefault();
e?.stopPropagation(); e?.stopPropagation();
const { valid } = await form.validate(); const props = unref(rootProps);
if (!props.formApi) {
return;
}
const { valid } = await props.formApi.validate();
if (!valid) { if (!valid) {
return; return;
} }
const values = toRaw(await unref(rootProps).formApi?.getValues()); const values = toRaw(await props.formApi.getValues());
await unref(rootProps).handleSubmit?.(values); await props.handleSubmit?.(values);
} }
async function handleReset(e: Event) { 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 type { FormActions, FormSchema, VbenFormProps } from './types';
import { toRaw } from 'vue'; import { isRef, toRaw } from 'vue';
import { Store } from '@vben-core/shared/store'; import { Store } from '@vben-core/shared/store';
import { import {
@@ -39,6 +39,7 @@ function getDefaultState(): VbenFormProps {
layout: 'horizontal', layout: 'horizontal',
resetButtonOptions: {}, resetButtonOptions: {},
schema: [], schema: [],
scrollToFirstError: false,
showCollapseButton: false, showCollapseButton: false,
showDefaultActions: true, showDefaultActions: true,
submitButtonOptions: {}, submitButtonOptions: {},
@@ -100,9 +101,26 @@ export class FormApi {
getFieldComponentRef<T = ComponentPublicInstance>( getFieldComponentRef<T = ComponentPublicInstance>(
fieldName: string, fieldName: string,
): T | undefined { ): T | undefined {
return this.componentRefMap.has(fieldName) let target = this.componentRefMap.has(fieldName)
? (this.componentRefMap.get(fieldName) as T) ? (this.componentRefMap.get(fieldName) as ComponentPublicInstance)
: undefined; : 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) { async setFieldValue(field: string, value: any, shouldValidate?: boolean) {
const form = await this.getForm(); const form = await this.getForm();
form.setFieldValue(field, value, shouldValidate); form.setFieldValue(field, value, shouldValidate);
@@ -360,14 +413,21 @@ export class FormApi {
if (Object.keys(validateResult?.errors ?? {}).length > 0) { if (Object.keys(validateResult?.errors ?? {}).length > 0) {
console.error('validate error', validateResult?.errors); console.error('validate error', validateResult?.errors);
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(validateResult.errors);
}
} }
return validateResult; return validateResult;
} }
async validateAndSubmitForm() { async validateAndSubmitForm() {
const form = await this.getForm(); const form = await this.getForm();
const { valid } = await form.validate(); const { valid, errors } = await form.validate();
if (!valid) { if (!valid) {
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(errors);
}
return; return;
} }
return await this.submitForm(); return await this.submitForm();
@@ -379,6 +439,10 @@ export class FormApi {
if (Object.keys(validateResult?.errors ?? {}).length > 0) { if (Object.keys(validateResult?.errors ?? {}).length > 0) {
console.error('validate error', validateResult?.errors); console.error('validate error', validateResult?.errors);
if (this.state?.scrollToFirstError) {
this.scrollToFirstError(fieldName);
}
} }
return validateResult; return validateResult;
} }

View File

@@ -387,6 +387,12 @@ export interface VbenFormProps<
*/ */
resetButtonOptions?: ActionButtonOptions; resetButtonOptions?: ActionButtonOptions;
/**
* 验证失败时是否自动滚动到第一个错误字段
* @default false
*/
scrollToFirstError?: boolean;
/** /**
* 是否显示默认操作按钮 * 是否显示默认操作按钮
* @default true * @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 { isString, mergeWithArrayOverride, set } from '@vben-core/shared/utils';
import { useForm } from 'vee-validate'; import { useForm } from 'vee-validate';
import { object } from 'zod'; import { object, ZodIntersection, ZodNumber, ZodObject, ZodString } from 'zod';
import { getDefaultsForSchema } from 'zod-defaults'; import { getDefaultsForSchema } from 'zod-defaults';
type ExtendFormProps = VbenFormProps & { formApi: ExtendedFormApi }; type ExtendFormProps = VbenFormProps & { formApi: ExtendedFormApi };
@@ -52,7 +52,12 @@ export function useFormInitial(
if (Reflect.has(item, 'defaultValue')) { if (Reflect.has(item, 'defaultValue')) {
set(initialValues, item.fieldName, item.defaultValue); set(initialValues, item.fieldName, item.defaultValue);
} else if (item.rules && !isString(item.rules)) { } else if (item.rules && !isString(item.rules)) {
// 检查规则是否适合提取默认值
const customDefaultValue = getCustomDefaultValue(item.rules);
zodObject[item.fieldName] = 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); 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 { return {
delegatedSlots, delegatedSlots,

View File

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

View File

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

View File

@@ -34,7 +34,6 @@ const props = withDefaults(defineProps<AlertProps>(), {
bordered: true, bordered: true,
buttonAlign: 'end', buttonAlign: 'end',
centered: true, centered: true,
containerClass: 'w-[520px]',
}); });
const emits = defineEmits(['closed', 'confirm', 'opened']); const emits = defineEmits(['closed', 'confirm', 'opened']);
const open = defineModel<boolean>('open', { default: false }); const open = defineModel<boolean>('open', { default: false });
@@ -148,7 +147,7 @@ async function handleOpenChange(val: boolean) {
:class=" :class="
cn( cn(
containerClass, 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, 'border-border border': bordered,
'shadow-3xl': !bordered, 'shadow-3xl': !bordered,

View File

@@ -1,7 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { DrawerProps, ExtendedDrawerApi } from './drawer'; 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 { import {
useIsMobile, useIsMobile,
@@ -94,6 +102,16 @@ const {
// }, // },
// ); // );
/**
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
*/
onDeactivated(() => {
// 如果弹窗没有被挂载到内容区域,则关闭弹窗
if (!appendToMain.value) {
props.drawerApi?.close();
}
});
function interactOutside(e: Event) { function interactOutside(e: Event) {
if (!closeOnClickModal.value || submitting.value) { if (!closeOnClickModal.value || submitting.value) {
e.preventDefault(); e.preventDefault();

View File

@@ -9,7 +9,6 @@ import {
h, h,
inject, inject,
nextTick, nextTick,
onDeactivated,
provide, provide,
reactive, reactive,
ref, ref,
@@ -72,13 +71,6 @@ export function useVbenDrawer<
}, },
); );
/**
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
*/
onDeactivated(() => {
(extendedApi as ExtendedDrawerApi)?.close?.();
});
return [Drawer, extendedApi as ExtendedDrawerApi] as const; return [Drawer, extendedApi as ExtendedDrawerApi] as const;
} }

View File

@@ -1,7 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ExtendedModalApi, ModalProps } from './modal'; 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 { import {
useIsMobile, useIsMobile,
@@ -96,10 +105,17 @@ const shouldDraggable = computed(
() => draggable.value && !shouldFullscreen.value && header.value, () => 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( const { dragging, transform } = useModalDraggable(
dialogRef, dialogRef,
headerRef, headerRef,
shouldDraggable, shouldDraggable,
getAppendTo,
); );
const firstOpened = ref(false); const firstOpened = ref(false);
@@ -135,6 +151,16 @@ watch(
// }, // },
// ); // );
/**
* 在开启keepAlive情况下 直接通过浏览器按钮/手势等返回 不会关闭弹窗
*/
onDeactivated(() => {
// 如果弹窗没有被挂载到内容区域,则关闭弹窗
if (!appendToMain.value) {
props.modalApi?.close();
}
});
function handleFullscreen() { function handleFullscreen() {
props.modalApi?.setState((prev) => { props.modalApi?.setState((prev) => {
// if (prev.fullscreen) { // if (prev.fullscreen) {
@@ -179,11 +205,6 @@ function handleFocusOutside(e: Event) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
const getAppendTo = computed(() => {
return appendToMain.value
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
: undefined;
});
const getForceMount = computed(() => { const getForceMount = computed(() => {
return !unref(destroyOnClose) && unref(firstOpened); return !unref(destroyOnClose) && unref(firstOpened);
@@ -205,7 +226,8 @@ function handleClosed() {
:append-to="getAppendTo" :append-to="getAppendTo"
:class=" :class="
cn( 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, modalClass,
{ {
'border-border border': bordered, 'border-border border': bordered,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -96,6 +96,15 @@ async function generateRoutes(
); );
break; 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", "name": "@vben/common-ui",
"version": "5.5.6", "version": "5.5.7",
"homepage": "https://github.com/vbenjs/vue-vben-admin", "homepage": "https://github.com/vbenjs/vue-vben-admin",
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues", "bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
"repository": { "repository": {
@@ -40,6 +40,7 @@
"@vben/types": "workspace:*", "@vben/types": "workspace:*",
"@vueuse/core": "catalog:", "@vueuse/core": "catalog:",
"@vueuse/integrations": "catalog:", "@vueuse/integrations": "catalog:",
"json-bigint": "catalog:",
"qrcode": "catalog:", "qrcode": "catalog:",
"tippy.js": "catalog:", "tippy.js": "catalog:",
"vue": "catalog:", "vue": "catalog:",

View File

@@ -3,11 +3,11 @@ import type { Component } from 'vue';
import type { AnyPromiseFunction } from '@vben/types'; 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 { 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'; import { objectOmit } from '@vueuse/core';
@@ -104,6 +104,8 @@ const refOptions = ref<OptionsItem[]>([]);
const loading = ref(false); const loading = ref(false);
// 首次是否加载过了 // 首次是否加载过了
const isFirstLoaded = ref(false); const isFirstLoaded = ref(false);
// 标记是否有待处理的请求
const hasPendingRequest = ref(false);
const getOptions = computed(() => { const getOptions = computed(() => {
const { labelField, valueField, childrenField, numberToString } = props; const { labelField, valueField, childrenField, numberToString } = props;
@@ -146,18 +148,26 @@ const bindProps = computed(() => {
}); });
async function fetchApi() { 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; return;
} }
// 如果正在加载,标记有待处理的请求并返回
if (loading.value) {
hasPendingRequest.value = true;
return;
}
refOptions.value = []; refOptions.value = [];
try { try {
loading.value = true; loading.value = true;
let finalParams = unref(mergedParams);
if (beforeFetch && isFunction(beforeFetch)) { 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)) { if (afterFetch && isFunction(afterFetch)) {
res = (await afterFetch(res)) || res; res = (await afterFetch(res)) || res;
} }
@@ -177,6 +187,13 @@ async function fetchApi() {
isFirstLoaded.value = false; isFirstLoaded.value = false;
} finally { } finally {
loading.value = false; 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 { return {
...props.params, ...props.params,
...unref(innerParams), ...unref(innerParams),
@@ -198,7 +215,7 @@ const params = computed(() => {
}); });
watch( watch(
params, mergedParams,
(value, oldValue) => { (value, oldValue) => {
if (isEqual(value, oldValue)) { if (isEqual(value, oldValue)) {
return; 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 SliderCaptcha } from './slider-captcha/index.vue';
export { default as SliderRotateCaptcha } from './slider-rotate-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'; 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; 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 { export interface CaptchaVerifyPassingData {
isPassing: boolean; isPassing: boolean;
time: number | string; time: number | string;

View File

@@ -1,7 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { CSSProperties } from 'vue'; 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'; import { VbenTooltip } from '@vben-core/shadcn-ui';
@@ -33,6 +40,16 @@ interface Props {
* @default true * @default true
*/ */
tooltip?: boolean; tooltip?: boolean;
/**
* 是否只在文本被截断时显示提示框
* @default false
*/
tooltipWhenEllipsis?: boolean;
/**
* 文本截断检测的像素差异阈值,越大则判断越严格
* @default 3
*/
ellipsisThreshold?: number;
/** /**
* 提示框背景颜色,优先级高于 overlayStyle * 提示框背景颜色,优先级高于 overlayStyle
*/ */
@@ -62,12 +79,15 @@ const props = withDefaults(defineProps<Props>(), {
maxWidth: '100%', maxWidth: '100%',
placement: 'top', placement: 'top',
tooltip: true, tooltip: true,
tooltipWhenEllipsis: false,
ellipsisThreshold: 3,
tooltipBackgroundColor: '', tooltipBackgroundColor: '',
tooltipColor: '', tooltipColor: '',
tooltipFontSize: 14, tooltipFontSize: 14,
tooltipMaxWidth: undefined, tooltipMaxWidth: undefined,
tooltipOverlayStyle: () => ({ textAlign: 'justify' }), tooltipOverlayStyle: () => ({ textAlign: 'justify' }),
}); });
const emit = defineEmits<{ expandChange: [boolean] }>(); const emit = defineEmits<{ expandChange: [boolean] }>();
const textMaxWidth = computed(() => { const textMaxWidth = computed(() => {
@@ -79,9 +99,67 @@ const textMaxWidth = computed(() => {
const ellipsis = ref(); const ellipsis = ref();
const isExpand = ref(false); const isExpand = ref(false);
const defaultTooltipMaxWidth = ref(); const defaultTooltipMaxWidth = ref();
const isEllipsis = ref(false);
const { width: eleWidth } = useElementSize(ellipsis); 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( watchEffect(
() => { () => {
if (props.tooltip && eleWidth.value) { if (props.tooltip && eleWidth.value) {
@@ -91,9 +169,13 @@ watchEffect(
}, },
{ flush: 'post' }, { flush: 'post' },
); );
function onExpand() { function onExpand() {
isExpand.value = !isExpand.value; isExpand.value = !isExpand.value;
emit('expandChange', isExpand.value); emit('expandChange', isExpand.value);
if (props.tooltipWhenEllipsis) {
checkEllipsis();
}
} }
function handleExpand() { function handleExpand() {
@@ -110,7 +192,9 @@ function handleExpand() {
color: tooltipColor, color: tooltipColor,
backgroundColor: tooltipBackgroundColor, backgroundColor: tooltipBackgroundColor,
}" }"
:disabled="!props.tooltip || isExpand" :disabled="
!props.tooltip || isExpand || (props.tooltipWhenEllipsis && !isEllipsis)
"
:side="placement" :side="placement"
> >
<slot name="tooltip"> <slot name="tooltip">

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ import type { AuthenticationProps } from './types';
import { computed, watch } from 'vue'; import { computed, watch } from 'vue';
import { $t } from '@vben/locales';
import { useVbenModal } from '@vben-core/popup-ui'; import { useVbenModal } from '@vben-core/popup-ui';
import { Slot, VbenAvatar } from '@vben-core/shadcn-ui'; import { Slot, VbenAvatar } from '@vben-core/shadcn-ui';

View File

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

View File

@@ -2,48 +2,139 @@ import type { Arrayable, MaybeElementRef } from '@vueuse/core';
import type { Ref } from 'vue'; 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 { isFunction } from '@vben/utils';
import { useElementHover } from '@vueuse/core'; 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 * 监测鼠标是否在元素内部,如果在元素内部则返回 true否则返回 false
* @param refElement 所有需要检测的元素。如果提供了一个数组,那么鼠标在任何一个元素内部都会返回 true * @param refElement 所有需要检测的元素。支持单个元素、元素数组或响应式引用的元素数组。如果鼠标在任何一个元素内部都会返回 true
* @param delay 延迟更新状态的时间 * @param delay 延迟更新状态的时间,可以是数字或包含进入/离开延迟的配置对象
* @returns 返回一个数组,第一个元素是一个 ref表示鼠标是否在元素内部第二个元素是一个控制器可以通过 enable 和 disable 方法来控制监听器的启用和禁用 * @returns 返回一个数组,第一个元素是一个 ref表示鼠标是否在元素内部第二个元素是一个控制器可以通过 enable 和 disable 方法来控制监听器的启用和禁用
*/ */
export function useHoverToggle( export function useHoverToggle(
refElement: Arrayable<MaybeElementRef>, refElement: Arrayable<MaybeElementRef> | Ref<HTMLElement[] | null>,
delay: (() => number) | number = 500, delay: (() => number) | HoverDelayOptions | number = DEFAULT_LEAVE_DELAY,
) { ) {
const isHovers: Array<Ref<boolean>> = []; // 兼容旧版本API
const value = ref(false); const normalizedOptions: HoverDelayOptions =
const timer = ref<ReturnType<typeof setTimeout> | undefined>(); typeof delay === 'number' || isFunction(delay)
const refs = Array.isArray(refElement) ? refElement : [refElement]; ? { enterDelay: DEFAULT_ENTER_DELAY, leaveDelay: delay }
refs.forEach((refEle) => { : {
const eleRef = computed(() => { enterDelay: DEFAULT_ENTER_DELAY,
const ele = unref(refEle); leaveDelay: DEFAULT_LEAVE_DELAY,
return ele instanceof Element ? ele : (ele?.$el as Element); ...delay,
}); };
const isHover = useElementHover(eleRef);
isHovers.push(isHover);
});
const isOutsideAll = computed(() => isHovers.every((v) => !v.value));
function setValueDelay(val: boolean) { const value = ref(false);
timer.value && clearTimeout(timer.value); const enterTimer = ref<ReturnType<typeof setTimeout> | undefined>();
timer.value = setTimeout( const leaveTimer = ref<ReturnType<typeof setTimeout> | undefined>();
() => { const hoverScopes = ref<ReturnType<typeof effectScope>[]>([]);
value.value = val;
timer.value = undefined; // 使用计算属性包装 refElement使其响应式变化
}, const refs = computed(() => {
isFunction(delay) ? delay() : delay, 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, isOutsideAll,
(val) => { (val) => {
setValueDelay(!val); setValueDelay(!val);
@@ -53,15 +144,19 @@ export function useHoverToggle(
const controller = { const controller = {
enable() { enable() {
watcher.resume(); hoverWatcher.resume();
}, },
disable() { disable() {
watcher.pause(); hoverWatcher.pause();
}, },
}; };
onUnmounted(() => { onUnmounted(() => {
timer.value && clearTimeout(timer.value); clearTimers();
// 停止监听器
stopWatcher();
// 停止所有剩余的作用域
hoverScopes.value.forEach((scope) => scope.stop());
}); });
return [value, controller] as [typeof value, typeof controller]; return [value, controller] as [typeof value, typeof controller];

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { $t } from '@vben/locales';
import { useVbenModal } from '@vben-core/popup-ui'; import { useVbenModal } from '@vben-core/popup-ui';
interface Props { interface Props {
// 轮时间,分钟 // 轮时间,分钟
checkUpdatesInterval?: number; checkUpdatesInterval?: number;
// 检查更新的地址 // 检查更新的地址
checkUpdateUrl?: string; checkUpdateUrl?: string;
@@ -46,6 +46,7 @@ async function getVersionTag() {
const response = await fetch(props.checkUpdateUrl, { const response = await fetch(props.checkUpdateUrl, {
cache: 'no-cache', cache: 'no-cache',
method: 'HEAD', method: 'HEAD',
redirect: 'manual',
}); });
return ( 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; public formApi = {} as ExtendedFormApi;
// private prevState: null | VxeGridProps = null; // private prevState: null | VxeGridProps = null;
public grid = {} as VxeGridInstance; public grid = {} as VxeGridInstance<T>;
public state: null | VxeGridProps = null; public state: null | VxeGridProps<T> = null;
public store: Store<VxeGridProps>; public store: Store<VxeGridProps<T>>;
private isMounted = false; private isMounted = false;
@@ -99,8 +99,8 @@ export class VxeGridApi {
setState( setState(
stateOrFn: stateOrFn:
| ((prev: VxeGridProps) => Partial<VxeGridProps>) | ((prev: VxeGridProps<T>) => Partial<VxeGridProps<T>>)
| Partial<VxeGridProps>, | Partial<VxeGridProps<T>>,
) { ) {
if (isFunction(stateOrFn)) { if (isFunction(stateOrFn)) {
this.store.setState((prev) => { this.store.setState((prev) => {

View File

@@ -3,4 +3,8 @@ export type { VxeTableGridOptions } from './types';
export * from './use-vxe-grid'; export * from './use-vxe-grid';
export { default as VbenVxeGrid } from './use-vxe-grid.vue'; 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 { 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'; import type { VxeGridApi } from './api';
@@ -35,7 +35,11 @@ export interface SeparatorOptions {
show?: boolean; show?: boolean;
backgroundColor?: string; 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 配置 * vxe-grid 配置
*/ */
gridOptions?: DeepPartial<VxeTableGridOptions>; gridOptions?: DeepPartial<VxeTableGridOptions<T>>;
/** /**
* vxe-grid 事件 * vxe-grid 事件
*/ */
gridEvents?: DeepPartial<VxeGridListeners>; gridEvents?: DeepPartial<VxeGridListeners<T>>;
/** /**
* 表单配置 * 表单配置
*/ */
formOptions?: VbenFormProps; formOptions?: VbenFormProps<D>;
/** /**
* 显示搜索表单 * 显示搜索表单
*/ */
@@ -74,9 +78,12 @@ export interface VxeGridProps {
separator?: boolean | SeparatorOptions; separator?: boolean | SeparatorOptions;
} }
export type ExtendedVxeGridApi = VxeGridApi & { export type ExtendedVxeGridApi<
useStore: <T = NoInfer<VxeGridProps>>( D extends Record<string, any> = any,
selector?: (state: NoInfer<VxeGridProps>) => T, F extends BaseFormComponentType = BaseFormComponentType,
> = VxeGridApi<D> & {
useStore: <T = NoInfer<VxeGridProps<D, F>>>(
selector?: (state: NoInfer<VxeGridProps<any, any>>) => T,
) => Readonly<Ref<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 type { ExtendedVxeGridApi, VxeGridProps } from './types';
import { defineComponent, h, onBeforeUnmount } from 'vue'; import { defineComponent, h, onBeforeUnmount } from 'vue';
@@ -7,16 +13,25 @@ import { useStore } from '@vben-core/shared/store';
import { VxeGridApi } from './api'; import { VxeGridApi } from './api';
import VxeGrid from './use-vxe-grid.vue'; 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 IS_REACTIVE = isReactive(options);
const api = new VxeGridApi(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) => { extendedApi.useStore = (selector) => {
return useStore(api.store, selector); return useStore(api.store, selector);
}; };
const Grid = defineComponent( const Grid = defineComponent(
(props: VxeGridProps, { attrs, slots }) => { (props: VxeGridProps<T>, { attrs, slots }) => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
api.unmount(); api.unmount();
}); });
@@ -26,6 +41,16 @@ export function useVbenVxeGrid(options: VxeGridProps) {
{ {
name: 'VbenVxeGrid', name: 'VbenVxeGrid',
inheritAttrs: false, inheritAttrs: false,
slots: Object as SlotsType<
{
// 表格标题
'table-title': undefined;
// 工具栏左侧部分
'toolbar-actions': VxeGridSlotTypes.DefaultSlotParams<T>;
// 工具栏右侧部分
'toolbar-tools': VxeGridSlotTypes.DefaultSlotParams<T>;
} & FilteredSlots<T>
>,
}, },
); );
// Add reactivity support // Add reactivity support

View File

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

View File

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

View File

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

View File

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

View File

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

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