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

This commit is contained in:
xingyu4j
2025-04-22 15:39:53 +08:00
38 changed files with 815 additions and 424 deletions

View File

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

View File

@@ -307,6 +307,7 @@ export class FormApi {
return true;
});
const filteredFields = fieldMergeFn(fields, form.values);
this.handleStringToArrayFields(filteredFields);
form.setValues(filteredFields, shouldValidate);
}
@@ -316,6 +317,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;
@@ -404,10 +406,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;
}
@@ -453,6 +498,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;
/**
* 表单字段映射
*/
@@ -354,11 +378,15 @@ export interface VbenFormProps<
/**
* 表单值变化回调
*/
handleValuesChange?: (values: Record<string, any>) => void;
handleValuesChange?: (
values: Record<string, any>,
fieldsChanged: string[],
) => void;
/**
* 重置按钮参数
*/
resetButtonOptions?: ActionButtonOptions;
/**
* 是否显示默认操作按钮
* @default true

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import type { Recordable } from '@vben-core/typings';
import type { ExtendedFormApi, VbenFormProps } from './types';
// import { toRaw, watch } from 'vue';
import { nextTick, onMounted, watch } from 'vue';
// import { isFunction } from '@vben-core/shared/utils';
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';
@@ -61,16 +62,46 @@ function handleKeyDownEnter(event: KeyboardEvent) {
}
const handleValuesChangeDebounced = useDebounceFn(async () => {
forward.value.handleValuesChange?.(
cloneDeep(await forward.value.formApi.getValues()),
);
state.value.submitOnChange && forward.value.formApi?.validateAndSubmitForm();
}, 300);
const valuesCache: Recordable<any> = {};
onMounted(async () => {
// 只在挂载后开始监听form.values会有一个初始化的过程
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>

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 = {
@@ -70,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,22 @@ const getIconRender = computed(() => {
}
return iconRender;
});
function doCancel() {
handleCancel();
handleOpenChange(false);
}
function doConfirm() {
handleConfirm();
handleOpenChange(false);
}
provideAlertContext({
doCancel,
doConfirm,
});
function handleConfirm() {
isConfirm.value = true;
emits('confirm');
@@ -98,11 +116,13 @@ function handleCancel() {
const loading = ref(false);
async function handleOpenChange(val: boolean) {
const confirmState = isConfirm.value;
isConfirm.value = false;
await nextTick();
if (!val && props.beforeClose) {
loading.value = true;
try {
const res = await props.beforeClose({ isConfirm: isConfirm.value });
const res = await props.beforeClose({ isConfirm: confirmState });
if (res !== false) {
open.value = false;
}
@@ -152,7 +172,7 @@ 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" />

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

@@ -54,7 +54,6 @@ describe('drawerApi', () => {
});
it('should close the drawer if onBeforeClose allows it', () => {
drawerApi.open();
drawerApi.close();
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 返回 false则不关闭弹窗
const allowClose = this.api.onBeforeClose?.() ?? true;
const allowClose = (await this.api.onBeforeClose?.()) ?? true;
if (allowClose) {
this.store.setState((prev) => ({
...prev,

View File

@@ -1,6 +1,6 @@
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';
@@ -151,7 +151,7 @@ export interface DrawerApiOptions extends DrawerState {
* 关闭前的回调,返回 false 可以阻止关闭
* @returns
*/
onBeforeClose?: () => void;
onBeforeClose?: () => MaybePromise<boolean | undefined>;
/**
* 点击取消按钮的回调
*/

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

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

View File

@@ -70,6 +70,13 @@ export function useVbenModal<TParentModalProps extends ModalProps = ModalProps>(
injectData.options?.onOpenChange?.(isOpen);
};
mergedOptions.onClosed = () => {
options.onClosed?.();
if (options.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

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

View File

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

View File

@@ -45,6 +45,9 @@
);
--vxe-ui-table-row-current-background-color: hsl(var(--accent));
--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;