Merge branch 'main' of https://github.com/vbenjs/vue-vben-admin into v-next-dev

pull/78/MERGE
xingyu4j 2025-04-22 15:39:53 +08:00
commit acd2787f29
38 changed files with 815 additions and 424 deletions

View File

@ -0,0 +1,13 @@
import { verifyAccessToken } from '~/utils/jwt-utils';
import { unAuthorizedResponse } from '~/utils/response';
export default eventHandler((event) => {
const userinfo = verifyAccessToken(event);
if (!userinfo) {
return unAuthorizedResponse(event);
}
return useResponseSuccess({
url: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
});
// return useResponseError("test")
});

View File

@ -7,6 +7,7 @@ export default defineEventHandler(() => {
<li><a href="/api/menu">/api/menu/all</a></li> <li><a href="/api/menu">/api/menu/all</a></li>
<li><a href="/api/auth/codes">/api/auth/codes</a></li> <li><a href="/api/auth/codes">/api/auth/codes</a></li>
<li><a href="/api/auth/login">/api/auth/login</a></li> <li><a href="/api/auth/login">/api/auth/login</a></li>
<li><a href="/api/upload">/api/upload</a></li>
</ul> </ul>
`; `;
}); });

View File

@ -115,7 +115,9 @@ export type ComponentType =
| 'DatePicker' | 'DatePicker'
| 'DefaultButton' | 'DefaultButton'
| 'Divider' | 'Divider'
| 'FileUpload'
| 'IconPicker' | 'IconPicker'
| 'ImageUpload'
| 'Input' | 'Input'
| 'InputNumber' | 'InputNumber'
| 'InputPassword' | 'InputPassword'
@ -125,16 +127,14 @@ export type ComponentType =
| 'RadioGroup' | 'RadioGroup'
| 'RangePicker' | 'RangePicker'
| 'Rate' | 'Rate'
| 'RichTextarea'
| 'Select' | 'Select'
| 'Space' | 'Space'
| 'Switch' | 'Switch'
| 'Textarea' | 'Textarea'
| 'RichTextarea'
| 'TimePicker' | 'TimePicker'
| 'TreeSelect' | 'TreeSelect'
| 'Upload' | 'Upload'
| 'FileUpload'
| 'ImageUpload'
| BaseFormComponentType; | BaseFormComponentType;
async function initComponentAdapter() { async function initComponentAdapter() {

View File

@ -151,17 +151,17 @@ function fetchApi(): Promise<Record<string, any>> {
| options | 直接传入选项数据也作为api返回空数据时的后备数据 | `OptionsItem[]` | - | - | | options | 直接传入选项数据也作为api返回空数据时的后备数据 | `OptionsItem[]` | - | - |
| visibleEvent | 触发重新请求数据的事件名 | `string` | - | - | | visibleEvent | 触发重新请求数据的事件名 | `string` | - | - |
| loadingSlot | 目标组件的插槽名称,用来显示一个"加载中"的图标 | `string` | - | - | | loadingSlot | 目标组件的插槽名称,用来显示一个"加载中"的图标 | `string` | - | - |
| autoSelect | 自动设置选项 | `'first' \| 'last' \| 'one'\| (item: OptionsItem[]) => OptionsItem \| false` | `false` | >5.5.4 | | autoSelect | 自动设置选项 | `'first' \| 'last' \| 'one'\| ((item: OptionsItem[]) => OptionsItem) \| false` | `false` | >5.5.4 |
#### autoSelect 自动设置选项 #### autoSelect 自动设置选项
如果当前值为undefined在选项数据成功加载之后自动从备选项中选择一个作为当前值。默认值为`false`,即不自动选择选项。注意:该属性不应用于多选组件。可选值有: 如果当前值为undefined在选项数据成功加载之后自动从备选项中选择一个作为当前值。默认值为`false`,即不自动选择选项。注意:该属性不应用于多选组件。可选值有:
- `first`:自动选择第一个选项 - `"first"`:自动选择第一个选项
- `last`:自动选择最后一个选项 - `"last"`:自动选择最后一个选项
- `one`:有且仅有一个选项时,自动选择它 - `"one"`:有且仅有一个选项时,自动选择它
- `函数`自定义选择逻辑函数的参数为options返回值为选择的选项 - `自定义函数`自定义选择逻辑函数的参数为options返回值为选择的选项
- false不自动选择选项 - `false`:不自动选择选项
### Methods ### Methods
@ -169,3 +169,5 @@ function fetchApi(): Promise<Record<string, any>> {
| --- | --- | --- | --- | | --- | --- | --- | --- |
| getComponentRef | 获取被包装的组件的实例 | ()=>T | >5.5.4 | | getComponentRef | 获取被包装的组件的实例 | ()=>T | >5.5.4 |
| updateParam | 设置接口请求参数将与params属性合并 | (newParams: Record<string, any>)=>void | >5.5.4 | | updateParam | 设置接口请求参数将与params属性合并 | (newParams: Record<string, any>)=>void | >5.5.4 |
| getOptions | 获取已加载的选项数据 | ()=>OptionsItem[] | >5.5.4 |
| getValue | 获取当前值 | ()=>any | >5.5.4 |

View File

@ -128,10 +128,11 @@ const [Drawer, drawerApi] = useVbenDrawer({
除了上面的属性类型包含`slot`,还可以通过插槽来自定义弹窗的内容。 除了上面的属性类型包含`slot`,还可以通过插槽来自定义弹窗的内容。
| 插槽名 | 描述 | | 插槽名 | 描述 |
| -------------- | ------------------- | | -------------- | -------------------------------------------------- |
| default | 默认插槽 - 弹窗内容 | | default | 默认插槽 - 弹窗内容 |
| prepend-footer | 取消按钮左侧 | | prepend-footer | 取消按钮左侧 |
| append-footer | 取消按钮右侧 | | center-footer | 取消按钮和确认按钮中间(不使用 footer 插槽时有效) |
| append-footer | 确认按钮右侧 |
| close-icon | 关闭按钮图标 | | close-icon | 关闭按钮图标 |
| extra | 额外内容(标题右侧) | | extra | 额外内容(标题右侧) |

View File

@ -310,7 +310,7 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| actionWrapperClass | 表单操作区域class | `any` | - | | actionWrapperClass | 表单操作区域class | `any` | - |
| handleReset | 表单重置回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - | | handleReset | 表单重置回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - |
| handleSubmit | 表单提交回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - | | handleSubmit | 表单提交回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - |
| handleValuesChange | 表单值变化回调 | `(values: Record<string, any>,) => void` | - | | handleValuesChange | 表单值变化回调 | `(values: Record<string, any>, fieldsChanged: string[]) => void` | - |
| actionButtonsReverse | 调换操作按钮位置 | `boolean` | `false` | | actionButtonsReverse | 调换操作按钮位置 | `boolean` | `false` |
| resetButtonOptions | 重置按钮组件参数 | `ActionButtonOptions` | - | | resetButtonOptions | 重置按钮组件参数 | `ActionButtonOptions` | - |
| submitButtonOptions | 提交按钮组件参数 | `ActionButtonOptions` | - | | submitButtonOptions | 提交按钮组件参数 | `ActionButtonOptions` | - |
@ -325,6 +325,12 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| submitOnChange | 字段值改变时提交表单(内部防抖,这个属性一般用于表格的搜索表单) | `boolean` | false | | submitOnChange | 字段值改变时提交表单(内部防抖,这个属性一般用于表格的搜索表单) | `boolean` | false |
| compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false | | compact | 是否紧凑模式(忽略为校验信息所预留的空间) | `boolean` | false |
::: tip handleValuesChange
`handleValuesChange` 回调函数的第一个参数`values`装载了表单改变后的当前值对象,第二个参数`fieldsChanged`是一个数组包含了所有被改变的字段名。注意第二个参数仅在v5.5.4(不含)以上版本可用并且传递的是已在schema中定义的字段名。如果你使用了字段映射并且需要检查是哪些字段发生了变化的话请注意该参数并不会包含映射后的字段名。
:::
::: tip fieldMappingTime ::: tip fieldMappingTime
此属性用于将表单内的数组值映射成 2 个字段它应当传入一个数组数组的每一项是一个映射规则规则的第一个成员是一个字符串表示需要映射的字段名第二个成员是一个数组表示映射后的字段名第三个成员是一个可选的格式掩码用于格式化日期时间字段也可以提供一个格式化函数参数分别为当前值和当前字段名返回格式化后的值。如果明确地将格式掩码设为null则原值映射而不进行格式化适用于非日期时间字段。例如`[['timeRange', ['startTime', 'endTime'], 'YYYY-MM-DD']]``timeRange`应当是一个至少具有2个成员的数组类型的值。Form会将`timeRange`的值前两个值分别按照格式掩码`YYYY-MM-DD`格式化后映射到`startTime`和`endTime`字段上。每一项的第三个参数是一个可选的格式掩码, 此属性用于将表单内的数组值映射成 2 个字段它应当传入一个数组数组的每一项是一个映射规则规则的第一个成员是一个字符串表示需要映射的字段名第二个成员是一个数组表示映射后的字段名第三个成员是一个可选的格式掩码用于格式化日期时间字段也可以提供一个格式化函数参数分别为当前值和当前字段名返回格式化后的值。如果明确地将格式掩码设为null则原值映射而不进行格式化适用于非日期时间字段。例如`[['timeRange', ['startTime', 'endTime'], 'YYYY-MM-DD']]``timeRange`应当是一个至少具有2个成员的数组类型的值。Form会将`timeRange`的值前两个值分别按照格式掩码`YYYY-MM-DD`格式化后映射到`startTime`和`endTime`字段上。每一项的第三个参数是一个可选的格式掩码,

View File

@ -59,7 +59,7 @@ Modal 内的内容一般业务中,会比较复杂,所以我们可以将 moda
::: info 注意 ::: info 注意
- `VbenModal` 组件对与参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenModal参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。 - `VbenModal` 组件对与参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenModal参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
- 如果你使用到了 `connectedComponent` 参数,那么会存在 2 个`useVbenModal`, 此时,如果同时设置了相同的参数,那么以内部为准(也就是没有设置 connectedComponent 的代码)。比如 同时设置了 `onConfirm`,那么以内部的 `onConfirm` 为准。`onOpenChange`事件除外,内外都会触发。 - 如果你使用到了 `connectedComponent` 参数,那么会存在 2 个`useVbenModal`, 此时,如果同时设置了相同的参数,那么以内部为准(也就是没有设置 connectedComponent 的代码)。比如 同时设置了 `onConfirm`,那么以内部的 `onConfirm` 为准。`onOpenChange`事件除外,内外都会触发。另外,如果设置了`destroyOnClose`内部Modal及其子组件会在被关闭后<b>完全销毁</b>
- 如果弹窗的默认行为不符合你的预期,可以在`src\bootstrap.ts`中修改`setDefaultModalProps`的参数来设置默认的属性如默认隐藏全屏按钮修改默认ZIndex等。 - 如果弹窗的默认行为不符合你的预期,可以在`src\bootstrap.ts`中修改`setDefaultModalProps`的参数来设置默认的属性如默认隐藏全屏按钮修改默认ZIndex等。
::: :::
@ -138,10 +138,11 @@ const [Modal, modalApi] = useVbenModal({
除了上面的属性类型包含`slot`,还可以通过插槽来自定义弹窗的内容。 除了上面的属性类型包含`slot`,还可以通过插槽来自定义弹窗的内容。
| 插槽名 | 描述 | | 插槽名 | 描述 |
| -------------- | ------------------- | | -------------- | -------------------------------------------------- |
| default | 默认插槽 - 弹窗内容 | | default | 默认插槽 - 弹窗内容 |
| prepend-footer | 取消按钮左侧 | | prepend-footer | 取消按钮左侧 |
| append-footer | 取消按钮右侧 | | center-footer | 取消按钮和确认按钮中间(不使用 footer 插槽时有效) |
| append-footer | 确认按钮右侧 |
### modalApi ### modalApi

View File

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { h } from 'vue'; import { h } from 'vue';
import { alert, prompt, VbenButton } from '@vben/common-ui'; import { alert, prompt, useAlertContext, VbenButton } from '@vben/common-ui';
import { Input, RadioGroup, Select } from 'ant-design-vue'; import { Input, RadioGroup, Select } from 'ant-design-vue';
import { BadgeJapaneseYen } from 'lucide-vue-next'; import { BadgeJapaneseYen } from 'lucide-vue-next';
@ -20,16 +20,30 @@ function showPrompt() {
function showSlotsPrompt() { function showSlotsPrompt() {
prompt({ prompt({
component: Input, component: () => {
componentProps: { // setup
const { doConfirm } = useAlertContext();
return h(
Input,
{
onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
//
doConfirm();
}
},
placeholder: '请输入', placeholder: '请输入',
prefix: '充值金额', prefix: '充值金额',
type: 'number', type: 'number',
}, },
componentSlots: { {
addonAfter: () => h(BadgeJapaneseYen), addonAfter: () => h(BadgeJapaneseYen),
}, },
content: '此弹窗演示了如何使用componentSlots传递自定义插槽', );
},
content:
'此弹窗演示了如何使用自定义插槽并且可以使用useAlertContext获取到弹窗的上下文。\n在输入框中按下回车键会触发确认操作。',
icon: 'question', icon: 'question',
modelPropName: 'value', modelPropName: 'value',
}).then((val) => { }).then((val) => {

View File

@ -11,3 +11,7 @@
当前只有对应的包下面存在 `tailwind.config.mjs` 文件才会启用 tailwindcss 的编译,否则不会启用 tailwindcss。如果你是纯粹的 SDK 包,不需要使用 tailwindcss可以不用创建 `tailwind.config.mjs` 文件。 当前只有对应的包下面存在 `tailwind.config.mjs` 文件才会启用 tailwindcss 的编译,否则不会启用 tailwindcss。如果你是纯粹的 SDK 包,不需要使用 tailwindcss可以不用创建 `tailwind.config.mjs` 文件。
::: :::
## 提示
现`tailwindcss`已至v4.x版本使用方法与`tailwindcss: ^3.4.17`有差异v4.0无法与v3.x版本兼容在开发前请确认`package.json`中的`tailwindcss`版本。

View File

@ -62,7 +62,7 @@ async function handleReset(e: Event) {
e?.stopPropagation(); e?.stopPropagation();
const props = unref(rootProps); const props = unref(rootProps);
const values = toRaw(props.formApi?.getValues()); const values = toRaw(await props.formApi?.getValues());
if (isFunction(props.handleReset)) { if (isFunction(props.handleReset)) {
await props.handleReset?.(values); await props.handleReset?.(values);

View File

@ -307,6 +307,7 @@ export class FormApi {
return true; return true;
}); });
const filteredFields = fieldMergeFn(fields, form.values); const filteredFields = fieldMergeFn(fields, form.values);
this.handleStringToArrayFields(filteredFields);
form.setValues(filteredFields, shouldValidate); form.setValues(filteredFields, shouldValidate);
} }
@ -316,6 +317,7 @@ export class FormApi {
const form = await this.getForm(); const form = await this.getForm();
await form.submitForm(); await form.submitForm();
const rawValues = toRaw(await this.getValues()); const rawValues = toRaw(await this.getValues());
this.handleArrayToStringFields(rawValues);
await this.state?.handleSubmit?.(rawValues); await this.state?.handleSubmit?.(rawValues);
return rawValues; return rawValues;
@ -404,10 +406,53 @@ export class FormApi {
return this.form; return this.form;
} }
private handleArrayToStringFields = (originValues: Record<string, any>) => {
const arrayToStringFields = this.state?.arrayToStringFields;
if (!arrayToStringFields || !Array.isArray(arrayToStringFields)) {
return;
}
const processFields = (fields: string[], separator: string = ',') => {
this.processFields(fields, separator, originValues, (value, sep) =>
Array.isArray(value) ? value.join(sep) : value,
);
};
// 处理简单数组格式 ['field1', 'field2', ';'] 或 ['field1', 'field2']
if (arrayToStringFields.every((item) => typeof item === 'string')) {
const lastItem =
arrayToStringFields[arrayToStringFields.length - 1] || '';
const fields =
lastItem.length === 1
? arrayToStringFields.slice(0, -1)
: arrayToStringFields;
const separator = lastItem.length === 1 ? lastItem : ',';
processFields(fields, separator);
return;
}
// 处理嵌套数组格式 [['field1'], ';']
arrayToStringFields.forEach((fieldConfig) => {
if (Array.isArray(fieldConfig)) {
const [fields, separator = ','] = fieldConfig;
// 根据类型定义fields 应该始终是字符串数组
if (!Array.isArray(fields)) {
console.warn(
`Invalid field configuration: fields should be an array of strings, got ${typeof fields}`,
);
return;
}
processFields(fields, separator);
}
});
};
private handleRangeTimeValue = (originValues: Record<string, any>) => { private handleRangeTimeValue = (originValues: Record<string, any>) => {
const values = { ...originValues }; const values = { ...originValues };
const fieldMappingTime = this.state?.fieldMappingTime; const fieldMappingTime = this.state?.fieldMappingTime;
this.handleStringToArrayFields(values);
if (!fieldMappingTime || !Array.isArray(fieldMappingTime)) { if (!fieldMappingTime || !Array.isArray(fieldMappingTime)) {
return values; return values;
} }
@ -453,6 +498,80 @@ export class FormApi {
return values; return values;
}; };
private handleStringToArrayFields = (originValues: Record<string, any>) => {
const arrayToStringFields = this.state?.arrayToStringFields;
if (!arrayToStringFields || !Array.isArray(arrayToStringFields)) {
return;
}
const processFields = (fields: string[], separator: string = ',') => {
this.processFields(fields, separator, originValues, (value, sep) => {
if (typeof value !== 'string') {
return value;
}
// 处理空字符串的情况
if (value === '') {
return [];
}
// 处理复杂分隔符的情况
const escapedSeparator = sep.replaceAll(
/[.*+?^${}()|[\]\\]/g,
String.raw`\$&`,
);
return value.split(new RegExp(escapedSeparator));
});
};
// 处理简单数组格式 ['field1', 'field2', ';'] 或 ['field1', 'field2']
if (arrayToStringFields.every((item) => typeof item === 'string')) {
const lastItem =
arrayToStringFields[arrayToStringFields.length - 1] || '';
const fields =
lastItem.length === 1
? arrayToStringFields.slice(0, -1)
: arrayToStringFields;
const separator = lastItem.length === 1 ? lastItem : ',';
processFields(fields, separator);
return;
}
// 处理嵌套数组格式 [['field1'], ';']
arrayToStringFields.forEach((fieldConfig) => {
if (Array.isArray(fieldConfig)) {
const [fields, separator = ','] = fieldConfig;
if (Array.isArray(fields)) {
processFields(fields, separator);
} else if (typeof originValues[fields] === 'string') {
const value = originValues[fields];
if (value === '') {
originValues[fields] = [];
} else {
const escapedSeparator = separator.replaceAll(
/[.*+?^${}()|[\]\\]/g,
String.raw`\$&`,
);
originValues[fields] = value.split(new RegExp(escapedSeparator));
}
}
}
});
};
private processFields = (
fields: string[],
separator: string,
originValues: Record<string, any>,
transformFn: (value: any, separator: string) => any,
) => {
fields.forEach((field) => {
const value = originValues[field];
if (value === undefined || value === null) {
return;
}
originValues[field] = transformFn(value, separator);
});
};
private updateState() { private updateState() {
const currentSchema = this.state?.schema ?? []; const currentSchema = this.state?.schema ?? [];
const prevSchema = this.prevState?.schema ?? []; const prevSchema = this.prevState?.schema ?? [];

View File

@ -232,6 +232,12 @@ export type FieldMappingTime = [
)?, )?,
][]; ][];
export type ArrayToStringFields = Array<
| [string[], string?] // 嵌套数组格式,可选分隔符
| string // 单个字段,使用默认分隔符
| string[] // 简单数组格式,最后一个元素可以是分隔符
>;
export interface FormSchema< export interface FormSchema<
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
> extends FormCommonConfig { > extends FormCommonConfig {
@ -266,6 +272,10 @@ export interface FormFieldProps extends FormSchema {
export interface FormRenderProps< export interface FormRenderProps<
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
> { > {
/**
* 使","
*/
arrayToStringFields?: ArrayToStringFields;
/** /**
* showCollapseButton=true * showCollapseButton=true
*/ */
@ -296,6 +306,10 @@ export interface FormRenderProps<
* *
*/ */
componentMap: Record<BaseFormComponentType, Component>; componentMap: Record<BaseFormComponentType, Component>;
/**
*
*/
fieldMappingTime?: FieldMappingTime;
/** /**
* *
*/ */
@ -308,10 +322,15 @@ export interface FormRenderProps<
* *
*/ */
schema?: FormSchema<T>[]; schema?: FormSchema<T>[];
/** /**
* / * /
*/ */
showCollapseButton?: boolean; showCollapseButton?: boolean;
/**
*
*/
/** /**
* *
* @default "grid-cols-1" * @default "grid-cols-1"
@ -339,6 +358,11 @@ export interface VbenFormProps<
* class * class
*/ */
actionWrapperClass?: ClassType; actionWrapperClass?: ClassType;
/**
* 使","
*/
arrayToStringFields?: ArrayToStringFields;
/** /**
* *
*/ */
@ -354,11 +378,15 @@ export interface VbenFormProps<
/** /**
* *
*/ */
handleValuesChange?: (values: Record<string, any>) => void; handleValuesChange?: (
values: Record<string, any>,
fieldsChanged: string[],
) => void;
/** /**
* *
*/ */
resetButtonOptions?: ActionButtonOptions; resetButtonOptions?: ActionButtonOptions;
/** /**
* *
* @default true * @default true

View File

@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Recordable } from '@vben-core/typings';
import type { ExtendedFormApi, VbenFormProps } from './types'; import type { ExtendedFormApi, VbenFormProps } from './types';
// import { toRaw, watch } from 'vue'; // import { toRaw, watch } from 'vue';
import { nextTick, onMounted, watch } from 'vue'; import { nextTick, onMounted, watch } from 'vue';
// import { isFunction } from '@vben-core/shared/utils';
import { useForwardPriorityValues } from '@vben-core/composables'; import { useForwardPriorityValues } from '@vben-core/composables';
import { cloneDeep } from '@vben-core/shared/utils'; import { cloneDeep, get, isEqual, set } from '@vben-core/shared/utils';
import { useDebounceFn } from '@vueuse/core'; import { useDebounceFn } from '@vueuse/core';
@ -61,16 +62,46 @@ function handleKeyDownEnter(event: KeyboardEvent) {
} }
const handleValuesChangeDebounced = useDebounceFn(async () => { const handleValuesChangeDebounced = useDebounceFn(async () => {
forward.value.handleValuesChange?.(
cloneDeep(await forward.value.formApi.getValues()),
);
state.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm(); state.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm();
}, 300); }, 300);
const valuesCache: Recordable<any> = {};
onMounted(async () => { onMounted(async () => {
// form.values // form.values
await nextTick(); await nextTick();
watch(() => form.values, handleValuesChangeDebounced, { deep: true }); watch(
() => form.values,
async (newVal) => {
if (forward.value.handleValuesChange) {
const fields = state.value.schema?.map((item) => {
return item.fieldName;
});
if (fields && fields.length > 0) {
const changedFields: string[] = [];
fields.forEach((field) => {
const newFieldValue = get(newVal, field);
const oldFieldValue = get(valuesCache, field);
if (!isEqual(newFieldValue, oldFieldValue)) {
changedFields.push(field);
set(valuesCache, field, newFieldValue);
}
});
if (changedFields.length > 0) {
// handleValuesChange
forward.value.handleValuesChange(
cloneDeep(await forward.value.formApi.getValues()),
changedFields,
);
}
}
}
handleValuesChangeDebounced();
},
{ deep: true },
);
}); });
</script> </script>

View File

@ -7,7 +7,7 @@ import type { AlertProps, BeforeCloseScope, PromptProps } from './alert';
import { h, nextTick, ref, render } from 'vue'; import { h, nextTick, ref, render } from 'vue';
import { useSimpleLocale } from '@vben-core/composables'; import { useSimpleLocale } from '@vben-core/composables';
import { Input } from '@vben-core/shadcn-ui'; import { Input, VbenRenderContent } from '@vben-core/shadcn-ui';
import { isFunction, isString } from '@vben-core/shared/utils'; import { isFunction, isString } from '@vben-core/shared/utils';
import Alert from './alert.vue'; import Alert from './alert.vue';
@ -146,11 +146,7 @@ export async function vbenPrompt<T = any>(
const inputComponentRef = ref<null | VNode>(null); const inputComponentRef = ref<null | VNode>(null);
const staticContents: Component[] = []; const staticContents: Component[] = [];
if (isString(content)) { staticContents.push(h(VbenRenderContent, { content, renderBr: true }));
staticContents.push(h('span', content));
} else if (content) {
staticContents.push(content as Component);
}
const modelPropName = _modelPropName || 'modelValue'; const modelPropName = _modelPropName || 'modelValue';
const componentProps = { ..._componentProps }; const componentProps = { ..._componentProps };

View File

@ -2,6 +2,8 @@ import type { Component, VNode, VNodeArrayChildren } from 'vue';
import type { Recordable } from '@vben-core/typings'; import type { Recordable } from '@vben-core/typings';
import { createContext } from '@vben-core/shadcn-ui';
export type IconType = 'error' | 'info' | 'question' | 'success' | 'warning'; export type IconType = 'error' | 'info' | 'question' | 'success' | 'warning';
export type BeforeCloseScope = { export type BeforeCloseScope = {
@ -70,3 +72,28 @@ export type PromptProps<T = any> = {
/** 输入组件的值属性名 */ /** 输入组件的值属性名 */
modelPropName?: string; modelPropName?: string;
} & Omit<AlertProps, 'beforeClose'>; } & Omit<AlertProps, 'beforeClose'>;
/**
* Alert
*/
export type AlertContext = {
/** 执行取消操作 */
doCancel: () => void;
/** 执行确认操作 */
doConfirm: () => void;
};
export const [injectAlertContext, provideAlertContext] =
createContext<AlertContext>('VbenAlertContext');
/**
* Alert
* @returns AlertContext
*/
export function useAlertContext() {
const context = injectAlertContext();
if (!context) {
throw new Error('useAlertContext must be used within an AlertProvider');
}
return context;
}

View File

@ -28,6 +28,8 @@ import {
import { globalShareState } from '@vben-core/shared/global-state'; import { globalShareState } from '@vben-core/shared/global-state';
import { cn } from '@vben-core/shared/utils'; import { cn } from '@vben-core/shared/utils';
import { provideAlertContext } from './alert';
const props = withDefaults(defineProps<AlertProps>(), { const props = withDefaults(defineProps<AlertProps>(), {
bordered: true, bordered: true,
buttonAlign: 'end', buttonAlign: 'end',
@ -87,6 +89,22 @@ const getIconRender = computed(() => {
} }
return iconRender; return iconRender;
}); });
function doCancel() {
handleCancel();
handleOpenChange(false);
}
function doConfirm() {
handleConfirm();
handleOpenChange(false);
}
provideAlertContext({
doCancel,
doConfirm,
});
function handleConfirm() { function handleConfirm() {
isConfirm.value = true; isConfirm.value = true;
emits('confirm'); emits('confirm');
@ -98,11 +116,13 @@ function handleCancel() {
const loading = ref(false); const loading = ref(false);
async function handleOpenChange(val: boolean) { async function handleOpenChange(val: boolean) {
const confirmState = isConfirm.value;
isConfirm.value = false;
await nextTick(); await nextTick();
if (!val && props.beforeClose) { if (!val && props.beforeClose) {
loading.value = true; loading.value = true;
try { try {
const res = await props.beforeClose({ isConfirm: isConfirm.value }); const res = await props.beforeClose({ isConfirm: confirmState });
if (res !== false) { if (res !== false) {
open.value = false; open.value = false;
} }
@ -152,7 +172,7 @@ async function handleOpenChange(val: boolean) {
</div> </div>
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
<div class="m-4 mb-6 min-h-[30px]"> <div class="m-4 min-h-[30px]">
<VbenRenderContent :content="content" render-br /> <VbenRenderContent :content="content" render-br />
</div> </div>
<VbenLoading v-if="loading && contentMasking" :spinning="loading" /> <VbenLoading v-if="loading && contentMasking" :spinning="loading" />

View File

@ -1,5 +1,10 @@
export * from './alert'; export type {
AlertProps,
BeforeCloseScope,
IconType,
PromptProps,
} from './alert';
export { useAlertContext } from './alert';
export { default as Alert } from './alert.vue'; export { default as Alert } from './alert.vue';
export { export {
vbenAlert as alert, vbenAlert as alert,

View File

@ -54,7 +54,6 @@ describe('drawerApi', () => {
}); });
it('should close the drawer if onBeforeClose allows it', () => { it('should close the drawer if onBeforeClose allows it', () => {
drawerApi.open();
drawerApi.close(); drawerApi.close();
expect(drawerApi.store.state.isOpen).toBe(false); expect(drawerApi.store.state.isOpen).toBe(false);
}); });

View File

@ -86,12 +86,13 @@ export class DrawerApi {
} }
/** /**
* *
* @description onBeforeClose onBeforeClose false
*/ */
close() { async close() {
// 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗 // 通过 onBeforeClose 钩子函数来判断是否允许关闭弹窗
// 如果 onBeforeClose 返回 false则不关闭弹窗 // 如果 onBeforeClose 返回 false则不关闭弹窗
const allowClose = this.api.onBeforeClose?.() ?? true; const allowClose = (await this.api.onBeforeClose?.()) ?? true;
if (allowClose) { if (allowClose) {
this.store.setState((prev) => ({ this.store.setState((prev) => ({
...prev, ...prev,

View File

@ -1,6 +1,6 @@
import type { Component, Ref } from 'vue'; import type { Component, Ref } from 'vue';
import type { ClassType } from '@vben-core/typings'; import type { ClassType, MaybePromise } from '@vben-core/typings';
import type { DrawerApi } from './drawer-api'; import type { DrawerApi } from './drawer-api';
@ -151,7 +151,7 @@ export interface DrawerApiOptions extends DrawerState {
* false * false
* @returns * @returns
*/ */
onBeforeClose?: () => void; onBeforeClose?: () => MaybePromise<boolean | undefined>;
/** /**
* *
*/ */

View File

@ -274,7 +274,7 @@ const getAppendTo = computed(() => {
{{ cancelText || $t('cancel') }} {{ cancelText || $t('cancel') }}
</slot> </slot>
</component> </component>
<slot name="center-footer"></slot>
<component <component
:is="components.PrimaryButton || VbenButton" :is="components.PrimaryButton || VbenButton"
v-if="showConfirmButton" v-if="showConfirmButton"

View File

@ -103,7 +103,7 @@ const { dragging, transform } = useModalDraggable(
); );
const firstOpened = ref(false); const firstOpened = ref(false);
const isClosed = ref(false); const isClosed = ref(true);
watch( watch(
() => state?.value?.isOpen, () => state?.value?.isOpen,
@ -186,7 +186,7 @@ const getAppendTo = computed(() => {
}); });
const getForceMount = computed(() => { const getForceMount = computed(() => {
return !unref(destroyOnClose); return !unref(destroyOnClose) && unref(firstOpened);
}); });
function handleClosed() { function handleClosed() {
@ -321,7 +321,7 @@ function handleClosed() {
{{ cancelText || $t('cancel') }} {{ cancelText || $t('cancel') }}
</slot> </slot>
</component> </component>
<slot name="center-footer"></slot>
<component <component
:is="components.PrimaryButton || VbenButton" :is="components.PrimaryButton || VbenButton"
v-if="showConfirmButton" v-if="showConfirmButton"

View File

@ -70,6 +70,13 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
injectData.options?.onOpenChange?.(isOpen); injectData.options?.onOpenChange?.(isOpen);
}; };
mergedOptions.onClosed = () => {
options.onClosed?.();
if (options.destroyOnClose) {
injectData.reCreateModal?.();
}
};
const api = new ModalApi(mergedOptions); const api = new ModalApi(mergedOptions);
const extendedApi: ExtendedModalApi = api as never; const extendedApi: ExtendedModalApi = api as never;

View File

@ -31,12 +31,11 @@ export default defineComponent({
if (props.renderBr && isString(props.content)) { if (props.renderBr && isString(props.content)) {
const lines = props.content.split('\n'); const lines = props.content.split('\n');
const result = []; const result = [];
for (let i = 0; i < lines.length; i++) { for (const [i, line] of lines.entries()) {
const line = lines[i]; result.push(h('p', { key: i }, line));
result.push(h('span', { key: i }, line)); // if (i < lines.length - 1) {
if (i < lines.length - 1) { // result.push(h('br'));
result.push(h('br')); // }
}
} }
return result; return result;
} else { } else {

View File

@ -39,6 +39,14 @@ const isAtRight = ref(false);
const isAtBottom = ref(false); const isAtBottom = ref(false);
const isAtLeft = ref(true); const isAtLeft = ref(true);
/**
* We have to check if the scroll amount is close enough to some threshold in order to
* more accurately calculate arrivedState. This is because scrollTop/scrollLeft are non-rounded
* numbers, while scrollHeight/scrollWidth and clientHeight/clientWidth are rounded.
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
*/
const ARRIVED_STATE_THRESHOLD_PIXELS = 1;
const showShadowTop = computed(() => props.shadow && props.shadowTop); const showShadowTop = computed(() => props.shadow && props.shadowTop);
const showShadowBottom = computed(() => props.shadow && props.shadowBottom); const showShadowBottom = computed(() => props.shadow && props.shadowBottom);
const showShadowLeft = computed(() => props.shadow && props.shadowLeft); const showShadowLeft = computed(() => props.shadow && props.shadowLeft);
@ -60,14 +68,18 @@ function handleScroll(event: Event) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
const scrollTop = target?.scrollTop ?? 0; const scrollTop = target?.scrollTop ?? 0;
const scrollLeft = target?.scrollLeft ?? 0; const scrollLeft = target?.scrollLeft ?? 0;
const offsetHeight = target?.offsetHeight ?? 0; const clientHeight = target?.clientHeight ?? 0;
const offsetWidth = target?.offsetWidth ?? 0; const clientWidth = target?.clientWidth ?? 0;
const scrollHeight = target?.scrollHeight ?? 0; const scrollHeight = target?.scrollHeight ?? 0;
const scrollWidth = target?.scrollWidth ?? 0; const scrollWidth = target?.scrollWidth ?? 0;
isAtTop.value = scrollTop <= 0; isAtTop.value = scrollTop <= 0;
isAtLeft.value = scrollLeft <= 0; isAtLeft.value = scrollLeft <= 0;
isAtBottom.value = scrollTop + offsetHeight >= scrollHeight; isAtBottom.value =
isAtRight.value = scrollLeft + offsetWidth >= scrollWidth; Math.abs(scrollTop) + clientHeight >=
scrollHeight - ARRIVED_STATE_THRESHOLD_PIXELS;
isAtRight.value =
Math.abs(scrollLeft) + clientWidth >=
scrollWidth - ARRIVED_STATE_THRESHOLD_PIXELS;
emit('scrollAt', { emit('scrollAt', {
bottom: isAtBottom.value, bottom: isAtBottom.value,

View File

@ -242,6 +242,10 @@ function emitChange() {
} }
const componentRef = ref(); const componentRef = ref();
defineExpose({ defineExpose({
/** 获取options数据 */
getOptions: () => unref(getOptions),
/** 获取当前值 */
getValue: () => unref(modelValue),
/** 获取被包装的组件实例 */ /** 获取被包装的组件实例 */
getComponentRef: <T = any,>() => componentRef.value as T, getComponentRef: <T = any,>() => componentRef.value as T,
/** 更新Api参数 */ /** 更新Api参数 */

View File

@ -74,7 +74,7 @@ function useMixedMenu() {
*/ */
const headerActive = computed(() => { const headerActive = computed(() => {
if (!needSplit.value) { if (!needSplit.value) {
return route.path; return route.meta?.activePath ?? route.path;
} }
return rootMenuPath.value; return rootMenuPath.value;
}); });

View File

@ -45,6 +45,9 @@
); );
--vxe-ui-table-row-current-background-color: hsl(var(--accent)); --vxe-ui-table-row-current-background-color: hsl(var(--accent));
--vxe-ui-table-row-hover-current-background-color: hsl(var(--accent-hover)); --vxe-ui-table-row-hover-current-background-color: hsl(var(--accent-hover));
--vxe-ui-font-primary-tinge-color: hsl(var(--primary));
--vxe-ui-font-primary-lighten-color: hsl(var(--primary) / 60%);
--vxe-ui-font-primary-darken-color: hsl(var(--primary));
height: auto !important; height: auto !important;

View File

@ -212,7 +212,12 @@ setupVbenVxeTable({
Popconfirm, Popconfirm,
{ {
getPopupContainer(el) { getPopupContainer(el) {
return el.closest('tbody') || document.body; return (
el
.closest('.vxe-table--viewport-wrapper')
?.querySelector('.vxe-table--main-wrapper')
?.querySelector('tbody') || document.body
);
}, },
placement: 'topLeft', placement: 'topLeft',
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']), title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),

View File

@ -0,0 +1,25 @@
import { requestClient } from '#/api/request';
interface UploadFileParams {
file: File;
onError?: (error: Error) => void;
onProgress?: (progress: { percent: number }) => void;
onSuccess?: (data: any, file: File) => void;
}
export async function upload_file({
file,
onError,
onProgress,
onSuccess,
}: UploadFileParams) {
try {
onProgress?.({ percent: 0 });
const data = await requestClient.upload('/upload', { file });
onProgress?.({ percent: 100 });
onSuccess?.(data, file);
} catch (error) {
onError?.(error instanceof Error ? error : new Error(String(error)));
}
}

View File

@ -18,7 +18,11 @@
"dynamic": "Dynamic Form", "dynamic": "Dynamic Form",
"custom": "Custom Component", "custom": "Custom Component",
"api": "Api", "api": "Api",
"merge": "Merge Form" "merge": "Merge Form",
"upload-error": "Partial file upload failed",
"upload-urls": "Urls after file upload",
"file": "file",
"upload-image": "Click to upload image"
}, },
"vxeTable": { "vxeTable": {
"title": "Vxe Table", "title": "Vxe Table",

View File

@ -21,7 +21,11 @@
"dynamic": "动态表单", "dynamic": "动态表单",
"custom": "自定义组件", "custom": "自定义组件",
"api": "Api", "api": "Api",
"merge": "合并表单" "merge": "合并表单",
"upload-error": "部分文件上传失败",
"upload-urls": "文件上传后的网址",
"file": "文件",
"upload-image": "点击上传图片"
}, },
"vxeTable": { "vxeTable": {
"title": "Vxe 表格", "title": "Vxe 表格",

View File

@ -30,5 +30,6 @@ function lockDrawer() {
<Button type="primary" @click="lockDrawer"></Button> <Button type="primary" @click="lockDrawer"></Button>
<!-- <template #prepend-footer> slot </template> --> <!-- <template #prepend-footer> slot </template> -->
<!-- <template #append-footer> prepend slot </template> --> <!-- <template #append-footer> prepend slot </template> -->
<!-- <template #center-footer> center slot </template> -->
</Drawer> </Drawer>
</template> </template>

View File

@ -1,5 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { h, ref } from 'vue'; import type { UploadFile } from 'ant-design-vue';
import { h, ref, toRaw } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
@ -9,6 +11,8 @@ import dayjs from 'dayjs';
import { useVbenForm, z } from '#/adapter/form'; import { useVbenForm, z } from '#/adapter/form';
import { getAllMenusApi } from '#/api'; import { getAllMenusApi } from '#/api';
import { upload_file } from '#/api/examples/upload';
import { $t } from '#/locales';
import DocButton from '../doc-button.vue'; import DocButton from '../doc-button.vue';
@ -42,6 +46,9 @@ const [BaseForm, baseFormApi] = useVbenForm({
fieldMappingTime: [['rangePicker', ['startTime', 'endTime'], 'YYYY-MM-DD']], fieldMappingTime: [['rangePicker', ['startTime', 'endTime'], 'YYYY-MM-DD']],
// //
handleSubmit: onSubmit, handleSubmit: onSubmit,
handleValuesChange(_values, fieldsChanged) {
message.info(`表单以下字段发生变化:${fieldsChanged.join('')}`);
},
// labelinputvertical // labelinputvertical
// labelinput // labelinput
@ -326,12 +333,56 @@ const [BaseForm, baseFormApi] = useVbenForm({
fieldName: 'treeSelect', fieldName: 'treeSelect',
label: '树选择', label: '树选择',
}, },
{
component: 'Upload',
componentProps: {
// https://ant.design/components/upload-cn
accept: '.png,.jpg,.jpeg',
//
customRequest: upload_file,
disabled: false,
maxCount: 1,
multiple: false,
showUploadList: true,
// text, picture, picture-card picture-circle
listType: 'picture-card',
},
fieldName: 'files',
label: $t('examples.form.file'),
renderComponentContent: () => {
return {
default: () => $t('examples.form.upload-image'),
};
},
rules: 'required',
},
], ],
// 321 // 321
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
}); });
function onSubmit(values: Record<string, any>) { function onSubmit(values: Record<string, any>) {
const files = toRaw(values.files) as UploadFile[];
const doneFiles = files.filter((file) => file.status === 'done');
const failedFiles = files.filter((file) => file.status !== 'done');
const msg = [
...doneFiles.map((file) => file.response?.url || file.url),
...failedFiles.map((file) => file.name),
].join(', ');
if (failedFiles.length === 0) {
message.success({
content: `${$t('examples.form.upload-urls')}: ${msg}`,
});
} else {
message.error({
content: `${$t('examples.form.upload-error')}: ${msg}`,
});
return;
}
// urls
values.files = doneFiles.map((file) => file.response?.url || file.url);
message.success({ message.success({
content: `form values: ${JSON.stringify(values)}`, content: `form values: ${JSON.stringify(values)}`,
}); });
@ -344,6 +395,14 @@ function handleSetFormValue() {
baseFormApi.setValues({ baseFormApi.setValues({
checkboxGroup: ['1'], checkboxGroup: ['1'],
datePicker: dayjs('2022-01-01'), datePicker: dayjs('2022-01-01'),
files: [
{
name: 'example.png',
status: 'done',
uid: '-1',
url: 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/logo-v1.webp',
},
],
mentions: '@afc163', mentions: '@afc163',
number: 3, number: 3,
options: '1', options: '1',

View File

@ -37,7 +37,7 @@ const [Modal, modalApi] = useVbenModal({
const { valid } = await formApi.validate(); const { valid } = await formApi.validate();
if (valid) { if (valid) {
modalApi.lock(); modalApi.lock();
const data = formApi.getValues(); const data = await formApi.getValues();
try { try {
await (formData.value?.id await (formData.value?.id
? updateDept(formData.value.id, data) ? updateDept(formData.value.id, data)

View File

@ -11,7 +11,7 @@ export function getMenuTypeOptions() {
value: 'catalog', value: 'catalog',
}, },
{ color: 'default', label: $t('system.menu.typeMenu'), value: 'menu' }, { color: 'default', label: $t('system.menu.typeMenu'), value: 'menu' },
{ color: 'error', label: $t('system.menu.typeButton'), value: 'button' }, { color: 'error', label: $t('system.menu.typeButton'), value: 'action' },
{ {
color: 'success', color: 'success',
label: $t('system.menu.typeEmbedded'), label: $t('system.menu.typeEmbedded'),

View File

@ -241,10 +241,10 @@ const schema: VbenFormSchema[] = [
component: 'Input', component: 'Input',
dependencies: { dependencies: {
rules: (values) => { rules: (values) => {
return values.type === 'button' ? 'required' : null; return values.type === 'action' ? 'required' : null;
}, },
show: (values) => { show: (values) => {
return ['button', 'catalog', 'embedded', 'menu'].includes(values.type); return ['action', 'catalog', 'embedded', 'menu'].includes(values.type);
}, },
triggerFields: ['type'], triggerFields: ['type'],
}, },
@ -277,7 +277,7 @@ const schema: VbenFormSchema[] = [
}, },
dependencies: { dependencies: {
show: (values) => { show: (values) => {
return values.type !== 'button'; return values.type !== 'action';
}, },
triggerFields: ['type'], triggerFields: ['type'],
}, },
@ -295,7 +295,7 @@ const schema: VbenFormSchema[] = [
}, },
dependencies: { dependencies: {
show: (values) => { show: (values) => {
return values.type !== 'button'; return values.type !== 'action';
}, },
triggerFields: ['type'], triggerFields: ['type'],
}, },
@ -314,7 +314,7 @@ const schema: VbenFormSchema[] = [
}, },
dependencies: { dependencies: {
show: (values) => { show: (values) => {
return values.type !== 'button'; return values.type !== 'action';
}, },
triggerFields: ['type'], triggerFields: ['type'],
}, },
@ -325,7 +325,7 @@ const schema: VbenFormSchema[] = [
component: 'Divider', component: 'Divider',
dependencies: { dependencies: {
show: (values) => { show: (values) => {
return !['button', 'link'].includes(values.type); return !['action', 'link'].includes(values.type);
}, },
triggerFields: ['type'], triggerFields: ['type'],
}, },
@ -372,7 +372,7 @@ const schema: VbenFormSchema[] = [
component: 'Checkbox', component: 'Checkbox',
dependencies: { dependencies: {
show: (values) => { show: (values) => {
return !['button'].includes(values.type); return !['action'].includes(values.type);
}, },
triggerFields: ['type'], triggerFields: ['type'],
}, },
@ -402,7 +402,7 @@ const schema: VbenFormSchema[] = [
component: 'Checkbox', component: 'Checkbox',
dependencies: { dependencies: {
show: (values) => { show: (values) => {
return !['button', 'link'].includes(values.type); return !['action', 'link'].includes(values.type);
}, },
triggerFields: ['type'], triggerFields: ['type'],
}, },
@ -417,7 +417,7 @@ const schema: VbenFormSchema[] = [
component: 'Checkbox', component: 'Checkbox',
dependencies: { dependencies: {
show: (values) => { show: (values) => {
return !['button', 'link'].includes(values.type); return !['action', 'link'].includes(values.type);
}, },
triggerFields: ['type'], triggerFields: ['type'],
}, },

File diff suppressed because it is too large Load Diff