This commit is contained in:
dap
2026-04-16 19:56:08 +08:00
74 changed files with 3040 additions and 267 deletions

View File

@@ -30,10 +30,6 @@ const submitButtonOptions = computed(() => {
};
});
// const isQueryForm = computed(() => {
// return !!unref(rootProps).showCollapseButton;
// });
async function handleSubmit(e: Event) {
e?.preventDefault();
e?.stopPropagation();

View File

@@ -0,0 +1,14 @@
export function resolveFieldNamePath(fieldName: string) {
if (fieldName.startsWith('[') && fieldName.endsWith(']')) {
const rawKey = fieldName.slice(1, -1);
return {
pathSegments: [rawKey],
rawKey,
};
}
return {
pathSegments: fieldName.match(/[^.[\]]+/g) ?? [],
rawKey: undefined,
};
}

View File

@@ -16,16 +16,21 @@ import { isRef, toRaw } from 'vue';
import { Store } from '@vben-core/shared/store';
import {
bindMethods,
cloneDeep,
createMerge,
formatDate,
get,
isDate,
isDayjsObject,
isFunction,
isObject,
mergeWithArrayOverride,
set,
StateHandler,
} from '@vben-core/shared/utils';
import { resolveFieldNamePath } from './field-name';
function getDefaultState(): VbenFormProps {
return {
actionWrapperClass: '',
@@ -158,7 +163,10 @@ export class FormApi {
async getValues<T = Recordable<any>>() {
const form = await this.getForm();
return (form.values ? this.handleRangeTimeValue(form.values) : {}) as T;
const values = form.values
? this.handleRangeTimeValue(cloneDeep(toRaw(form.values)))
: {};
return this.handleValueFormat(values) as T;
}
async isFieldValid(fieldName: string) {
@@ -206,14 +214,18 @@ export class FormApi {
return proxy;
}
mount(formActions: FormActions, componentRefMap: Map<string, unknown>) {
mount(formActions: FormActions, componentRefMap?: Map<string, unknown>) {
if (!this.isMounted) {
Object.assign(this.form, formActions);
this.stateHandler.setConditionTrue();
const initialValues = this.form.values
? this.handleRangeTimeValue(cloneDeep(toRaw(this.form.values)))
: {};
this.setLatestSubmissionValues({
...toRaw(this.handleRangeTimeValue(this.form.values)),
...this.handleValueFormat(initialValues),
});
this.componentRefMap = componentRefMap;
this.componentRefMap =
componentRefMap ?? this.componentRefMap ?? new Map();
this.isMounted = true;
}
}
@@ -363,6 +375,7 @@ export class FormApi {
unmount() {
this.form?.resetForm?.();
// this.state = null;
this.componentRefMap = new Map();
this.latestSubmissionValues = null;
this.isMounted = false;
this.stateHandler.reset();
@@ -443,6 +456,42 @@ export class FormApi {
return validateResult;
}
private deleteValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { pathSegments, rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
Reflect.deleteProperty(values, rawKey);
return;
}
if (!pathSegments || pathSegments.length === 0) {
Reflect.deleteProperty(values, fieldName);
return;
}
let target: Record<string, any> | undefined = values;
for (const segment of pathSegments.slice(0, -1)) {
if (!target || !isObject(target)) {
return;
}
target = target[segment];
}
if (!target || !isObject(target)) {
return;
}
const lastPathSegment = pathSegments.at(-1);
if (!lastPathSegment) {
return;
}
Reflect.deleteProperty(target, lastPathSegment);
}
private async getForm() {
if (!this.isMounted) {
// 等待form挂载
@@ -560,6 +609,36 @@ export class FormApi {
return values;
};
private handleValueFormat = (originValues: Record<string, any>) => {
const values = { ...originValues };
const currentSchema = this.state?.schema ?? [];
currentSchema.forEach((schema) => {
if (!schema.valueFormat) {
return;
}
const fieldName = schema.fieldName;
const value = this.resolveValueByFieldName(values, fieldName);
this.deleteValueByFieldName(values, fieldName);
const formattedValue = schema.valueFormat(
value,
(key, nextValue) => {
this.setValueByFieldName(values, key, nextValue);
},
values,
);
if (formattedValue !== undefined) {
this.setValueByFieldName(values, fieldName, formattedValue);
}
});
return values;
};
private processFields = (
fields: string[],
separator: string,
@@ -575,6 +654,32 @@ export class FormApi {
});
};
private resolveValueByFieldName(
values: Record<string, any>,
fieldName: string,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
return values[rawKey];
}
return get(values, fieldName);
}
private setValueByFieldName(
values: Record<string, any>,
fieldName: string,
value: any,
) {
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
values[rawKey] = value;
return;
}
set(values, fieldName, value);
}
private updateState() {
const currentSchema = this.state?.schema ?? [];
const prevSchema = this.prevState?.schema ?? [];

View File

@@ -1,15 +1,18 @@
import type {
ExtendedFormApi,
FormItemDependencies,
FormSchemaRuleType,
MaybeComponentProps,
} from '../types';
import { computed, ref, watch } from 'vue';
import { computed, isRef, ref, watch } from 'vue';
import { get, isBoolean, isFunction } from '@vben-core/shared/utils';
import { useFormValues } from 'vee-validate';
import { resolveFieldNamePath } from '../field-name';
import { injectFormProps } from '../use-form-context';
import { injectRenderFormProps } from './context';
/**
@@ -22,20 +25,21 @@ function resolveValueByFieldName(
fieldName: string,
) {
// vee-validate[] 表示禁用嵌套
if (fieldName.startsWith('[') && fieldName.endsWith(']')) {
const rawKey = fieldName.slice(1, -1);
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
return values[rawKey];
}
return get(values, fieldName);
}
export default function useDependencies(
getDependencies: () => FormItemDependencies | undefined,
) {
const values = useFormValues();
const [extendApi] = injectFormProps();
const formRenderProps = injectRenderFormProps();
const formApi = formRenderProps.form;
if (!formApi) {
@@ -46,6 +50,19 @@ export default function useDependencies(
throw new Error('useDependencies should be used within <VbenForm>');
}
// 在 dependencies 里提供访问extendApi的能力
const getController = (): ExtendedFormApi => {
const controller = isRef(extendApi)
? extendApi.value.formApi
: extendApi.formApi;
if (!controller) {
throw new Error('formApi is required in useDependencies');
}
return controller;
};
const isIf = ref(true);
const isDisabled = ref(false);
const isShow = ref(true);
@@ -91,7 +108,7 @@ export default function useDependencies(
const formValues = values.value;
if (isFunction(whenIf)) {
isIf.value = !!(await whenIf(formValues, formApi));
isIf.value = !!(await whenIf(formValues, formApi, getController()));
// 不渲染
if (!isIf.value) return;
} else if (isBoolean(whenIf)) {
@@ -101,31 +118,43 @@ export default function useDependencies(
// 2. 判断show如果show为false则隐藏
if (isFunction(show)) {
isShow.value = !!(await show(formValues, formApi));
isShow.value = !!(await show(formValues, formApi, getController()));
} else if (isBoolean(show)) {
isShow.value = show;
}
if (isFunction(componentProps)) {
dynamicComponentProps.value = await componentProps(formValues, formApi);
dynamicComponentProps.value = await componentProps(
formValues,
formApi,
getController(),
);
}
if (isFunction(rules)) {
dynamicRules.value = await rules(formValues, formApi);
dynamicRules.value = await rules(formValues, formApi, getController());
}
if (isFunction(disabled)) {
isDisabled.value = !!(await disabled(formValues, formApi));
isDisabled.value = !!(await disabled(
formValues,
formApi,
getController(),
));
} else if (isBoolean(disabled)) {
isDisabled.value = disabled;
}
if (isFunction(required)) {
isRequired.value = !!(await required(formValues, formApi));
isRequired.value = !!(await required(
formValues,
formApi,
getController(),
));
}
if (isFunction(trigger)) {
trigger(formValues, formApi);
trigger(formValues, formApi, getController());
}
},
{ deep: true, immediate: true },

View File

@@ -7,15 +7,24 @@ import type {
MaybeComponentProps,
} from '../types';
import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
import { CircleAlert } from '@vben-core/icons';
import {
computed,
nextTick,
onUnmounted,
ref,
useTemplateRef,
watch,
} from 'vue';
import { ChevronsDown, CircleAlert } from '@vben-core/icons';
import {
Button,
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
VbenCollapsible,
VbenRenderContent,
VbenTooltip,
} from '@vben-core/shadcn-ui';
@@ -53,6 +62,8 @@ const {
renderComponentContent,
rules,
help,
collapsible,
defaultCollapsed = false,
} = defineProps<
Props & {
commonComponentProps: MaybeComponentProps;
@@ -67,6 +78,7 @@ const fieldComponentRef = useTemplateRef<HTMLInputElement>('fieldComponentRef');
const formApi = formRenderProps.form;
const compact = computed(() => formRenderProps.compact);
const isInValid = computed(() => errors.value?.length > 0);
const collapseOpen = ref(!defaultCollapsed);
function getFormApi(): FormActions {
if (!formApi) {
@@ -77,7 +89,9 @@ function getFormApi(): FormActions {
}
const FieldComponent = computed(() => {
const finalComponent = isString(component) ? componentMap.value[component] : component;
const finalComponent = isString(component)
? componentMap.value[component]
: component;
if (!finalComponent) {
// 组件未注册
console.warn(`Component ${component} is not registered`);
@@ -85,8 +99,14 @@ const FieldComponent = computed(() => {
return finalComponent;
});
const { dynamicComponentProps, dynamicRules, isDisabled, isIf, isRequired, isShow } =
useDependencies(() => dependencies);
const {
dynamicComponentProps,
dynamicRules,
isDisabled,
isIf,
isRequired,
isShow,
} = useDependencies(() => dependencies);
const labelStyle = computed(() => {
return labelClass?.includes('w-') || isVertical.value
@@ -225,7 +245,8 @@ function fieldBindEvent(slotProps: Record<string, any>) {
const handler = slotProps.componentField['onUpdate:modelValue'];
const bindEventField =
modelPropName || (isString(component) ? componentBindEventMap.value?.[component] : null);
modelPropName ||
(isString(component) ? componentBindEventMap.value?.[component] : null);
let value = modelValue;
// antd design 的一些组件会传递一个 event 对象
@@ -287,6 +308,15 @@ function autofocus() {
fieldComponentRef.value?.focus?.();
}
}
const shouldCollapsible = computed(() => {
return collapsible; /* && isVertical.value; */
});
function toggleCollapsed() {
collapseOpen.value = !collapseOpen.value;
}
const componentRefMap = injectComponentRefMap();
watch(fieldComponentRef, (componentRef) => {
componentRefMap?.set(fieldName, componentRef);
@@ -299,7 +329,12 @@ onUnmounted(() => {
</script>
<template>
<FormField v-if="!hide && isIf" v-bind="fieldProps" v-slot="slotProps" :name="fieldName">
<FormField
v-if="!hide && isIf"
v-bind="fieldProps"
v-slot="slotProps"
:name="fieldName"
>
<FormItem
v-show="isShow"
:class="{
@@ -321,6 +356,7 @@ onUnmounted(() => {
{
'mr-2 shrink-0 justify-end': !isVertical,
'mb-1 flex-row': isVertical,
'self-start': shouldCollapsible && !isVertical,
},
labelClass,
)
@@ -334,58 +370,87 @@ onUnmounted(() => {
<template v-if="label">
<VbenRenderContent :content="label" />
</template>
</FormLabel>
<!-- overflow-hidden导致radio的波纹效果无法显示 -->
<div class="flex-auto p-[1px]">
<div :class="cn('relative flex w-full items-center', wrapperClass)">
<FormControl :class="cn(controlClass)">
<slot
v-bind="{
...slotProps,
...createComponentProps(slotProps),
disabled: shouldDisabled,
isInValid,
<template #extra>
<Button
class="ml-0.5"
variant="icon"
size="icon"
@click.prevent="toggleCollapsed"
v-if="shouldCollapsible"
>
<ChevronsDown
:size="16"
class="transition-transform"
:class="{
'rotate-180': !collapseOpen,
}"
>
<component
:is="FieldComponent"
ref="fieldComponentRef"
:class="{
'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
/>
</Button>
</template>
</FormLabel>
<div class="flex-auto p-px">
<VbenCollapsible :show-trigger="false" v-model:open="collapseOpen">
<template #collapsibleContent>
<div :class="cn('relative flex w-full items-center', wrapperClass)">
<FormControl :class="cn(controlClass)">
<slot
v-bind="{
...slotProps,
...createComponentProps(slotProps),
disabled: shouldDisabled,
isInValid,
}"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"
>
<template v-for="name in renderContentKey" :key="name" #[name]="renderSlotProps">
<VbenRenderContent
:content="customContentRender[name]"
v-bind="{ ...renderSlotProps, formContext: slotProps }"
/>
</template>
<!-- <slot></slot> -->
</component>
<VbenTooltip v-if="compact && isInValid" :delay-duration="300" side="left">
<template #trigger>
<slot name="trigger">
<CircleAlert
:class="
cn(
'inline-flex size-5 cursor-pointer text-foreground/80 hover:text-foreground',
)
"
/>
</slot>
</template>
<FormMessage />
</VbenTooltip>
</slot>
</FormControl>
<!-- 自定义后缀 -->
<div v-if="suffix" class="ml-1">
<VbenRenderContent :content="suffix" />
</div>
</div>
}"
>
<component
:is="FieldComponent"
ref="fieldComponentRef"
:class="{
'border-destructive hover:border-destructive/80 focus:border-destructive focus:shadow-[0_0_0_2px_rgba(255,38,5,0.06)]':
isInValid,
}"
v-bind="createComponentProps(slotProps)"
:disabled="shouldDisabled"
>
<template
v-for="name in renderContentKey"
:key="name"
#[name]="renderSlotProps"
>
<VbenRenderContent
:content="customContentRender[name]"
v-bind="{ ...renderSlotProps, formContext: slotProps }"
/>
</template>
<!-- <slot></slot> -->
</component>
<VbenTooltip
v-if="compact && isInValid"
:delay-duration="300"
side="left"
>
<template #trigger>
<slot name="trigger">
<CircleAlert
:class="
cn(
'inline-flex size-5 cursor-pointer text-foreground/80 hover:text-foreground',
)
"
/>
</slot>
</template>
<FormMessage />
</VbenTooltip>
</slot>
</FormControl>
<!-- 自定义后缀 -->
<div v-if="suffix" class="ml-1">
<VbenRenderContent :content="suffix" />
</div>
</div>
</template>
</VbenCollapsible>
<FormDescription v-if="description" class="text-xs">
<VbenRenderContent :content="description" />
</FormDescription>

View File

@@ -26,6 +26,7 @@ const props = defineProps<Props>();
</span>
<!-- <VbenRenderContent :content="help" /> -->
</VbenHelpTooltip>
<slot name="extra"></slot>
<span v-if="colon && label" class="ml-0.5">:</span>
</FormLabel>
</template>

View File

@@ -3,8 +3,9 @@ export { setupVbenForm } from './config';
export type {
BaseFormComponentType,
ExtendedFormApi,
FormSchema as VbenFormSchema,
FormLayout,
VbenFormProps,
FormSchema as VbenFormSchema,
} from './types';
export * from './use-vben-form';

View File

@@ -85,16 +85,19 @@ export type FormSchemaRuleType =
type FormItemDependenciesCondition<T = boolean | PromiseLike<boolean>> = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
) => T;
type FormItemDependenciesConditionWithRules = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
) => FormSchemaRuleType | PromiseLike<FormSchemaRuleType>;
type FormItemDependenciesConditionWithProps = (
value: Partial<Record<string, any>>,
actions: FormActions,
controller: ExtendedFormApi, // 在 dependencies 里提供访问extendApi的能力
) => MaybeComponentProps | PromiseLike<MaybeComponentProps>;
export interface FormItemDependencies {
@@ -145,6 +148,11 @@ type ComponentProps =
| MaybeComponentProps;
export interface FormCommonConfig {
/**
* 是否可折叠的
* @default false
*/
collapsible?: boolean;
/**
* 在Label后显示一个冒号
*/
@@ -157,6 +165,11 @@ export interface FormCommonConfig {
* 所有表单项的控件样式
*/
controlClass?: string;
/**
* 默认折叠
* @default false
*/
defaultCollapsed?: boolean;
/**
* 所有表单项的禁用状态
* @default false
@@ -228,6 +241,19 @@ type MappedComponentProps<P> =
) => P & Record<string, any>)
| (P & Record<string, any>);
/**
* 格式化 `getValues()` 输出中的当前字段值。
* - 返回 `undefined`:保留当前字段已被移除的状态,通常配合 `setValue(key, nextValue)`
* 把一个字段拆分写入到其他字段,例如 `startTime` / `endTime`
* - 返回其他值:会将当前字段恢复/写回为该返回值
* - `setValue` 回调签名为 `(key, nextValue) => void`
*/
export type FormValueFormat = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: Record<string, any>,
) => any;
interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
/** 默认值 */
defaultValue?: any;
@@ -249,6 +275,12 @@ interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
rules?: FormSchemaRuleType;
/** 后缀 */
suffix?: CustomRenderType;
/**
* 获取表单值时格式化当前字段。
* - 返回值不为 `undefined` 时,会回写到当前 fieldName
* - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段
*/
valueFormat?: FormValueFormat;
}
type FormSchemaDiscriminated<