This commit is contained in:
dap 2025-04-16 10:09:48 +08:00
commit f096dfc6e6
28 changed files with 424 additions and 113 deletions

View File

@ -12,6 +12,12 @@ Alert提供的功能与Modal类似但只适用于简单应用场景。例如
:::
::: tip 注意
Alert提供的快捷方法alert、confirm、prompt动态创建的弹窗在已打开的情况下不支持HMR热更新代码变更后需要关闭这些弹窗后重新打开。
:::
::: tip README
下方示例代码中的,存在一些主题色未适配、样式缺失的问题,这些问题只在文档内会出现,实际使用并不会有这些问题,可忽略,不必纠结。
@ -32,6 +38,23 @@ Alert提供的功能与Modal类似但只适用于简单应用场景。例如
<DemoPreview dir="demos/vben-alert/prompt" />
## useAlertContext
当弹窗的content、footer、icon使用自定义组件时在这些组件中可以使用 `useAlertContext` 获取当前弹窗的上下文对象,用来主动控制弹窗。
::: tip 注意
`useAlertContext`只能用在setup或者函数式组件中。
:::
### Methods
| 方法 | 描述 | 类型 | 版本要求 |
| --------- | ------------------ | -------- | -------- |
| doConfirm | 调用弹窗的确认操作 | ()=>void | >5.5.4 |
| doCancel | 调用弹窗的取消操作 | ()=>void | >5.5.4 |
## 类型说明
```ts
@ -69,8 +92,14 @@ export type AlertProps = {
contentClass?: string;
/** 执行beforeClose回调期间在内容区域显示一个loading遮罩*/
contentMasking?: boolean;
/** 弹窗底部内容(与按钮在同一个容器中) */
footer?: Component | string;
/** 弹窗的图标(在标题的前面) */
icon?: Component | IconType;
/**
* 弹窗遮罩模糊效果
*/
overlayBlur?: number;
/** 是否显示取消按钮 */
showCancel?: boolean;
/** 弹窗标题 */

View File

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

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

View File

@ -60,7 +60,6 @@ Modal 内的内容一般业务中,会比较复杂,所以我们可以将 moda
- `VbenModal` 组件对与参数的处理优先级是 `slot` > `props` > `state`(通过api更新的状态以及useVbenModal参数)。如果你已经传入了 `slot` 或者 `props`,那么 `setState` 将不会生效,这种情况下你可以通过 `slot` 或者 `props` 来更新状态。
- 如果你使用到了 `connectedComponent` 参数,那么会存在 2 个`useVbenModal`, 此时,如果同时设置了相同的参数,那么以内部为准(也就是没有设置 connectedComponent 的代码)。比如 同时设置了 `onConfirm`,那么以内部的 `onConfirm` 为准。`onOpenChange`事件除外,内外都会触发。
- 使用了`connectedComponent`参数时,可以配置`destroyOnClose`属性来决定当关闭弹窗时,是否要销毁`connectedComponent`组件(重新创建`connectedComponent`组件,这将会把其内部所有的变量、状态、数据等恢复到初始状态。)。
- 如果弹窗的默认行为不符合你的预期,可以在`src\bootstrap.ts`中修改`setDefaultModalProps`的参数来设置默认的属性如默认隐藏全屏按钮修改默认ZIndex等。
:::
@ -84,7 +83,7 @@ const [Modal, modalApi] = useVbenModal({
| --- | --- | --- | --- |
| appendToMain | 是否挂载到内容区域默认挂载到body | `boolean` | `false` |
| connectedComponent | 连接另一个Modal组件 | `Component` | - |
| destroyOnClose | 关闭时销毁`connectedComponent` | `boolean` | `false` |
| destroyOnClose | 关闭时销毁 | `boolean` | `false` |
| title | 标题 | `string\|slot` | - |
| titleTooltip | 标题提示信息 | `string\|slot` | - |
| description | 描述信息 | `string\|slot` | - |
@ -138,11 +137,12 @@ const [Modal, modalApi] = useVbenModal({
除了上面的属性类型包含`slot`,还可以通过插槽来自定义弹窗的内容。
| 插槽名 | 描述 |
| -------------- | ------------------- |
| default | 默认插槽 - 弹窗内容 |
| prepend-footer | 取消按钮左侧 |
| append-footer | 取消按钮右侧 |
| 插槽名 | 描述 |
| -------------- | -------------------------------------------------- |
| default | 默认插槽 - 弹窗内容 |
| prepend-footer | 取消按钮左侧 |
| center-footer | 取消按钮和确认按钮中间(不使用 footer 插槽时有效) |
| append-footer | 确认按钮右侧 |
### modalApi

View File

@ -1,6 +1,10 @@
<script lang="ts" setup>
import { h, ref } from 'vue';
import { alert, confirm, VbenButton } from '@vben/common-ui';
import { Checkbox, message } from 'ant-design-vue';
function showConfirm() {
confirm('This is an alert message')
.then(() => {
@ -18,6 +22,34 @@ function showIconConfirm() {
});
}
function showfooterConfirm() {
const checked = ref(false);
confirm({
cancelText: '不要虾扯蛋',
confirmText: '是的我们都是NPC',
content:
'刚才发生的事情,为什么我似乎早就经历过一般?\n我甚至能在事情发生过程中潜意识里预知到接下来会发生什么。\n\n听起来挺玄乎的你有过这种感觉吗',
footer: () =>
h(
Checkbox,
{
checked: checked.value,
class: 'flex-1',
'onUpdate:checked': (v) => (checked.value = v),
},
'不再提示',
),
icon: 'question',
title: '未解之谜',
}).then(() => {
if (checked.value) {
message.success('我不会再拿这个问题烦你了');
} else {
message.info('下次还要继续问你哟');
}
});
}
function showAsyncConfirm() {
confirm({
beforeClose({ isConfirm }) {
@ -37,6 +69,7 @@ function showAsyncConfirm() {
<div class="flex gap-4">
<VbenButton @click="showConfirm">Confirm</VbenButton>
<VbenButton @click="showIconConfirm">Confirm With Icon</VbenButton>
<VbenButton @click="showfooterConfirm">Confirm With Footer</VbenButton>
<VbenButton @click="showAsyncConfirm">Async Confirm</VbenButton>
</div>
</template>

View File

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

View File

@ -295,6 +295,7 @@ export class FormApi {
return true;
});
const filteredFields = fieldMergeFn(fields, form.values);
this.handleStringToArrayFields(filteredFields);
form.setValues(filteredFields, shouldValidate);
}
@ -304,6 +305,7 @@ export class FormApi {
const form = await this.getForm();
await form.submitForm();
const rawValues = toRaw(await this.getValues());
this.handleArrayToStringFields(rawValues);
await this.state?.handleSubmit?.(rawValues);
return rawValues;
@ -392,10 +394,53 @@ export class FormApi {
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>) => {
const values = { ...originValues };
const fieldMappingTime = this.state?.fieldMappingTime;
this.handleStringToArrayFields(values);
if (!fieldMappingTime || !Array.isArray(fieldMappingTime)) {
return values;
}
@ -441,6 +486,80 @@ export class FormApi {
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() {
const currentSchema = this.state?.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<
T extends BaseFormComponentType = BaseFormComponentType,
> extends FormCommonConfig {
@ -266,6 +272,10 @@ export interface FormFieldProps extends FormSchema {
export interface FormRenderProps<
T extends BaseFormComponentType = BaseFormComponentType,
> {
/**
* 使","
*/
arrayToStringFields?: ArrayToStringFields;
/**
* showCollapseButton=true下生效
*/
@ -296,6 +306,10 @@ export interface FormRenderProps<
*
*/
componentMap: Record<BaseFormComponentType, Component>;
/**
*
*/
fieldMappingTime?: FieldMappingTime;
/**
*
*/
@ -308,10 +322,15 @@ export interface FormRenderProps<
*
*/
schema?: FormSchema<T>[];
/**
* /
*/
showCollapseButton?: boolean;
/**
*
*/
/**
*
* @default "grid-cols-1"
@ -339,6 +358,11 @@ export interface VbenFormProps<
* class
*/
actionWrapperClass?: ClassType;
/**
* 使","
*/
arrayToStringFields?: ArrayToStringFields;
/**
*
*/
@ -359,6 +383,7 @@ export interface VbenFormProps<
*
*/
resetButtonOptions?: ActionButtonOptions;
/**
*
* @default true

View File

@ -208,6 +208,8 @@ onBeforeUnmount(() => {
nsMenu.e('popup-container'),
is(rootMenu.theme, true),
opened ? '' : 'hidden',
'overflow-auto',
'max-h-[calc(var(--radix-hover-card-content-available-height)-20px)]',
]"
:content-props="contentProps"
:open="true"

View File

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

View File

@ -2,6 +2,8 @@ import type { Component, VNode, VNodeArrayChildren } from 'vue';
import type { Recordable } from '@vben-core/typings';
import { createContext } from '@vben-core/shadcn-ui';
export type IconType = 'error' | 'info' | 'question' | 'success' | 'warning';
export type BeforeCloseScope = {
@ -34,6 +36,8 @@ export type AlertProps = {
contentClass?: string;
/** 执行beforeClose回调期间在内容区域显示一个loading遮罩*/
contentMasking?: boolean;
/** 弹窗底部内容(与按钮在同一个容器中) */
footer?: Component | string;
/** 弹窗的图标(在标题的前面) */
icon?: Component | IconType;
/**
@ -68,3 +72,28 @@ export type PromptProps<T = any> = {
/** 输入组件的值属性名 */
modelPropName?: string;
} & 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 { cn } from '@vben-core/shared/utils';
import { provideAlertContext } from './alert';
const props = withDefaults(defineProps<AlertProps>(), {
bordered: true,
buttonAlign: 'end',
@ -87,6 +89,23 @@ const getIconRender = computed(() => {
}
return iconRender;
});
function doCancel() {
isConfirm.value = false;
handleOpenChange(false);
}
function doConfirm() {
isConfirm.value = true;
handleOpenChange(false);
emits('confirm');
}
provideAlertContext({
doCancel,
doConfirm,
});
function handleConfirm() {
isConfirm.value = true;
emits('confirm');
@ -152,12 +171,16 @@ async function handleOpenChange(val: boolean) {
</div>
</AlertDialogTitle>
<AlertDialogDescription>
<div class="m-4 mb-6 min-h-[30px]">
<div class="m-4 min-h-[30px]">
<VbenRenderContent :content="content" render-br />
</div>
<VbenLoading v-if="loading && contentMasking" :spinning="loading" />
</AlertDialogDescription>
<div class="flex justify-end gap-x-2" :class="`justify-${buttonAlign}`">
<div
class="flex items-center justify-end gap-x-2"
:class="`justify-${buttonAlign}`"
>
<VbenRenderContent :content="footer" />
<AlertDialogCancel v-if="showCancel" as-child>
<component
:is="components.DefaultButton || VbenButton"

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 {
vbenAlert as alert,

View File

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

View File

@ -44,6 +44,7 @@ export class ModalApi {
confirmDisabled: false,
confirmLoading: false,
contentClass: '',
destroyOnClose: true,
draggable: false,
footer: true,
footerClass: '',

View File

@ -60,6 +60,10 @@ export interface ModalProps {
*
*/
description?: string;
/**
*
*/
destroyOnClose?: boolean;
/**
*
* @default false
@ -153,10 +157,6 @@ export interface ModalApiOptions extends ModalState {
*
*/
connectedComponent?: Component;
/**
* 使 connectedComponent
*/
destroyOnClose?: boolean;
/**
* false
* @returns

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { ExtendedModalApi, ModalProps } from './modal';
import { computed, nextTick, provide, ref, useId, watch } from 'vue';
import { computed, nextTick, provide, ref, unref, useId, watch } from 'vue';
import {
useIsMobile,
@ -34,6 +34,7 @@ interface Props extends ModalProps {
const props = withDefaults(defineProps<Props>(), {
appendToMain: false,
destroyOnClose: true,
modalApi: undefined,
});
@ -67,6 +68,7 @@ const {
confirmText,
contentClass,
description,
destroyOnClose,
draggable,
footer: showFooter,
footerClass,
@ -100,10 +102,15 @@ const { dragging, transform } = useModalDraggable(
shouldDraggable,
);
const firstOpened = ref(false);
const isClosed = ref(true);
watch(
() => state?.value?.isOpen,
async (v) => {
if (v) {
isClosed.value = false;
if (!firstOpened.value) firstOpened.value = true;
await nextTick();
if (!contentRef.value) return;
const innerContentRef = contentRef.value.getContentRef();
@ -113,6 +120,7 @@ watch(
dialogRef.value.style.transform = `translate(${offsetX}px, ${offsetY}px)`;
}
},
{ immediate: true },
);
watch(
@ -176,6 +184,15 @@ const getAppendTo = computed(() => {
? `#${ELEMENT_ID_MAIN_CONTENT}>div:not(.absolute)>div`
: undefined;
});
const getForceMount = computed(() => {
return !unref(destroyOnClose);
});
function handleClosed() {
isClosed.value = true;
props.modalApi?.onClosed();
}
</script>
<template>
<Dialog
@ -197,9 +214,11 @@ const getAppendTo = computed(() => {
shouldFullscreen,
'top-1/2 !-translate-y-1/2': centered && !shouldFullscreen,
'duration-300': !dragging,
hidden: isClosed,
},
)
"
:force-mount="getForceMount"
:modal="modal"
:open="state?.isOpen"
:show-close="closable"
@ -207,7 +226,7 @@ const getAppendTo = computed(() => {
:overlay-blur="overlayBlur"
close-class="top-3"
@close-auto-focus="handleFocusOutside"
@closed="() => modalApi?.onClosed()"
@closed="handleClosed"
:close-disabled="submitting"
@escape-key-down="escapeKeyDown"
@focus-outside="handleFocusOutside"
@ -302,7 +321,7 @@ const getAppendTo = computed(() => {
{{ cancelText || $t('cancel') }}
</slot>
</component>
<slot name="center-footer"></slot>
<component
:is="components.PrimaryButton || VbenButton"
v-if="showConfirmButton"

View File

@ -1,14 +1,6 @@
import type { ExtendedModalApi, ModalApiOptions, ModalProps } from './modal';
import {
defineComponent,
h,
inject,
nextTick,
provide,
reactive,
ref,
} from 'vue';
import { defineComponent, h, inject, nextTick, provide, reactive } from 'vue';
import { useStore } from '@vben-core/shared/store';
@ -32,7 +24,6 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
const { connectedComponent } = options;
if (connectedComponent) {
const extendedApi = reactive({});
const isModalReady = ref(true);
const Modal = defineComponent(
(props: TParentModalProps, { attrs, slots }) => {
provide(USER_MODAL_INJECT_KEY, {
@ -42,11 +33,6 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
Object.setPrototypeOf(extendedApi, api);
},
options,
async reCreateModal() {
isModalReady.value = false;
await nextTick();
isModalReady.value = true;
},
});
checkProps(extendedApi as ExtendedModalApi, {
...props,
@ -55,7 +41,7 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
});
return () =>
h(
isModalReady.value ? connectedComponent : 'div',
connectedComponent,
{
...props,
...attrs,
@ -84,14 +70,6 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
injectData.options?.onOpenChange?.(isOpen);
};
const onClosed = mergedOptions.onClosed;
mergedOptions.onClosed = () => {
onClosed?.();
if (mergedOptions.destroyOnClose) {
injectData.reCreateModal?.();
}
};
const api = new ModalApi(mergedOptions);
const extendedApi: ExtendedModalApi = api as never;

View File

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

View File

@ -39,6 +39,14 @@ const isAtRight = ref(false);
const isAtBottom = ref(false);
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 showShadowBottom = computed(() => props.shadow && props.shadowBottom);
const showShadowLeft = computed(() => props.shadow && props.shadowLeft);
@ -60,14 +68,18 @@ function handleScroll(event: Event) {
const target = event.target as HTMLElement;
const scrollTop = target?.scrollTop ?? 0;
const scrollLeft = target?.scrollLeft ?? 0;
const offsetHeight = target?.offsetHeight ?? 0;
const offsetWidth = target?.offsetWidth ?? 0;
const clientHeight = target?.clientHeight ?? 0;
const clientWidth = target?.clientWidth ?? 0;
const scrollHeight = target?.scrollHeight ?? 0;
const scrollWidth = target?.scrollWidth ?? 0;
isAtTop.value = scrollTop <= 0;
isAtLeft.value = scrollLeft <= 0;
isAtBottom.value = scrollTop + offsetHeight >= scrollHeight;
isAtRight.value = scrollLeft + offsetWidth >= scrollWidth;
isAtBottom.value =
Math.abs(scrollTop) + clientHeight >=
scrollHeight - ARRIVED_STATE_THRESHOLD_PIXELS;
isAtRight.value =
Math.abs(scrollLeft) + clientWidth >=
scrollWidth - ARRIVED_STATE_THRESHOLD_PIXELS;
emit('scrollAt', {
bottom: isAtBottom.value,

View File

@ -59,9 +59,15 @@ interface Props {
* - `first`自动选择第一个选项
* - `last`自动选择最后一个选项
* - `one`: 当请求的结果只有一个选项时自动选择该选项
* - 函数自定义选择逻辑函数的参数为请求的结果数组返回值为选择的选项
* - false不自动选择(默认)
*/
autoSelect?: 'first' | 'last' | 'one' | false;
autoSelect?:
| 'first'
| 'last'
| 'one'
| ((item: OptionsItem[]) => OptionsItem)
| false;
}
defineOptions({ name: 'ApiComponent', inheritAttrs: false });
@ -209,29 +215,37 @@ function emitChange() {
unref(getOptions).length > 0
) {
let firstOption;
switch (props.autoSelect) {
case 'first': {
firstOption = unref(getOptions)[0];
break;
}
case 'last': {
firstOption = unref(getOptions)[unref(getOptions).length - 1];
break;
}
case 'one': {
if (unref(getOptions).length === 1) {
if (isFunction(props.autoSelect)) {
firstOption = props.autoSelect(unref(getOptions));
} else {
switch (props.autoSelect) {
case 'first': {
firstOption = unref(getOptions)[0];
break;
}
case 'last': {
firstOption = unref(getOptions)[unref(getOptions).length - 1];
break;
}
case 'one': {
if (unref(getOptions).length === 1) {
firstOption = unref(getOptions)[0];
}
break;
}
break;
}
}
if (firstOption) modelValue.value = firstOption[props.valueField];
if (firstOption) modelValue.value = firstOption.value;
}
emit('optionsChange', unref(getOptions));
}
const componentRef = ref();
defineExpose({
/** 获取options数据 */
getOptions: () => unref(getOptions),
/** 获取当前值 */
getValue: () => unref(modelValue),
/** 获取被包装的组件实例 */
getComponentRef: <T = any,>() => componentRef.value as T,
/** 更新Api参数 */

View File

@ -212,7 +212,12 @@ setupVbenVxeTable({
Popconfirm,
{
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',
title: $t('ui.actionTitle.delete', [attrs?.nameTitle || '']),

View File

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

View File

@ -16,15 +16,18 @@ const [Modal, modalApi] = useVbenModal({
},
onOpenChange(isOpen) {
if (isOpen) {
handleUpdate(10);
handleUpdate();
}
},
});
function handleUpdate(len: number) {
function handleUpdate(len?: number) {
modalApi.setState({ confirmDisabled: true, loading: true });
setTimeout(() => {
list.value = Array.from({ length: len }, (_v, k) => k + 1);
list.value = Array.from(
{ length: len ?? Math.floor(Math.random() * 10) + 1 },
(_v, k) => k + 1,
);
modalApi.setState({ confirmDisabled: false, loading: false });
}, 2000);
}
@ -40,7 +43,7 @@ function handleUpdate(len: number) {
{{ item }}
</div>
<template #prepend-footer>
<Button type="link" @click="handleUpdate(6)">点击更新数据</Button>
<Button type="link" @click="handleUpdate()">点击更新数据</Button>
</template>
</Modal>
</template>

View File

@ -24,7 +24,7 @@ const value = ref();
title="基础弹窗示例"
title-tooltip="标题提示内容"
>
此弹窗指定在内容区域打开
<Input v-model="value" placeholder="KeepAlive测试" />
此弹窗指定在内容区域打开并且在关闭之后弹窗内容不会被销毁
<Input v-model:value="value" placeholder="KeepAlive测试" />
</Modal>
</template>

View File

@ -198,7 +198,7 @@ async function openPrompt() {
</template>
</Card>
<Card class="w-[300px]" title="指定容器">
<Card class="w-[300px]" title="指定容器+关闭后不销毁">
<p>在内容区域打开弹窗的示例</p>
<template #actions>
<Button type="primary" @click="openInContentModal">打开弹窗</Button>

View File

@ -11,7 +11,7 @@ export function getMenuTypeOptions() {
value: 'catalog',
},
{ 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',
label: $t('system.menu.typeEmbedded'),

View File

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