Merge branch 'vbenjs:main' into main

This commit is contained in:
zhb410181
2026-07-23 15:25:16 +08:00
committed by GitHub
50 changed files with 1779 additions and 472 deletions

1
.gitignore vendored
View File

@@ -60,3 +60,4 @@ vite.config.ts.*
skills-lock.json skills-lock.json
.atomcode .atomcode
datalog datalog
.playwright-mcp

View File

@@ -42,18 +42,27 @@ async function initSetupVbenForm() {
}); });
} }
function useVbenForm<TValues extends FormValues = FormValues>( function useVbenForm<
options: FormProps<ComponentType, ComponentPropsMap, TValues>, TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) { ) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options); return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
} }
export { initSetupVbenForm, useVbenForm, z }; export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema<TValues extends FormValues = FormValues> = export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>; FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps< export type VbenFormProps<
ComponentType, TFormValues extends FormValues = FormValues,
ComponentPropsMap, TSubmitValues extends FormValues = TFormValues,
TValues > = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;
>;

View File

@@ -1,3 +1,4 @@
import type { FormValues } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table'; import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component'; import type { ComponentPropsMap, ComponentType } from './component';
@@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm, useVbenForm,
}); });
export const useVbenVxeGrid = <T extends Record<string, any>>( export const useVbenVxeGrid = <
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>> T extends Record<string, any>,
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest); TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
export type * from '@vben/plugins/vxe-table'; export type * from '@vben/plugins/vxe-table';

View File

@@ -41,18 +41,27 @@ async function initSetupVbenForm() {
}, },
}); });
} }
function useVbenForm<TValues extends FormValues = FormValues>( function useVbenForm<
options: FormProps<ComponentType, ComponentPropsMap, TValues>, TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) { ) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options); return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
} }
export { initSetupVbenForm, useVbenForm, z }; export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema<TValues extends FormValues = FormValues> = export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>; FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps< export type VbenFormProps<
ComponentType, TFormValues extends FormValues = FormValues,
ComponentPropsMap, TSubmitValues extends FormValues = TFormValues,
TValues > = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;
>;

View File

@@ -1,3 +1,4 @@
import type { FormValues } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table'; import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component'; import type { ComponentPropsMap, ComponentType } from './component';
@@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm, useVbenForm,
}); });
export const useVbenVxeGrid = <T extends Record<string, any>>( export const useVbenVxeGrid = <
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>> T extends Record<string, any>,
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest); TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
export type * from '@vben/plugins/vxe-table'; export type * from '@vben/plugins/vxe-table';

View File

@@ -34,18 +34,27 @@ async function initSetupVbenForm() {
}); });
} }
function useVbenForm<TValues extends FormValues = FormValues>( function useVbenForm<
options: FormProps<ComponentType, ComponentPropsMap, TValues>, TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) { ) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options); return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
} }
export { initSetupVbenForm, useVbenForm, z }; export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema<TValues extends FormValues = FormValues> = export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>; FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps< export type VbenFormProps<
ComponentType, TFormValues extends FormValues = FormValues,
ComponentPropsMap, TSubmitValues extends FormValues = TFormValues,
TValues > = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;
>;

View File

@@ -1,3 +1,4 @@
import type { FormValues } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table'; import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component'; import type { ComponentPropsMap, ComponentType } from './component';
@@ -71,8 +72,23 @@ setupVbenVxeTable({
useVbenForm, useVbenForm,
}); });
export const useVbenVxeGrid = <T extends Record<string, any>>( export const useVbenVxeGrid = <
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>> T extends Record<string, any>,
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest); TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
export type * from '@vben/plugins/vxe-table'; export type * from '@vben/plugins/vxe-table';

View File

@@ -38,18 +38,27 @@ async function initSetupVbenForm() {
}); });
} }
function useVbenForm<TValues extends FormValues = FormValues>( function useVbenForm<
options: FormProps<ComponentType, ComponentPropsMap, TValues>, TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) { ) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options); return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
} }
export { initSetupVbenForm, useVbenForm, z }; export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema<TValues extends FormValues = FormValues> = export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>; FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps< export type VbenFormProps<
ComponentType, TFormValues extends FormValues = FormValues,
ComponentPropsMap, TSubmitValues extends FormValues = TFormValues,
TValues > = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;
>;

View File

@@ -1,3 +1,4 @@
import type { FormValues } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table'; import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component'; import type { ComponentPropsMap, ComponentType } from './component';
@@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm, useVbenForm,
}); });
export const useVbenVxeGrid = <T extends Record<string, any>>( export const useVbenVxeGrid = <
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>> T extends Record<string, any>,
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest); TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
export type * from '@vben/plugins/vxe-table'; export type * from '@vben/plugins/vxe-table';

View File

@@ -42,18 +42,27 @@ async function initSetupVbenForm() {
}); });
} }
function useVbenForm<TValues extends FormValues = FormValues>( function useVbenForm<
options: FormProps<ComponentType, ComponentPropsMap, TValues>, TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) { ) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options); return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
} }
export { initSetupVbenForm, useVbenForm, z }; export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema<TValues extends FormValues = FormValues> = export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>; FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps< export type VbenFormProps<
ComponentType, TFormValues extends FormValues = FormValues,
ComponentPropsMap, TSubmitValues extends FormValues = TFormValues,
TValues > = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;
>;

View File

@@ -1,3 +1,4 @@
import type { FormValues } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table'; import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { ComponentPropsMap, ComponentType } from './component'; import type { ComponentPropsMap, ComponentType } from './component';
@@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm, useVbenForm,
}); });
export const useVbenVxeGrid = <T extends Record<string, any>>( export const useVbenVxeGrid = <
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>> T extends Record<string, any>,
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest); TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
export type * from '@vben/plugins/vxe-table'; export type * from '@vben/plugins/vxe-table';

View File

@@ -41,18 +41,30 @@ setupVbenForm<ComponentType>({
}, },
}); });
function useVbenForm<TValues extends FormValues = FormValues>( function useVbenForm<
options: FormProps<ComponentType, Record<never, never>, TValues>, TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
Record<never, never>,
TFormValues,
TSubmitValues
>,
) { ) {
return useForm<TValues, ComponentType, Record<never, never>>(options); return useForm<
TFormValues,
ComponentType,
Record<never, never>,
TSubmitValues
>(options);
} }
export { useVbenForm, z }; export { useVbenForm, z };
export type VbenFormSchema<TValues extends FormValues = FormValues> = export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, Record<never, never>, TValues>; FormSchema<ComponentType, Record<never, never>, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps< export type VbenFormProps<
ComponentType, TFormValues extends FormValues = FormValues,
Record<never, never>, TSubmitValues extends FormValues = TFormValues,
TValues > = FormProps<ComponentType, Record<never, never>, TFormValues, TSubmitValues>;
>;

View File

@@ -244,13 +244,28 @@ export { initComponentAdapter };
<DemoPreview dir="demos/vben-form/query" /> <DemoPreview dir="demos/vben-form/query" />
## 值格式化 ## 表单值编解码
当组件的展示值与后端真正需要的 payload 不一致时,可以在 schema 上使用 `valueFormat`。它会在 `getValues()`、提交、以及依赖这些输出的方法中生效。 当组件值与后端 payload 不一致时,使用表单级 `codec` 统一定义双向转换。`encode` 接收完整 `TFormValues` 并返回完整 `TSubmitValues`;`decode` 执行反向转换。多字段拆分、合并和删除都在一个纯函数边界完成,不依赖 schema 顺序或字符串路径写入。
- `return xxx`:回写当前字段 `codec` 直接写在 `useVbenForm` 选项中即可。只需标注 `encode` 的表单值入参,`TSubmitValues` 会从返回对象自动推导,并传递给 `decode`、`getValues()` 和提交回调:
- `setValue('startTime', xxx)`:写入其他字段
- `return undefined`:保持当前字段已被移除,适合把一个字段拆成多个字段 ```ts
const [Form, formApi] = useVbenForm({
codec: {
decode(values) {
return { period: [values.startTime, values.endTime] };
},
encode(values: Readonly<FormValues>) {
return {
endTime: values.period[1],
startTime: values.period[0],
};
},
},
schema,
});
```
<DemoPreview dir="demos/vben-form/value-format" /> <DemoPreview dir="demos/vben-form/value-format" />
@@ -305,7 +320,7 @@ const [Form, formApi] = useVbenForm({
### 类型传递与插槽 ### 类型传递与插槽
通过 `useVbenForm<TValues>` 定义一次表单值类型后,`getValues`、`setValues`、`setFieldValue`、`handleSubmit`、`handleValuesChange`、`formApi.form.values` 和 selector 都会沿用该类型,可直接作为 API 请求参数: 使用 `useVbenForm<TFormValues, TSubmitValues>` 分别声明组件表单值和提交值。schema、slots、`setValues`、`formApi.form.values` 使用 `TFormValues`;`getValues`、submit 和 `handleSubmit` 第一参数使用 `TSubmitValues`。两种结构相同时只传一个泛型即可。
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
@@ -362,15 +377,16 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
| 方法名 | 描述 | 类型 | 版本号 | | 方法名 | 描述 | 类型 | 版本号 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| submit | 提交表单 | `(e?: Event) => Promise<TValues>` | - | | submit | 提交表单 | `(e?: Event) => Promise<TSubmitValues>` | - |
| validateAndSubmit | 校验通过后提交表单 | `() => Promise<TValues \| undefined>` | - | | validateAndSubmit | 校验通过后提交表单 | `() => Promise<TSubmitValues \| undefined>` | - |
| reset | 重置表单 | `(state?: FormResetState<TValues>, options?: FormResetOptions) => Promise<void>` | - | | reset | 重置表单 | `(state?: FormResetState<TFormValues>, options?: FormResetOptions) => Promise<void>` | - |
| clearValidation | 清空指定字段或全部校验,并取消进行中的异步校验 | `(fieldNames?: FormFieldName<TValues> \| FormFieldName<TValues>[]) => Promise<void>` | - | | clearValidation | 清空指定字段或全部校验,并取消进行中的异步校验 | `(fieldNames?: FormFieldName<TFormValues> \| FormFieldName<TFormValues>[]) => Promise<void>` | - |
| setValues | 设置表单值,默认会过滤不在 schema 中定义的字段 | `(fields: Partial<TValues>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - | | setValues | 设置表单组件值,默认会过滤不在 schema 中定义的字段 | `(fields: Partial<TFormValues>, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
| getValues | 获取经过字段映射和 valueFormat 的值 | `() => Promise<TValues>` | - | | setSubmitValues | 通过 codec.decode 回填完整提交值 | `(values: TSubmitValues, filterFields?: boolean, shouldValidate?: boolean) => Promise<void>` | - |
| getRawValues | 获取未格式化的独立值快照 | `() => Promise<TValues>` | - | | getValues | 获取经过 codec.encode 或旧格式化管道的提交值 | `() => Promise<TSubmitValues>` | - |
| getValueSnapshot | 一次获取原始值和格式化值 | `() => Promise<FormValueSnapshot<TValues>>` | - | | getRawValues | 获取未格式化的独立表单值快照 | `() => Promise<TFormValues>` | - |
| formatValues | 格式化指定的原始值快照 | `(rawValues: Readonly<TValues>) => TValues` | - | | getValueSnapshot | 一次获取表单值和提交值 | `() => Promise<FormValueSnapshot<TFormValues, TSubmitValues>>` | - |
| formatValues | 编码指定的表单值快照 | `(rawValues: Readonly<TFormValues>) => TSubmitValues` | - |
| validate | 表单校验 | `() => Promise<FormValidationResult>` | - | | validate | 表单校验 | `() => Promise<FormValidationResult>` | - |
| validateField | 校验指定字段 | `(fieldName: string) => Promise<FormValidationResult>` | - | | validateField | 校验指定字段 | `(fieldName: string) => Promise<FormValidationResult>` | - |
| isFieldValid | 检查某个字段是否已通过校验 | `(fieldName: string)=>Promise<boolean>` | - | | isFieldValid | 检查某个字段是否已通过校验 | `(fieldName: string)=>Promise<boolean>` | - |
@@ -415,8 +431,9 @@ const submitting = formApi.form.useSelector((state) => state.meta.submitting);
| actionLayout | 表单操作按钮位置 | `'newLine' \| 'rowEnd' \| 'inline'` | `rowEnd` | | actionLayout | 表单操作按钮位置 | `'newLine' \| 'rowEnd' \| 'inline'` | `rowEnd` |
| actionPosition | 表单操作按钮对齐方式 | `'left' \| 'center' \| 'right'` | `right` | | actionPosition | 表单操作按钮对齐方式 | `'left' \| 'center' \| 'right'` | `right` |
| handleReset | 表单重置回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - | | handleReset | 表单重置回调 | `(values: Record<string, any>,) => Promise<void> \| void` | - |
| handleSubmit | 表单提交回调 | `(values: TValues, rawValues: Readonly<TValues>) => Promise<void> \| void` | - | | codec | 表单值与提交值的双向编解码器 | `FormCodec<TFormValues, TSubmitValues>` | - |
| handleValuesChange | 表单值变化回调 | `(rawValues: Readonly<TValues>, fieldsChanged: string[], getFormattedValues: () => TValues) => void` | - | | handleSubmit | 表单提交回调 | `(values: TSubmitValues, rawValues: Readonly<TFormValues>) => Promise<void> \| void` | - |
| handleValuesChange | 表单值变化回调 | `(rawValues: Readonly<TFormValues>, fieldsChanged: string[], getFormattedValues: () => TSubmitValues) => void` | - |
| handleCollapsedChange | 表单收起展开状态变化回调 | `(collapsed: boolean) => void` | - | | handleCollapsedChange | 表单收起展开状态变化回调 | `(collapsed: boolean) => void` | - |
| actionButtonsReverse | 调换操作按钮位置 | `boolean` | `false` | | actionButtonsReverse | 调换操作按钮位置 | `boolean` | `false` |
| resetButtonOptions | 重置按钮组件参数 | `ActionButtonOptions` | - | | resetButtonOptions | 重置按钮组件参数 | `ActionButtonOptions` | - |
@@ -435,41 +452,15 @@ const submitting = formApi.form.useSelector((state) => state.meta.submitting);
::: tip handleValuesChange ::: tip handleValuesChange
`handleValuesChange` 的第一个参数是未经过 `valueFormat`、`fieldMappingTime` 或 array-to-string 转换的只读当前值,第二个参数是本次发生变化的 schema 字段名。第三个参数 `getFormattedValues` 是惰性函数:不调用就不会执行深拷贝和格式化,适合只在少数变化场景读取提交结构。字段映射生成的目标字段不会出现在 `fieldsChanged` 中。 `handleValuesChange` 的第一个参数是未编码的只读 `TFormValues`,第二个参数是本次发生变化的 schema 字段名。第三个参数 `getFormattedValues` 是惰性函数:不调用就不会执行 codec 或旧格式化管道。
`getRawValues()` 和 `getValues()` 分别只生成一份目标快照;确实需要同时比较两种结构时再调用 `getValueSnapshot()`。`handleSubmit(values, rawValues)` 会在提交边界同时提供格式化结果和对应的原始快照。 `getRawValues()` 和 `getValues()` 分别只生成一份目标快照;确实需要同时比较两种结构时再调用 `getValueSnapshot()`。`handleSubmit(values, rawValues)` 会在提交边界同时提供格式化结果和对应的原始快照。
::: :::
::: tip fieldMappingTime ::: tip 旧格式化 API
此属性用于将表单内的数组值映射成 2 个字段,它应当传入一个数组,数组的每一项是一个映射规则,规则的第一个成员是一个字符串,表示需要映射的字段名,第二个成员是一个数组,表示映射后的字段名,第三个成员是一个可选的格式掩码,用于格式化日期时间字段;也可以提供一个格式化函数(参数分别为当前值和当前字段名,返回格式化后的值)。如果明确地将格式掩码设为null,则原值映射而不进行格式化(适用于非日期时间字段)。例如:`[['timeRange', ['startTime', 'endTime'], 'YYYY-MM-DD']]`,`timeRange`应当是一个至少具有2个成员的数组类型的值。Form会将`timeRange`的值前两个值分别按照格式掩码`YYYY-MM-DD`格式化后映射到`startTime`和`endTime`字段上。每一项的第三个参数是一个可选的格式掩码, `schema.valueFormat`、`fieldMappingTime` 和 `arrayToStringFields` 仍保持原运行时行为,但已经标记为 `@deprecated`,开发环境首次使用时会提示迁移。配置 codec 后只执行 codec;同时存在的旧配置会被忽略,避免重复转换。
:::
::: tip valueFormat
`valueFormat` 适合处理“组件值”和“提交值”不一致的场景。例如:
- `RangePicker` 返回 `[dayjs, dayjs]`,但后端需要 `{ startTime, endTime }`
- `DatePicker` 返回 `dayjs`,但后端只需要时间戳
`valueFormat` 会在 `getValues()` 过程中执行:
- 返回 `undefined`:当前字段保持删除状态
- 返回其他值:回写当前字段
- 调用 `setValue(key, nextValue)`:写入一个或多个新字段
```ts
{
component: 'RangePicker',
fieldName: 'reportRange',
valueFormat(value, setValue) {
setValue('startTime', value?.[0]?.valueOf());
setValue('endTime', value?.[1]?.valueOf());
},
}
```
::: :::
@@ -604,7 +595,7 @@ export interface FormSchema<
rules?: FormSchemaRuleType; rules?: FormSchemaRuleType;
/** 后缀 */ /** 后缀 */
suffix?: CustomRenderType; suffix?: CustomRenderType;
/** 获取 getValues() 输出时格式化当前字段 */ /** @deprecated 使用表单级 codec */
valueFormat?: FormValueFormat; valueFormat?: FormValueFormat;
} }
``` ```
@@ -615,6 +606,8 @@ export interface FormSchema<
::: details FormValueFormat ::: details FormValueFormat
`FormValueFormat` 是兼容类型,已标记为 `@deprecated`。新代码应使用 `FormCodec<TFormValues, TSubmitValues>`。
```ts ```ts
type FormValueFormat = ( type FormValueFormat = (
value: any, value: any,

View File

@@ -1,47 +1,80 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, ref } from 'vue';
import { Button, Card, message, Space, Tag } from 'antdv-next'; import { Button, Card, message, Space, Tag } from 'antdv-next';
import { useVbenForm } from '#/adapter/form'; import { useVbenForm } from '#/adapter/form';
const transformedValues = ref<Record<string, any>>({}); interface ValueFormatFormValues {
const liveValues = ref<Record<string, any>>({}); firstName?: string;
lastName?: string;
tags?: string[];
}
function encodeValueFormatValues(values: Readonly<ValueFormatFormValues>) {
return {
fullName: [values.firstName, values.lastName].filter(Boolean).join(' '),
tags: (values.tags ?? []).join(','),
};
}
type ValueFormatSubmitValues = ReturnType<typeof encodeValueFormatValues>;
function decodeValueFormatValues(
values: Readonly<ValueFormatSubmitValues>,
): ValueFormatFormValues {
const [firstName = '', ...lastNameParts] = values.fullName
.trim()
.split(/\s+/);
return {
firstName,
lastName: lastNameParts.join(' '),
tags: values.tags ? values.tags.split(',') : [],
};
}
const transformedValues = ref<Partial<ValueFormatSubmitValues>>({});
const liveValues = ref<Partial<ValueFormatFormValues>>({});
const [Form, formApi] = useVbenForm({ const [Form, formApi] = useVbenForm({
codec: {
decode: decodeValueFormatValues,
encode: encodeValueFormatValues,
},
commonConfig: { commonConfig: {
componentProps: { componentProps: {
class: 'w-full', class: 'w-full',
}, },
}, },
handleSubmit, handleSubmit,
handleValuesChange,
schema: [ schema: [
{ {
component: 'RangePicker', component: 'Input',
fieldName: 'reportRange', fieldName: 'firstName',
help: '通过 setValue 拆分为 startTime / endTime,并移除原字段', help: '与姓氏一起编码为 fullName',
label: '统计时间范围', label: '名字',
valueFormat(value, setValue) {
setValue('startTime', value?.[0]?.valueOf());
setValue('endTime', value?.[1]?.valueOf());
},
},
{
component: 'DatePicker',
fieldName: 'deadline',
help: '直接 return 时间戳,保留原字段名',
label: '截止时间',
valueFormat(value) {
return value?.valueOf();
},
}, },
{ {
component: 'Input', component: 'Input',
fieldName: 'lastName',
help: '与名字一起编码为 fullName',
label: '姓氏',
},
{
component: 'Select',
componentProps: { componentProps: {
placeholder: '请输入关键字', mode: 'multiple',
options: [
{ label: '管理员', value: 'admin' },
{ label: '审核员', value: 'reviewer' },
{ label: '访客', value: 'guest' },
],
placeholder: '请选择标签',
}, },
fieldName: 'keyword', fieldName: 'tags',
label: '关键字', help: '数组编码为逗号分隔字符串',
label: '标签',
}, },
], ],
wrapperClass: 'grid-cols-1 md:grid-cols-2', wrapperClass: 'grid-cols-1 md:grid-cols-2',
@@ -53,22 +86,8 @@ const transformedValuesPreview = computed(() => {
return formatJsonPreview(transformedValues.value); return formatJsonPreview(transformedValues.value);
}); });
function formatJsonPreview(value: Record<string, any>) { function formatJsonPreview(value: unknown) {
return JSON.stringify( return JSON.stringify(value, null, 2);
value,
(_key, currentValue) => {
return isFormattableDateValue(currentValue)
? currentValue.format('YYYY-MM-DD HH:mm:ss')
: currentValue;
},
2,
);
}
function isFormattableDateValue(
value: unknown,
): value is { format: (template: string) => string } {
return !!value && typeof value === 'object' && 'format' in value;
} }
async function handleInspectValues() { async function handleInspectValues() {
@@ -76,44 +95,54 @@ async function handleInspectValues() {
message.success('已刷新 getValues 输出'); message.success('已刷新 getValues 输出');
} }
function handleSubmit(values: Record<string, any>) { async function handleSetSubmitValues() {
await formApi.setSubmitValues({
fullName: 'Ada Lovelace',
tags: 'admin,reviewer',
});
await syncPreviewValues();
message.success('已通过 codec.decode 回填提交值');
}
function handleSubmit(values: ValueFormatSubmitValues) {
transformedValues.value = values; transformedValues.value = values;
message.success({ message.success({
content: `getValues output: ${JSON.stringify(values)}`, content: `getValues output: ${JSON.stringify(values)}`,
}); });
} }
async function syncPreviewValues(values?: Record<string, any>) { function handleValuesChange(
liveValues.value = values ?? formApi.form?.values ?? {}; values: Readonly<ValueFormatFormValues>,
_fieldsChanged: string[],
getFormattedValues: () => ValueFormatSubmitValues,
) {
liveValues.value = { ...values };
transformedValues.value = getFormattedValues();
}
async function syncPreviewValues(values?: Readonly<ValueFormatFormValues>) {
liveValues.value = { ...(values ?? formApi.form?.values ?? {}) };
transformedValues.value = await formApi.getValues(); transformedValues.value = await formApi.getValues();
} }
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
watch( await syncPreviewValues();
() => formApi.form?.values,
async (values) => {
await syncPreviewValues(values);
},
{
deep: true,
immediate: true,
},
);
}); });
</script> </script>
<template> <template>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Tag color="processing">return 值:回写当前字段</Tag> <Tag color="processing">encode:生成完整提交值</Tag>
<Tag color="success">setValue:拆分写入其他字段</Tag> <Tag color="success">decode:恢复完整表单值</Tag>
<Tag color="warning">return undefined:保持原字段删除</Tag> <Tag color="warning">多字段转换原子执行</Tag>
</div> </div>
<Card title="valueFormat 示例"> <Card title="Codec 示例">
<template #extra> <template #extra>
<Space wrap> <Space wrap>
<Button @click="handleSetSubmitValues">从提交值回填</Button>
<Button type="primary" @click="handleInspectValues"> <Button type="primary" @click="handleInspectValues">
查看 getValues 输出 查看 getValues 输出
</Button> </Button>
@@ -128,7 +157,7 @@ onMounted(async () => {
liveValuesPreview liveValuesPreview
}}</pre> }}</pre>
</Card> </Card>
<Card title="getValues / submit 输出(valueFormat 后)"> <Card title="getValues / submit 输出(codec.encode 后)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{ <pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
transformedValuesPreview transformedValuesPreview
}}</pre> }}</pre>

View File

@@ -207,7 +207,7 @@ Create the form through `useVbenForm`:
## Typed Values and Slots ## Typed Values and Slots
Declare the value shape once with `useVbenForm<TValues>`. The same type flows through value APIs, callbacks, selectors, and field/default/action slots: Use `useVbenForm<TFormValues, TSubmitValues>` to declare component-facing form values and submission values separately. Schema, slots, selectors, and `setValues` use `TFormValues`; `getValues`, submit, and the first `handleSubmit` argument use `TSubmitValues`. Pass one generic when both shapes are identical.
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
@@ -254,30 +254,47 @@ async function fillForm() {
Named field slots expose `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`. The default slot exposes `shapes`, `values`, and `formApi`; action slots expose `values` and `formApi`. Forms without an explicit `TValues` remain compatible with arbitrary slot names and broad props. Named field slots expose `field`, `componentField`, `modelValue`, `name`, `disabled`, `isInValid`, `values`, and `formApi`. The default slot exposes `shapes`, `values`, and `formApi`; action slots expose `values` and `formApi`. Forms without an explicit `TValues` remain compatible with arbitrary slot names and broad props.
## Value Formatting ## Form Codec
Use `schema.valueFormat` when the component value is convenient for the UI but the final payload returned by `getValues()` should use a different shape. Use the form-level `codec` when component values and the backend payload have different shapes. `encode` converts the complete `TFormValues` object to `TSubmitValues`; `decode` performs the inverse conversion. Multi-field splits and merges are atomic and do not depend on schema order or string-path writes.
- return a value to write back to the current field Define `codec` directly in the `useVbenForm` options. Annotate only the form-value input of `encode`; `TSubmitValues` is inferred from its return object and flows into `decode`, `getValues()`, and submit callbacks:
- call `setValue(key, nextValue)` to write derived fields
- return `undefined` to keep the original field removed after decomposition ```ts
const [Form, formApi] = useVbenForm({
codec: {
decode(values) {
return { period: [values.startTime, values.endTime] };
},
encode(values: Readonly<FormValues>) {
return {
endTime: values.period[1],
startTime: values.period[0],
};
},
},
schema,
});
```
<DemoPreview dir="demos/vben-form/value-format" /> <DemoPreview dir="demos/vben-form/value-format" />
`schema.valueFormat`, `fieldMappingTime`, and `arrayToStringFields` remain runtime-compatible but are deprecated. When a codec is configured it takes precedence and deprecated transforms are ignored.
## Key API Notes ## Key API Notes
- `useVbenForm` returns `[Form, formApi]` - `useVbenForm` returns `[Form, formApi]`
- `useVbenForm<TValues>` propagates values through APIs, callbacks, schema callbacks, and slots - `useVbenForm<TFormValues, TSubmitValues>` keeps component values and submission values distinct
- prefer `reset`, `submit`, `validateAndSubmit`, and `clearValidation` - prefer `reset`, `submit`, `validateAndSubmit`, and `clearValidation`
- `resetForm`, `submitForm`, `validateAndSubmitForm`, and `resetValidate` remain deprecated aliases that warn once in development - `resetForm`, `submitForm`, `validateAndSubmitForm`, and `resetValidate` remain deprecated aliases that warn once in development
- `clearValidation` invalidates in-flight async results before clearing errors - `clearValidation` invalidates in-flight async results before clearing errors
- `formApi.getFieldComponentRef()` and `formApi.getFocusedField()` are available in current versions - `formApi.getFieldComponentRef()` and `formApi.getFocusedField()` are available in current versions
- `handleValuesChange(values, fieldsChanged)` receives readonly raw form state before `valueFormat`, `fieldMappingTime`, or array-to-string conversion - `handleValuesChange(values, fieldsChanged)` receives readonly `TFormValues` before codec or legacy formatting
- its third `getFormattedValues` argument formats lazily, so raw-only change handlers avoid clone and transform work - its third `getFormattedValues` argument formats lazily, so raw-only change handlers avoid clone and transform work
- `getRawValues()` returns only an independent raw snapshot, `getValues()` returns only the formatted payload, and `getValueSnapshot()` returns both - `getRawValues()` returns only an independent raw snapshot, `getValues()` returns only the formatted payload, and `getValueSnapshot()` returns both
- `handleSubmit(values, rawValues)` receives the formatted payload and its corresponding raw snapshot - `handleSubmit(values, rawValues)` receives the formatted payload and its corresponding raw snapshot
- `fieldMappingTime` and `scrollToFirstError` are part of the current form props - `fieldMappingTime`, `arrayToStringFields`, and `schema.valueFormat` are deprecated compatibility options
- `schema.valueFormat` lets `getValues()` transform UI values into backend-friendly payloads - `codec.encode` defines the `getValues()` payload and `codec.decode` powers complete `setSubmitValues()` fills
- `formApi.form` is the stable `FormContextApi`; raw TanStack generics are intentionally not exposed - `formApi.form` is the stable `FormContextApi`; raw TanStack generics are intentionally not exposed
- prefer `formApi.form.useFieldValue`, `useFieldValues`, and `useFieldError` for fine-grained subscriptions; use `useValues` only when the whole form is required - prefer `formApi.form.useFieldValue`, `useFieldValues`, and `useFieldError` for fine-grained subscriptions; use `useValues` only when the whole form is required
- `useSelector` remains the compatibility selector for combined `{ values, errors, meta }` state - `useSelector` remains the compatibility selector for combined `{ values, errors, meta }` state

View File

@@ -23,7 +23,7 @@ The following Vben APIs remain supported:
- `useVbenForm(options)` returning `[Form, formApi]` - `useVbenForm(options)` returning `[Form, formApi]`
- existing `FormApi` methods for values, reset, validation, submission, schema updates, and component refs - existing `FormApi` methods for values, reset, validation, submission, schema updates, and component refs
- existing `FormSchema` fields, dependencies, `valueFormat`, and array schema structure - existing `FormSchema` fields, dependencies, deprecated `valueFormat`, and array schema structure
- application adapters and the re-exported `z` namespace - application adapters and the re-exported `z` namespace
- the existing `componentField` slot and binding shape - the existing `componentField` slot and binding shape
@@ -42,9 +42,9 @@ New code uses `reset`, `submit`, `validateAndSubmit`, and `clearValidation`. The
| `useFieldValue(fieldName)` | `FormContextApi` | Subscribes to one field value without reacting to unrelated fields. | | `useFieldValue(fieldName)` | `FormContextApi` | Subscribes to one field value without reacting to unrelated fields. |
| `useFieldValues(fieldNames)` | `FormContextApi` | Subscribes to a declared group of field values. | | `useFieldValues(fieldNames)` | `FormContextApi` | Subscribes to a declared group of field values. |
| `useFieldError(fieldName)` | `FormContextApi` | Subscribes to one field error without consuming the full error object. | | `useFieldError(fieldName)` | `FormContextApi` | Subscribes to one field error without consuming the full error object. |
| `getRawValues()` | `FormApi` | Returns an independent raw snapshot before field mapping and `valueFormat`. | | `getRawValues()` | `FormApi` | Returns an independent form-value snapshot before codec or legacy formatting. |
| `formatValues(rawValues)` | `FormApi` | Runs the unified formatting pipeline on a supplied raw snapshot. | | `formatValues(rawValues)` | `FormApi` | Runs the unified formatting pipeline on a supplied raw snapshot. |
| `getValueSnapshot()` | `FormApi` | Returns `{ rawValues, values }`, where `values` is the formatted payload. | | `getValueSnapshot()` | `FormApi` | Returns `{ rawValues, values }`, where `values` is `TSubmitValues`. |
| `asyncDebounceMs` | `FormFieldOptions` | Configures TanStack Field async validation debounce. | | `asyncDebounceMs` | `FormFieldOptions` | Configures TanStack Field async validation debounce. |
| `changeEventFallback` | `FormCommonConfig` / adapter config | Enables fallback for legacy components that emit `change` without `update:*`; defaults to `false`. | | `changeEventFallback` | `FormCommonConfig` / adapter config | Enables fallback for legacy components that emit `change` without `update:*`; defaults to `false`. |
@@ -60,7 +60,7 @@ New code uses `reset`, `submit`, `validateAndSubmit`, and `clearValidation`. The
| Change-event compatibility | `disabledOnChangeListener: false` enabled fallback | `changeEventFallback: true` enables fallback with positive semantics. | | Change-event compatibility | `disabledOnChangeListener: false` enabled fallback | `changeEventFallback: true` enables fallback with positive semantics. |
| Top-level render callbacks | `componentProps(values, actions, ctx)`, `help(values, actions, ctx)`, `renderComponentContent(values, actions, ctx)` | Receive only lightweight `FormSchemaContext`; value-dependent behavior moves to `dependencies.resolve`. | | Top-level render callbacks | `componentProps(values, actions, ctx)`, `help(values, actions, ctx)`, `renderComponentContent(values, actions, ctx)` | Receive only lightweight `FormSchemaContext`; value-dependent behavior moves to `dependencies.resolve`. |
| `validateAndSubmit()` | Repeated low-level validation/scroll handling and could validate again during submit | Delegates to canonical `validate()` and shared submission logic; invalid forms do not submit. | | `validateAndSubmit()` | Repeated low-level validation/scroll handling and could validate again during submit | Delegates to canonical `validate()` and shared submission logic; invalid forms do not submit. |
| `getValues()` | Implicitly returned transformed values | Still returns the formatted payload; use `getRawValues()` for raw state. | | `getValues()` | Implicitly returned transformed values | Returns codec-encoded `TSubmitValues`; without a codec it preserves legacy formatting. |
### Removed APIs ### Removed APIs
@@ -86,7 +86,7 @@ New code uses `reset`, `submit`, `validateAndSubmit`, and `clearValidation`. The
- Field components use fine-grained value/error selectors; full error aggregation is no longer on the normal input path. - Field components use fine-grained value/error selectors; full error aggregation is no longer on the normal input path.
- Async validators discard stale Promises through a Vben generation without reading private TanStack AbortController or meta fields. - Async validators discard stale Promises through a Vben generation without reading private TanStack AbortController or meta fields.
- New and legacy dependencies share one atomic executor, so stale async results cannot overwrite newer state. - New and legacy dependencies share one atomic executor, so stale async results cannot overwrite newer state.
- Formatting runs in a fixed array-to-string, range mapping, schema `valueFormat` order and performs one deep clone per formatted snapshot. - New code uses one form-level codec to encode the complete object atomically; legacy array-to-string, range mapping, and schema `valueFormat` remain compatible but deprecated.
## Typed Values and Slots ## Typed Values and Slots
@@ -198,7 +198,7 @@ Required markers are derived from whether the schema accepts `undefined`.
Do not read `_def`, `_zod.def`, or `typeName`. Use public `.unwrap()` APIs and public pipe inputs. Delegate intersection defaults to the Zod 4-compatible `zod-defaults` package. Do not read `_def`, `_zod.def`, or `typeName`. Use public `.unwrap()` APIs and public pipe inputs. Delegate intersection defaults to the Zod 4-compatible `zod-defaults` package.
Standard Schema validation does not write transform/coerce output back into TanStack Form state. Keep using `valueFormat` for submission payload conversion, or explicitly call `parseAsync` at the submission boundary when transformed schema output is required. Standard Schema validation does not write transform/coerce output back into TanStack Form state. Use the form-level codec for submission payload conversion, or explicitly call `parseAsync` inside the codec `encode` boundary when transformed schema output is required.
Also review these changes: Also review these changes:
@@ -220,7 +220,7 @@ The shadcn form primitives now use a Vben-owned field context. Labels, controls,
`dependencies.resolve(context)` is the recommended API: it evaluates once and atomically commits one dynamic-state patch, while stale async results are discarded as a unit. Legacy `if/show/disabled/required/rules/componentProps/trigger` callbacks remain supported through the same normalized executor, but are marked `@deprecated` and emit one development warning. Both APIs react only to declared `triggerFields`. `dependencies.resolve(context)` is the recommended API: it evaluates once and atomically commits one dynamic-state patch, while stale async results are discarded as a unit. Legacy `if/show/disabled/required/rules/componentProps/trigger` callbacks remain supported through the same normalized executor, but are marked `@deprecated` and emit one development warning. Both APIs react only to declared `triggerFields`.
`handleValuesChange(rawValues, fieldsChanged, getFormattedValues)` receives readonly raw values and formats only when its third argument is called. `getRawValues()` and `getValues()` each create only the requested snapshot; use `getValueSnapshot()` when both are required. `handleSubmit(values, rawValues)` receives both forms at submission. The formatter performs one deep clone, then applies array-to-string, range mapping, and schema `valueFormat` in order. Array fields keep using TanStack push/remove operations and stable row identity. `handleValuesChange(rawValues, fieldsChanged, getFormattedValues)` receives readonly `TFormValues` and runs codec or legacy formatting only when its third argument is called. `getRawValues()` returns form values and `getValues()` returns `TSubmitValues`; use `getValueSnapshot()` when both are required. `handleSubmit(values, rawValues)` receives both forms at submission. Legacy array-to-string, range mapping, and schema `valueFormat` remain compatible but deprecated. Array fields keep using TanStack push/remove operations and stable row identity.
## Test and Acceptance Matrix ## Test and Acceptance Matrix

View File

@@ -23,7 +23,7 @@ outline: deep
- `useVbenForm(options)` 仍返回 `[Form, formApi]` - `useVbenForm(options)` 仍返回 `[Form, formApi]`
- `FormApi` 的值、校验、提交、重置、schema 更新和组件引用能力 - `FormApi` 的值、校验、提交、重置、schema 更新和组件引用能力
- `FormSchema` 的 `fieldName`、`component`、`componentProps`、`rules`、`dependencies`、`defaultValue`、`valueFormat` 和数组字段结构 - `FormSchema` 的 `fieldName`、`component`、`componentProps`、`rules`、`dependencies`、`defaultValue`、已弃用的 `valueFormat` 和数组字段结构
- `dependencies.triggerFields` 与回调参数 - `dependencies.triggerFields` 与回调参数
- 组件适配器和 `z` 重导出路径 - 组件适配器和 `z` 重导出路径
- 自定义 slot 中原有的 `componentField` 绑定对象 - 自定义 slot 中原有的 `componentField` 绑定对象
@@ -43,9 +43,9 @@ outline: deep
| `useFieldValue(fieldName)` | `FormContextApi` | 订阅单字段值,避免无关字段变化触发组件更新。 | | `useFieldValue(fieldName)` | `FormContextApi` | 订阅单字段值,避免无关字段变化触发组件更新。 |
| `useFieldValues(fieldNames)` | `FormContextApi` | 订阅一组字段值,主要用于声明式依赖计算。 | | `useFieldValues(fieldNames)` | `FormContextApi` | 订阅一组字段值,主要用于声明式依赖计算。 |
| `useFieldError(fieldName)` | `FormContextApi` | 订阅单字段错误,不再依赖全量错误对象。 | | `useFieldError(fieldName)` | `FormContextApi` | 订阅单字段错误,不再依赖全量错误对象。 |
| `getRawValues()` | `FormApi` | 返回未执行字段映射与 `valueFormat` 的独立原始值快照。 | | `getRawValues()` | `FormApi` | 返回未执行 codec 或旧格式化管道的独立表单值快照。 |
| `formatValues(rawValues)` | `FormApi` | 对指定原始值执行统一格式化流水线。 | | `formatValues(rawValues)` | `FormApi` | 对指定原始值执行统一格式化流水线。 |
| `getValueSnapshot()` | `FormApi` | 同时返回 `{ rawValues, values }`,其中 `values` 为格式化结果。 | | `getValueSnapshot()` | `FormApi` | 同时返回 `{ rawValues, values }`,其中 `values` 为 `TSubmitValues`。 |
| `asyncDebounceMs` | `FormFieldOptions` | 设置 TanStack Field 异步校验防抖时间。 | | `asyncDebounceMs` | `FormFieldOptions` | 设置 TanStack Field 异步校验防抖时间。 |
| `changeEventFallback` | `FormCommonConfig` / adapter config | 为只发送 `change`、不发送 `update:*` 的旧组件启用事件回退,默认 `false`。 | | `changeEventFallback` | `FormCommonConfig` / adapter config | 为只发送 `change`、不发送 `update:*` 的旧组件启用事件回退,默认 `false`。 |
@@ -61,7 +61,7 @@ outline: deep
| change 事件兼容 | `disabledOnChangeListener: false` 表示启用 | `changeEventFallback: true` 表示启用,改为正向语义。 | | change 事件兼容 | `disabledOnChangeListener: false` 表示启用 | `changeEventFallback: true` 表示启用,改为正向语义。 |
| 顶层动态渲染回调 | `componentProps(values, actions, ctx)`、`help(values, actions, ctx)`、`renderComponentContent(values, actions, ctx)` | 仅接收轻量 `FormSchemaContext`。依赖表单值的动态逻辑迁移到 `dependencies.resolve`。 | | 顶层动态渲染回调 | `componentProps(values, actions, ctx)`、`help(values, actions, ctx)`、`renderComponentContent(values, actions, ctx)` | 仅接收轻量 `FormSchemaContext`。依赖表单值的动态逻辑迁移到 `dependencies.resolve`。 |
| `validateAndSubmit()` | 自行调用底层校验并重复实现错误滚动,提交阶段可能再次校验 | 委托统一 `validate()` 与共享提交逻辑,无效时不提交,错误滚动只有一个实现。 | | `validateAndSubmit()` | 自行调用底层校验并重复实现错误滚动,提交阶段可能再次校验 | 委托统一 `validate()` 与共享提交逻辑,无效时不提交,错误滚动只有一个实现。 |
| `getValues()` | 隐式完成所有字段转换 | 语义保持为“返回格式化值”;需要原始值时显式使用 `getRawValues()`。 | | `getValues()` | 隐式完成所有字段转换 | 返回 codec 编码后的 `TSubmitValues`;无 codec 时保持旧格式化行为。 |
### 删除的 API ### 删除的 API
@@ -87,7 +87,7 @@ outline: deep
- 字段组件改用细粒度 value/error selector;全量错误聚合退出普通输入热路径。 - 字段组件改用细粒度 value/error selector;全量错误聚合退出普通输入热路径。
- async validator 通过 Vben generation 丢弃过期 Promise,不读取 TanStack 私有 AbortController 或 meta 字段。 - async validator 通过 Vben generation 丢弃过期 Promise,不读取 TanStack 私有 AbortController 或 meta 字段。
- dependencies 新旧语法共用一个原子执行器,异步旧结果不会覆盖新状态。 - dependencies 新旧语法共用一个原子执行器,异步旧结果不会覆盖新状态。
- 值格式化按 array-to-string、时间范围映射、schema `valueFormat` 的固定顺序执行,并且每次格式化只深拷贝一次。 - 新代码使用表单级 codec 原子编码完整对象;旧 array-to-string、时间范围映射和 schema `valueFormat` 继续兼容但已弃用。
## 值类型与插槽类型 ## 值类型与插槽类型
@@ -212,7 +212,7 @@ Vben 表单按以下优先级生成初值:
不要读取 `_def`、`_zod.def` 或 `typeName`。公共包装器使用 `.unwrap()`;Zod 4 的 transform/pipe 使用公开的输入 schema。intersection 的默认值交给支持 Zod 4 的 `zod-defaults` 处理。 不要读取 `_def`、`_zod.def` 或 `typeName`。公共包装器使用 `.unwrap()`;Zod 4 的 transform/pipe 使用公开的输入 schema。intersection 的默认值交给支持 Zod 4 的 `zod-defaults` 处理。
TanStack Form 使用 Standard Schema 校验时不会自动把 transform/coerce 的输出写回当前表单 state。提交 payload 需要转换时,继续使用 `valueFormat`;如果必须提交 schema transform 后的结果,应在提交边界显式调用 `parseAsync`。 TanStack Form 使用 Standard Schema 校验时不会自动把 transform/coerce 的输出写回当前表单 state。提交 payload 需要转换时,使用表单级 codec;如果必须提交 schema transform 后的结果,应在 codec 的 `encode` 边界显式调用 `parseAsync`。
### 其他需要复核的 API ### 其他需要复核的 API
@@ -245,7 +245,7 @@ shadcn form primitive 使用 Vben 自有字段上下文,不再注入 vee 的 `
`dependencies.resolve(context)` 是推荐语法:一次求值并原子提交完整动态 patch,过期异步结果整体丢弃。旧的 `if/show/disabled/required/rules/componentProps/trigger` 语法仍兼容,但已标记为 `@deprecated` 并在开发环境首次使用时提示迁移;内部仍归一到同一个执行器。两种语法都只根据 `triggerFields` 重算,无关字段变化不会执行回调。 `dependencies.resolve(context)` 是推荐语法:一次求值并原子提交完整动态 patch,过期异步结果整体丢弃。旧的 `if/show/disabled/required/rules/componentProps/trigger` 语法仍兼容,但已标记为 `@deprecated` 并在开发环境首次使用时提示迁移;内部仍归一到同一个执行器。两种语法都只根据 `triggerFields` 重算,无关字段变化不会执行回调。
`handleValuesChange(rawValues, fieldsChanged, getFormattedValues)` 接收未格式化的只读当前值,第三个参数仅在调用时执行格式化。`getRawValues()` 和 `getValues()` 分别只生成原始或格式化快照;需要同时比较时使用 `getValueSnapshot()`。提交回调通过 `handleSubmit(values, rawValues)` 同时取得两种结构。格式化流水线只深拷贝一次,并按 array-to-string、时间范围映射、schema `valueFormat` 的顺序执行。数组字段继续使用 TanStack push/remove 操作和稳定行身份。 `handleValuesChange(rawValues, fieldsChanged, getFormattedValues)` 接收只读 `TFormValues`,第三个参数仅在调用时执行 codec 或旧格式化管道。`getRawValues()` 返回表单值,`getValues()` 返回 `TSubmitValues`;需要同时比较时使用 `getValueSnapshot()`。提交回调通过 `handleSubmit(values, rawValues)` 同时取得两种结构。旧 array-to-string、时间范围映射和 schema `valueFormat` 继续兼容但已弃用。数组字段继续使用 TanStack push/remove 操作和稳定行身份。
## 测试与验收 ## 测试与验收

View File

@@ -1,3 +1,5 @@
import type { BaseFormComponentType } from '../src/types';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FormApi } from '../src/form-api'; import { FormApi } from '../src/form-api';
@@ -106,6 +108,99 @@ describe('formApi', () => {
expect(formActions.values).toEqual(originalValuesSnapshot); expect(formActions.values).toEqual(originalValuesSnapshot);
}); });
it('should encode submissions and decode complete values with a codec', async () => {
interface FilterFormValues {
period: [number, number];
tags: string[];
}
interface FilterSubmitValues {
endTime: number;
startTime: number;
tags: string;
}
const setValues = vi.fn();
const codecFormApi = new FormApi<
FilterFormValues,
BaseFormComponentType,
Record<never, never>,
FilterSubmitValues
>({
codec: {
decode(values) {
return {
period: [values.startTime, values.endTime],
tags: values.tags.split(','),
};
},
encode(values) {
return {
endTime: values.period[1],
startTime: values.period[0],
tags: values.tags.join(','),
};
},
},
});
const formActions: any = {
meta: {},
setValues,
values: { period: [1, 2], tags: ['admin', 'user'] },
};
await codecFormApi.mount(formActions, new Map());
expect(await codecFormApi.getValues()).toEqual({
endTime: 2,
startTime: 1,
tags: 'admin,user',
});
expect(await codecFormApi.getValueSnapshot()).toEqual({
rawValues: { period: [1, 2], tags: ['admin', 'user'] },
values: { endTime: 2, startTime: 1, tags: 'admin,user' },
});
await codecFormApi.setSubmitValues(
{ endTime: 4, startTime: 3, tags: 'editor' },
false,
);
expect(setValues).toHaveBeenCalledWith(
{ period: [3, 4], tags: ['editor'] },
false,
);
});
it('should scan deprecated schema transforms once for unchanged state', async () => {
const getChildren = vi.fn(() => []);
const schema = {
component: 'text',
fieldName: 'name',
get children() {
return getChildren();
},
} as any;
const codecFormApi = new FormApi({
codec: {
decode: (values) => values,
encode: (values) => values,
},
schema: [schema],
});
const formActions: any = {
meta: {},
values: { name: 'Ada' },
};
await codecFormApi.mount(formActions, new Map());
expect(getChildren).toHaveBeenCalledTimes(1);
await codecFormApi.getValues();
await codecFormApi.getValues();
expect(getChildren).toHaveBeenCalledTimes(1);
});
it('should format child schema values inside array fields', async () => { it('should format child schema values inside array fields', async () => {
formApi.setState({ formApi.setState({
schema: [ schema: [

View File

@@ -0,0 +1,81 @@
import type { FormCodec } from '../src/types';
import { describe, expect, expectTypeOf, it } from 'vitest';
import {
decodeFormValues,
encodeFormValues,
FormCodecError,
} from '../src/form-codec';
interface FilterFormValues {
period: [number, number];
tags: string[];
}
interface FilterSubmitValues {
endTime: number;
startTime: number;
tags: string;
}
const filterCodec: FormCodec<FilterFormValues, FilterSubmitValues> = {
decode(values) {
return {
period: [values.startTime, values.endTime],
tags: values.tags ? values.tags.split(',') : [],
};
},
encode(values) {
return {
endTime: values.period[1],
startTime: values.period[0],
tags: values.tags.join(','),
};
},
};
describe('form codec', () => {
it('encodes and decodes complete form values', () => {
const submitValues = encodeFormValues(filterCodec, {
period: [1, 2],
tags: ['admin', 'user'],
});
expect(submitValues).toEqual({
endTime: 2,
startTime: 1,
tags: 'admin,user',
});
expect(decodeFormValues(filterCodec, submitValues)).toEqual({
period: [1, 2],
tags: ['admin', 'user'],
});
expectTypeOf(submitValues).toEqualTypeOf<FilterSubmitValues>();
});
it('reports the failed codec phase without mutating inputs', () => {
const values = Object.freeze({ period: [1, 2], tags: ['admin'] }) as {
period: [number, number];
tags: string[];
};
const codec: FormCodec<FilterFormValues, FilterSubmitValues> = {
decode: filterCodec.decode,
encode() {
throw new Error('broken encoder');
},
};
expect(() => encodeFormValues(codec, values)).toThrowError(FormCodecError);
let codecError: unknown;
try {
encodeFormValues(codec, values);
} catch (error) {
codecError = error;
}
expect(codecError).toBeInstanceOf(FormCodecError);
expect(codecError).toMatchObject({ phase: 'encode' });
expect(values).toEqual({ period: [1, 2], tags: ['admin'] });
});
});

View File

@@ -1,3 +1,5 @@
import type { BaseFormComponentType } from '../src/types';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { setupVbenForm } from '../src/config'; import { setupVbenForm } from '../src/config';
@@ -14,6 +16,83 @@ afterEach(() => {
}); });
describe('form api compatibility', () => { describe('form api compatibility', () => {
it('keeps deprecated value transforms and warns once per API', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const formApi = new FormApi({
arrayToStringFields: ['tags'],
fieldMappingTime: [['period', ['startTime', 'endTime'], null]],
schema: [
{
component: 'input',
fieldName: 'name',
valueFormat: (value: string) => value.trim(),
},
],
});
const form = {
meta: {},
values: {
name: ' Ada ',
period: [1, 2],
tags: ['admin', 'user'],
},
} as any;
formApi.mount(form);
expect(await formApi.getValues()).toEqual({
endTime: 2,
name: 'Ada',
startTime: 1,
tags: 'admin,user',
});
expect(warning).toHaveBeenCalledTimes(3);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `schema.valueFormat` is deprecated. Use the form-level `codec` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `fieldMappingTime` is deprecated. Use the form-level `codec` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `arrayToStringFields` is deprecated. Use the form-level `codec` instead.',
);
});
it('prefers the codec when deprecated transforms are also configured', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const formApi = new FormApi<
{ name: string },
BaseFormComponentType,
Record<never, never>,
{ normalizedName: string }
>({
codec: {
decode(values) {
return { name: values.normalizedName };
},
encode(values) {
return { normalizedName: values.name.toUpperCase() };
},
},
schema: [
{
component: 'input',
fieldName: 'name',
valueFormat: () => 'legacy',
},
],
});
const form = { meta: {}, values: { name: 'Ada' } } as any;
formApi.mount(form);
expect(await formApi.getValues()).toEqual({ normalizedName: 'ADA' });
expect(warning).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'[Vben Form] The form `codec` takes precedence over deprecated `valueFormat`, `fieldMappingTime`, and `arrayToStringFields` options.',
);
});
it('forwards defineRules and warns only once in development', async () => { it('forwards defineRules and warns only once in development', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const legacyRule = () => 'legacy error'; const legacyRule = () => 'legacy error';

View File

@@ -653,4 +653,181 @@ describe('useVbenForm integration', () => {
expect(formApi.form.getFieldError('username')).toBeUndefined(); expect(formApi.form.getFieldError('username')).toBeUndefined();
}); });
it('passes formatted values and raw values to handleSubmit callback', async () => {
const handleSubmit = vi.fn();
const [Form, formApi] = useVbenForm({
codec: {
decode(values: Readonly<{ normalizedName: string }>) {
return { name: values.normalizedName };
},
encode(values: Readonly<{ name: string }>) {
return { normalizedName: values.name.trim().toUpperCase() };
},
},
handleSubmit,
schema: [
{
component: TestInput,
defaultValue: '',
fieldName: 'name',
rules: 'required',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await formApi.setFieldValue('name', ' test ');
await formApi.validateAndSubmit();
await flushPromises();
expect(handleSubmit).toHaveBeenCalledOnce();
expect(handleSubmit).toHaveBeenCalledWith(
{ normalizedName: 'TEST' },
{ name: ' test ' },
);
});
it('does not expose raw values through native form submit events', async () => {
const onSubmit = vi.fn();
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
defaultValue: '',
fieldName: 'name',
rules: 'required',
valueFormat: (value) => value.trim().toUpperCase(),
},
],
});
const wrapper = mount(Form, { attrs: { onSubmit } });
wrappers.push(wrapper);
await flushPromises();
await formApi.setFieldValue('name', ' raw ');
await wrapper.get('form').trigger('submit');
await flushPromises();
expect(onSubmit).toHaveBeenCalledOnce();
expect(onSubmit).toHaveBeenCalledWith(undefined);
});
it('calls handleReset with formatted values on reset button click', async () => {
const handleReset = vi.fn();
const [Form, formApi] = useVbenForm({
codec: {
decode(values: Readonly<{ normalizedName: string }>) {
return { name: values.normalizedName };
},
encode(values: Readonly<{ name: string }>) {
return { normalizedName: values.name.toUpperCase() };
},
},
handleReset,
schema: [
{
component: TestInput,
defaultValue: 'hello',
fieldName: 'name',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(await formApi.getValues()).toEqual({ normalizedName: 'HELLO' });
const resetButton = wrapper.findAll('button')[0];
expect(resetButton).toBeDefined();
if (!resetButton) return;
await resetButton.trigger('click');
await flushPromises();
expect(handleReset).toHaveBeenCalledOnce();
expect(handleReset).toHaveBeenCalledWith({ normalizedName: 'HELLO' });
});
it('setValues with multiple fields emits only the final values', async () => {
const handleValuesChange = vi.fn();
const [Form, formApi] = useVbenForm({
handleValuesChange,
schema: [
{ component: TestInput, fieldName: 'first' },
{ component: TestInput, fieldName: 'second' },
{ component: TestInput, fieldName: 'third' },
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
const initialCallCount = handleValuesChange.mock.calls.length;
await formApi.setValues({ first: 'a', second: 'b', third: 'c' });
await flushPromises();
const calls = handleValuesChange.mock.calls.slice(initialCallCount);
expect(calls).toHaveLength(1);
expect(calls[0]?.[0]).toEqual({
first: 'a',
second: 'b',
third: 'c',
});
});
it('retains initial values for unspecified fields on partial reset', async () => {
const [Form, formApi] = useVbenForm({
schema: [
{ component: TestInput, defaultValue: 'original', fieldName: 'name' },
{ component: TestInput, defaultValue: 'keep', fieldName: 'alias' },
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await formApi.setValues({ alias: 'changed', name: 'changed' });
await formApi.reset({ values: { name: 'reset' } });
await flushPromises();
expect(await formApi.getValues()).toEqual({
alias: 'keep',
name: 'reset',
});
});
it('applies valueFormat consistently across value APIs', async () => {
const handleSubmit = vi.fn();
const [Form, formApi] = useVbenForm({
handleSubmit,
schema: [
{
component: TestInput,
fieldName: 'name',
valueFormat: (value) => (value ? value.trim().toUpperCase() : ''),
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await formApi.setFieldValue('name', ' hello ');
await flushPromises();
expect(await formApi.getValues()).toEqual({ name: 'HELLO' });
expect(await formApi.getValueSnapshot()).toEqual({
rawValues: { name: ' hello ' },
values: { name: 'HELLO' },
});
await formApi.validateAndSubmit();
await flushPromises();
expect(handleSubmit).toHaveBeenCalledWith(
{ name: 'HELLO' },
{ name: ' hello ' },
);
});
}); });

View File

@@ -6,6 +6,7 @@ import type {
FormFieldOptions, FormFieldOptions,
FormItemDependencies, FormItemDependencies,
FormValidationResult, FormValidationResult,
FormValueSnapshot,
VbenFormAdapterOptions, VbenFormAdapterOptions,
VbenFormProps, VbenFormProps,
} from '../src/types'; } from '../src/types';
@@ -22,6 +23,12 @@ interface AccountFormValues {
roles: string[]; roles: string[];
} }
interface AccountSubmitValues {
email: string;
nickname: string;
roles: string;
}
describe('form public types', () => { describe('form public types', () => {
it('keeps the compatibility alias and stable method signatures', () => { it('keeps the compatibility alias and stable method signatures', () => {
expectTypeOf<FormActions>().toEqualTypeOf<FormContextApi>(); expectTypeOf<FormActions>().toEqualTypeOf<FormContextApi>();
@@ -142,6 +149,96 @@ describe('form public types', () => {
>().toEqualTypeOf<AccountFormValues>(); >().toEqualTypeOf<AccountFormValues>();
}); });
it('keeps form and submit values distinct with a codec', () => {
const options: VbenFormProps<
BaseFormComponentType,
Record<never, never>,
AccountFormValues,
AccountSubmitValues
> = {
codec: {
decode(values) {
return {
email: values.email,
profile: { nickname: values.nickname },
roles: values.roles.split(','),
};
},
encode(values) {
return {
email: values.email,
nickname: values.profile.nickname,
roles: values.roles.join(','),
};
},
},
handleSubmit(values, rawValues) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(rawValues).toEqualTypeOf<Readonly<AccountFormValues>>();
},
handleReset(values) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
},
handleValuesChange(values, _fieldsChanged, getFormattedValues) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>();
expectTypeOf(getFormattedValues()).toEqualTypeOf<AccountSubmitValues>();
},
schema: [],
};
const [, formApi] = useVbenForm<
AccountFormValues,
BaseFormComponentType,
Record<never, never>,
AccountSubmitValues
>(options);
expectTypeOf(
formApi.getValues(),
).resolves.toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(
formApi.getRawValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
expectTypeOf(formApi.getValueSnapshot()).resolves.toEqualTypeOf<
FormValueSnapshot<AccountFormValues, AccountSubmitValues>
>();
expectTypeOf(formApi.setSubmitValues)
.parameter(0)
.toEqualTypeOf<AccountSubmitValues>();
});
it('infers submit values from an inline codec', () => {
const [, formApi] = useVbenForm({
codec: {
decode(values) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountSubmitValues>>();
return {
email: values.email,
profile: { nickname: values.nickname },
roles: values.roles.split(','),
};
},
encode(values: Readonly<AccountFormValues>) {
return {
email: values.email,
nickname: values.profile.nickname,
roles: values.roles.join(','),
};
},
},
handleReset(values) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
},
schema: [],
});
expectTypeOf(
formApi.getValues(),
).resolves.toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(
formApi.getRawValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
});
it('exposes canonical names alongside deprecated aliases', () => { it('exposes canonical names alongside deprecated aliases', () => {
expectTypeOf<FormContextApi['reset']>().toEqualTypeOf< expectTypeOf<FormContextApi['reset']>().toEqualTypeOf<
FormContextApi['resetForm'] FormContextApi['resetForm']

View File

@@ -29,14 +29,16 @@ import {
import { warnDeprecatedOnce } from './deprecation'; import { warnDeprecatedOnce } from './deprecation';
import { resolveFieldNamePath } from './field-name'; import { resolveFieldNamePath } from './field-name';
import { decodeFormValues, encodeFormValues } from './form-codec';
import { updateFormSchemaList } from './form-render/schema'; import { updateFormSchemaList } from './form-render/schema';
import { formatFormValues } from './form-value-transform'; import { formatFormValues } from './form-value-transform';
type FormApiProps< type FormApiProps<
TValues extends FormValues, TFormValues extends FormValues,
T extends BaseFormComponentType, T extends BaseFormComponentType,
P extends Record<string, any>, P extends Record<string, any>,
> = VbenFormProps<T, P, TValues>; TSubmitValues extends FormValues,
> = VbenFormProps<T, P, TFormValues, TSubmitValues>;
type FormApiSchema< type FormApiSchema<
TValues extends FormValues, TValues extends FormValues,
@@ -45,10 +47,11 @@ type FormApiSchema<
> = FormSchema<T, P, TValues>; > = FormSchema<T, P, TValues>;
function getDefaultState< function getDefaultState<
TValues extends FormValues, TFormValues extends FormValues,
T extends BaseFormComponentType, T extends BaseFormComponentType,
P extends Record<string, any>, P extends Record<string, any>,
>(): FormApiProps<TValues, T, P> { TSubmitValues extends FormValues,
>(): FormApiProps<TFormValues, T, P, TSubmitValues> {
return { return {
actionWrapperClass: '', actionWrapperClass: '',
collapsed: false, collapsed: false,
@@ -73,18 +76,19 @@ function getDefaultState<
} }
export class FormApi< export class FormApi<
TValues extends FormValues = FormValues, TFormValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TFormValues,
> { > {
// private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>; // private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
public form = {} as FormActions<TValues>; public form = {} as FormActions<TFormValues>;
isMounted = false; isMounted = false;
public state: FormApiProps<TValues, T, P> | null = null; public state: FormApiProps<TFormValues, T, P, TSubmitValues> | null = null;
stateHandler: StateHandler; stateHandler: StateHandler;
public store: Store<FormApiProps<TValues, T, P>>; public store: Store<FormApiProps<TFormValues, T, P, TSubmitValues>>;
/** /**
* 组件实例映射 * 组件实例映射
@@ -92,16 +96,22 @@ export class FormApi<
private componentRefMap: Map<string, unknown> = new Map(); private componentRefMap: Map<string, unknown> = new Map();
// 最后一次点击提交时的表单值 // 最后一次点击提交时的表单值
private latestSubmissionValues: null | Partial<TValues> = null; private latestSubmissionValues: null | Partial<TSubmitValues> = null;
private prevState: FormApiProps<TValues, T, P> | null = null; private legacyTransformWarningState: null | Pick<
FormApiProps<TFormValues, T, P, TSubmitValues>,
'arrayToStringFields' | 'codec' | 'fieldMappingTime' | 'schema'
> = null;
constructor(options: FormApiProps<TValues, T, P> = {}) { private prevState: FormApiProps<TFormValues, T, P, TSubmitValues> | null =
null;
constructor(options: FormApiProps<TFormValues, T, P, TSubmitValues> = {}) {
const { ...storeState } = options; const { ...storeState } = options;
const defaultState = getDefaultState<TValues, T, P>(); const defaultState = getDefaultState<TFormValues, T, P, TSubmitValues>();
this.store = new Store<FormApiProps<TValues, T, P>>({ this.store = new Store<FormApiProps<TFormValues, T, P, TSubmitValues>>({
...defaultState, ...defaultState,
...storeState, ...storeState,
}); });
@@ -118,21 +128,31 @@ export class FormApi<
} }
async clearValidation( async clearValidation(
fieldNames?: FormFieldName<TValues> | FormFieldName<TValues>[], fieldNames?: FormFieldName<TFormValues> | FormFieldName<TFormValues>[],
) { ) {
const form = await this.getForm(); const form = await this.getForm();
form.clearValidation(fieldNames); form.clearValidation(fieldNames);
} }
formatValues<TResult extends FormValues = TValues>( formatValues(rawValues: Readonly<TFormValues>): TSubmitValues;
/** @deprecated Declare the submit type on `useVbenForm` instead. */
formatValues<TResult extends FormValues>(
rawValues: Readonly<FormValues>, rawValues: Readonly<FormValues>,
) { ): TResult;
formatValues(rawValues: Readonly<FormValues>): FormValues {
this.warnLegacyValueTransforms();
if (this.state?.codec) {
return encodeFormValues(
this.state.codec,
rawValues as Readonly<TFormValues>,
);
}
return formatFormValues( return formatFormValues(
toRaw(rawValues), toRaw(rawValues),
this.state?.schema ?? [], this.state?.schema ?? [],
this.state?.fieldMappingTime, this.state?.fieldMappingTime,
this.state?.arrayToStringFields, this.state?.arrayToStringFields,
) as TResult; );
} }
/** /**
@@ -196,41 +216,52 @@ export class FormApi<
return this.latestSubmissionValues || {}; return this.latestSubmissionValues || {};
} }
async getRawValues<TResult extends FormValues = TValues>() { async getRawValues(): Promise<TFormValues>;
/** @deprecated Declare the form value type on `useVbenForm` instead. */
async getRawValues<TResult extends FormValues>(): Promise<TResult>;
async getRawValues(): Promise<FormValues> {
const form = await this.getForm(); const form = await this.getForm();
return cloneDeep(toRaw(form.values ?? {})) as unknown as TResult; return cloneDeep(toRaw(form.values ?? {}));
} }
getState() { getState() {
return this.state; return this.state;
} }
async getValues<TResult extends FormValues = TValues>() { async getValues(): Promise<TSubmitValues>;
/** @deprecated Declare the submit type on `useVbenForm` instead. */
async getValues<TResult extends FormValues>(): Promise<TResult>;
async getValues(): Promise<FormValues> {
const form = await this.getForm(); const form = await this.getForm();
return this.formatValues<TResult>(toRaw(form.values ?? {})); return this.formatValues(toRaw(form.values ?? {}));
} }
async getValueSnapshot<TResult extends FormValues = TValues>(): Promise< async getValueSnapshot(): Promise<
FormValueSnapshot<TFormValues, TSubmitValues>
>;
/** @deprecated Declare form and submit value types on `useVbenForm`. */
async getValueSnapshot<TResult extends FormValues>(): Promise<
FormValueSnapshot<TResult> FormValueSnapshot<TResult>
> { >;
const rawValues = await this.getRawValues<TResult>(); async getValueSnapshot(): Promise<FormValueSnapshot> {
const rawValues = await this.getRawValues();
return { return {
rawValues, rawValues,
values: this.formatValues<TResult>(rawValues), values: this.formatValues(rawValues),
}; };
} }
async isFieldValid(fieldName: FormFieldName<TValues>) { async isFieldValid(fieldName: FormFieldName<TFormValues>) {
const form = await this.getForm(); const form = await this.getForm();
return form.isFieldValid(fieldName); return form.isFieldValid(fieldName);
} }
merge(formApi: FormApi<any, any, any>) { merge(formApi: FormApi<any, any, any, any>) {
const chain = [this, formApi]; const chain = [this, formApi];
const proxy = new Proxy(formApi, { const proxy = new Proxy(formApi, {
get(target: any, prop: any) { get(target: any, prop: any) {
if (prop === 'merge') { if (prop === 'merge') {
return (nextFormApi: FormApi<any, any, any>) => { return (nextFormApi: FormApi<any, any, any, any>) => {
chain.push(nextFormApi); chain.push(nextFormApi);
return proxy; return proxy;
}; };
@@ -266,7 +297,7 @@ export class FormApi<
} }
mount( mount(
formActions: FormActions<TValues>, formActions: FormActions<TFormValues>,
componentRefMap?: Map<string, unknown>, componentRefMap?: Map<string, unknown>,
) { ) {
if (!this.isMounted) { if (!this.isMounted) {
@@ -275,9 +306,7 @@ export class FormApi<
const initialValues = this.form.values const initialValues = this.form.values
? this.formatValues(toRaw(this.form.values)) ? this.formatValues(toRaw(this.form.values))
: {}; : {};
this.setLatestSubmissionValues({ this.setLatestSubmissionValues(initialValues);
...initialValues,
} as Partial<TValues>);
this.componentRefMap = this.componentRefMap =
componentRefMap ?? this.componentRefMap ?? new Map(); componentRefMap ?? this.componentRefMap ?? new Map();
this.isMounted = true; this.isMounted = true;
@@ -302,13 +331,16 @@ export class FormApi<
/** /**
* 重置表单 * 重置表单
*/ */
async reset(state?: FormResetState<TValues>, opts?: FormResetOptions) { async reset(state?: FormResetState<TFormValues>, opts?: FormResetOptions) {
const form = await this.getForm(); const form = await this.getForm();
return form.reset(state, opts); return form.reset(state, opts);
} }
/** @deprecated Use `reset` instead. */ /** @deprecated Use `reset` instead. */
async resetForm(state?: FormResetState<TValues>, opts?: FormResetOptions) { async resetForm(
state?: FormResetState<TFormValues>,
opts?: FormResetOptions,
) {
warnDeprecatedOnce( warnDeprecatedOnce(
'form-api-reset-form', 'form-api-reset-form',
'[Vben Form] `formApi.resetForm()` is deprecated. Use `formApi.reset()` instead.', '[Vben Form] `formApi.resetForm()` is deprecated. Use `formApi.reset()` instead.',
@@ -359,27 +391,27 @@ export class FormApi<
} }
} }
async setFieldValue<TFieldName extends FormFieldName<TValues>>( async setFieldValue<TFieldName extends FormFieldName<TFormValues>>(
field: TFieldName, field: TFieldName,
value: FormFieldValue<TValues, NoInfer<TFieldName>>, value: FormFieldValue<TFormValues, NoInfer<TFieldName>>,
shouldValidate?: boolean, shouldValidate?: boolean,
) { ) {
const form = await this.getForm(); const form = await this.getForm();
await form.setFieldValue(field, value, shouldValidate); await form.setFieldValue(field, value, shouldValidate);
} }
setLatestSubmissionValues(values: null | Partial<TValues>) { setLatestSubmissionValues(values: null | Partial<TSubmitValues>) {
this.latestSubmissionValues = { this.latestSubmissionValues = {
...toRaw(values), ...toRaw(values),
} as Partial<TValues>; } as Partial<TSubmitValues>;
} }
setState( setState(
stateOrFn: stateOrFn:
| (( | ((
prev: FormApiProps<TValues, T, P>, prev: FormApiProps<TFormValues, T, P, TSubmitValues>,
) => Partial<FormApiProps<TValues, T, P>>) ) => Partial<FormApiProps<TFormValues, T, P, TSubmitValues>>)
| Partial<FormApiProps<TValues, T, P>>, | Partial<FormApiProps<TFormValues, T, P, TSubmitValues>>,
) { ) {
if (isFunction(stateOrFn)) { if (isFunction(stateOrFn)) {
this.store.setState((prev) => { this.store.setState((prev) => {
@@ -390,6 +422,21 @@ export class FormApi<
} }
} }
async setSubmitValues(
values: TSubmitValues,
filterFields: boolean = true,
shouldValidate: boolean = false,
) {
const codec = this.state?.codec;
if (!codec) {
throw new Error(
'[Vben Form] `setSubmitValues()` requires a form `codec`.',
);
}
const formValues = decodeFormValues(codec, values);
await this.setValues(formValues, filterFields, shouldValidate);
}
/** /**
* 设置表单值 * 设置表单值
* @param fields record * @param fields record
@@ -397,7 +444,7 @@ export class FormApi<
* @param shouldValidate * @param shouldValidate
*/ */
async setValues( async setValues(
fields: Partial<TValues>, fields: Partial<TFormValues>,
filterFields: boolean = true, filterFields: boolean = true,
shouldValidate: boolean = false, shouldValidate: boolean = false,
) { ) {
@@ -445,8 +492,8 @@ export class FormApi<
} }
return result; return result;
}; };
const filteredFields = filterValue(fields) as Partial<TValues>; const filteredFields = filterValue(fields) as Partial<TFormValues>;
form.setValues(filteredFields as Partial<TValues>, shouldValidate); form.setValues(filteredFields as Partial<TFormValues>, shouldValidate);
} }
async submit(e?: Event) { async submit(e?: Event) {
@@ -475,8 +522,8 @@ export class FormApi<
this.stateHandler.reset(); this.stateHandler.reset();
} }
updateSchema(schema: Partial<FormApiSchema<TValues, T, P>>[]) { updateSchema(schema: Partial<FormApiSchema<TFormValues, T, P>>[]) {
const updated: Partial<FormApiSchema<TValues, T, P>>[] = [...schema]; const updated: Partial<FormApiSchema<TFormValues, T, P>>[] = [...schema];
const hasField = updated.every( const hasField = updated.every(
(item) => Reflect.has(item, 'fieldName') && item.fieldName, (item) => Reflect.has(item, 'fieldName') && item.fieldName,
); );
@@ -523,7 +570,7 @@ export class FormApi<
return this.validateAndSubmit(); return this.validateAndSubmit();
} }
async validateField(fieldName: FormFieldName<TValues>) { async validateField(fieldName: FormFieldName<TFormValues>) {
const form = await this.getForm(); const form = await this.getForm();
const validateResult = await form.validateField(fieldName); const validateResult = await form.validateField(fieldName);
@@ -568,9 +615,74 @@ export class FormApi<
for (const schema of deletedSchema) { for (const schema of deletedSchema) {
this.form?.setFieldValue?.( this.form?.setFieldValue?.(
schema.fieldName, schema.fieldName,
undefined as FormFieldValue<TValues, string>, undefined as FormFieldValue<TFormValues, string>,
); );
} }
} }
} }
private warnLegacyValueTransforms() {
const warningState = {
arrayToStringFields: this.state?.arrayToStringFields,
codec: this.state?.codec,
fieldMappingTime: this.state?.fieldMappingTime,
schema: this.state?.schema ?? [],
};
const previousState = this.legacyTransformWarningState;
if (
previousState &&
previousState.arrayToStringFields === warningState.arrayToStringFields &&
previousState.codec === warningState.codec &&
previousState.fieldMappingTime === warningState.fieldMappingTime &&
previousState.schema === warningState.schema
) {
return;
}
this.legacyTransformWarningState = warningState;
const hasValueFormat = (
items: FormApiSchema<TFormValues, T, P>[],
): boolean => {
return items.some((schema) => {
if (schema.valueFormat) {
return true;
}
const children = 'children' in schema ? schema.children : undefined;
return Array.isArray(children) && hasValueFormat(children);
});
};
const usesValueFormat = hasValueFormat(warningState.schema);
const usesFieldMappingTime =
(warningState.fieldMappingTime?.length ?? 0) > 0;
const usesArrayToStringFields =
(warningState.arrayToStringFields?.length ?? 0) > 0;
const usesLegacyTransform =
usesValueFormat || usesFieldMappingTime || usesArrayToStringFields;
if (warningState.codec && usesLegacyTransform) {
warnDeprecatedOnce(
'form-codec-legacy-transform-conflict',
'[Vben Form] The form `codec` takes precedence over deprecated `valueFormat`, `fieldMappingTime`, and `arrayToStringFields` options.',
);
return;
}
if (usesValueFormat) {
warnDeprecatedOnce(
'form-schema-value-format',
'[Vben Form] `schema.valueFormat` is deprecated. Use the form-level `codec` instead.',
);
}
if (usesFieldMappingTime) {
warnDeprecatedOnce(
'form-field-mapping-time',
'[Vben Form] `fieldMappingTime` is deprecated. Use the form-level `codec` instead.',
);
}
if (usesArrayToStringFields) {
warnDeprecatedOnce(
'form-array-to-string-fields',
'[Vben Form] `arrayToStringFields` is deprecated. Use the form-level `codec` instead.',
);
}
}
} }

View File

@@ -0,0 +1,40 @@
import type { FormCodec, FormValues } from './types';
export type FormCodecPhase = 'decode' | 'encode';
export class FormCodecError extends Error {
override readonly cause: unknown;
readonly phase: FormCodecPhase;
constructor(phase: FormCodecPhase, cause: unknown) {
super(`[Vben Form] Failed to ${phase} form values.`);
this.name = 'FormCodecError';
this.cause = cause;
this.phase = phase;
}
}
export function decodeFormValues<
TFormValues extends FormValues,
TSubmitValues extends FormValues,
>(
codec: FormCodec<TFormValues, TSubmitValues>,
values: Readonly<TSubmitValues>,
) {
try {
return codec.decode(values);
} catch (error) {
throw new FormCodecError('decode', error);
}
}
export function encodeFormValues<
TFormValues extends FormValues,
TSubmitValues extends FormValues,
>(codec: FormCodec<TFormValues, TSubmitValues>, values: Readonly<TFormValues>) {
try {
return codec.encode(values);
} catch (error) {
throw new FormCodecError('encode', error);
}
}

View File

@@ -69,7 +69,7 @@ const formComponent = 'form';
const formComponentProps = computed(() => { const formComponentProps = computed(() => {
return props.form return props.form
? { ? {
onSubmit: props.form.handleSubmit((val) => emits('submit', val)), onSubmit: props.form.handleSubmit(() => emits('submit', undefined)),
} }
: { : {
onSubmit: (event: Event) => { onSubmit: (event: Event) => {

View File

@@ -10,6 +10,8 @@ import type {
import { computed, shallowRef } from 'vue'; import { computed, shallowRef } from 'vue';
import { mergeWithArrayOverride } from '@vben-core/shared/utils';
import { useForm } from '@tanstack/vue-form'; import { useForm } from '@tanstack/vue-form';
import { createRuntimeFieldComponent } from './form-runtime-field'; import { createRuntimeFieldComponent } from './form-runtime-field';
@@ -218,7 +220,19 @@ export function useFormRuntime<TValues extends FormValues>(
invalidateFieldValidation(fieldName); invalidateFieldValidation(fieldName);
} }
manualErrors.value = new Map(); manualErrors.value = new Map();
rawForm.reset(resetState?.values as TValues | undefined, options); const partialValues = resetState?.values;
let resetValues: TValues | undefined;
if (partialValues) {
resetValues = options?.force
? (partialValues as TValues)
: (mergeWithArrayOverride(
partialValues,
rawForm.options.defaultValues ?? defaultValues,
) as TValues);
}
rawForm.reset(resetValues, {
keepDefaultValues: options?.keepDefaultValues,
});
} }
async function submit() { async function submit() {
@@ -244,13 +258,13 @@ export function useFormRuntime<TValues extends FormValues>(
typeof fieldName typeof fieldName
>; >;
}, },
handleSubmit(callback) { handleSubmit(callback?) {
return async (event) => { return async (event?: Event) => {
event?.preventDefault(); event?.preventDefault();
event?.stopPropagation(); event?.stopPropagation();
const result = await validate(); const result = await validate();
if (result.valid) { if (result.valid) {
await callback(values.value); await callback?.(values.value as TValues);
} }
}; };
}, },

View File

@@ -1,13 +1,18 @@
export { setupVbenForm } from './config'; export { setupVbenForm } from './config';
export { FormCodecError } from './form-codec';
export type { FormCodecPhase } from './form-codec';
export type { export type {
BaseFormComponentType, BaseFormComponentType,
ExtendedFormApi, ExtendedFormApi,
FormActions, FormActions,
FormCodec,
FormContextApi, FormContextApi,
FormLayout, FormLayout,
FormSchemaContext, FormSchemaContext,
FormValues, FormValues,
FormValueSnapshot,
VbenFormActionSlotProps, VbenFormActionSlotProps,
VbenFormComponent, VbenFormComponent,
VbenFormDefaultSlotProps, VbenFormDefaultSlotProps,

View File

@@ -9,6 +9,16 @@ import type { FormApi } from './form-api';
export type FormValues = Record<string, any>; export type FormValues = Record<string, any>;
export interface FormCodec<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> {
/** 将提交值转换为表单组件值。 */
decode: (values: Readonly<TSubmitValues>) => TFormValues;
/** 将表单组件值转换为提交值。 */
encode: (values: Readonly<TFormValues>) => TSubmitValues;
}
export type FormFieldName<TValues extends FormValues = FormValues> = export type FormFieldName<TValues extends FormValues = FormValues> =
| Extract<keyof TValues, string> | Extract<keyof TValues, string>
| (Record<never, never> & string); | (Record<never, never> & string);
@@ -116,9 +126,12 @@ export interface FormValidationResult {
valid: boolean; valid: boolean;
} }
export interface FormValueSnapshot<TValues extends FormValues = FormValues> { export interface FormValueSnapshot<
rawValues: Readonly<TValues>; TFormValues extends FormValues = FormValues,
values: TValues; TSubmitValues extends FormValues = TFormValues,
> {
rawValues: Readonly<TFormValues>;
values: TSubmitValues;
} }
export interface FormResetState<TValues extends FormValues = FormValues> { export interface FormResetState<TValues extends FormValues = FormValues> {
@@ -141,7 +154,7 @@ export interface FormContextApi<TValues extends FormValues = FormValues> {
fieldName: TFieldName, fieldName: TFieldName,
) => FormFieldValue<TValues, TFieldName>; ) => FormFieldValue<TValues, TFieldName>;
handleSubmit: ( handleSubmit: (
callback: (values: TValues) => Promise<void> | void, callback?: (values: TValues) => Promise<void> | void,
) => (event?: Event) => Promise<void>; ) => (event?: Event) => Promise<void>;
isFieldValid: (fieldName: string) => boolean; isFieldValid: (fieldName: string) => boolean;
readonly meta: FormMeta; readonly meta: FormMeta;
@@ -205,8 +218,9 @@ export interface VbenFormActionSlotProps<
TValues extends FormValues = FormValues, TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TValues,
> { > {
formApi: ExtendedFormApi<TValues, T, P>; formApi: ExtendedFormApi<TValues, T, P, TSubmitValues>;
values: TValues; values: TValues;
} }
@@ -214,7 +228,8 @@ export interface VbenFormDefaultSlotProps<
TValues extends FormValues = FormValues, TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
> extends VbenFormActionSlotProps<TValues, T, P> { TSubmitValues extends FormValues = TValues,
> extends VbenFormActionSlotProps<TValues, T, P, TSubmitValues> {
shapes: FormShape[]; shapes: FormShape[];
} }
@@ -223,7 +238,8 @@ export interface VbenFormFieldSlotProps<
TFieldName extends KnownFormFieldName<TValues> = KnownFormFieldName<TValues>, TFieldName extends KnownFormFieldName<TValues> = KnownFormFieldName<TValues>,
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
> extends VbenFormActionSlotProps<TValues, T, P> { TSubmitValues extends FormValues = TValues,
> extends VbenFormActionSlotProps<TValues, T, P, TSubmitValues> {
componentField: FormComponentField<TValues[TFieldName], TFieldName>; componentField: FormComponentField<TValues[TFieldName], TFieldName>;
disabled: boolean; disabled: boolean;
field: FormRuntimeField<TValues[TFieldName]>; field: FormRuntimeField<TValues[TFieldName]>;
@@ -236,12 +252,19 @@ type VbenFormFieldSlots<
TValues extends FormValues, TValues extends FormValues,
T extends BaseFormComponentType, T extends BaseFormComponentType,
P extends Record<string, any>, P extends Record<string, any>,
TSubmitValues extends FormValues,
> = > =
string extends Extract<keyof TValues, string> string extends Extract<keyof TValues, string>
? Record<string, ((props: any) => any) | undefined> ? Record<string, ((props: any) => any) | undefined>
: { : {
[TFieldName in KnownFormFieldName<TValues>]?: ( [TFieldName in KnownFormFieldName<TValues>]?: (
props: VbenFormFieldSlotProps<TValues, TFieldName, T, P>, props: VbenFormFieldSlotProps<
TValues,
TFieldName,
T,
P,
TSubmitValues
>,
) => any; ) => any;
}; };
@@ -249,21 +272,33 @@ export type VbenFormSlots<
TValues extends FormValues = FormValues, TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
> = VbenFormFieldSlots<TValues, T, P> & { TSubmitValues extends FormValues = TValues,
default?: (props: VbenFormDefaultSlotProps<TValues, T, P>) => any; > = VbenFormFieldSlots<TValues, T, P, TSubmitValues> & {
'expand-after'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any; default?: (
'expand-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any; props: VbenFormDefaultSlotProps<TValues, T, P, TSubmitValues>,
'reset-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any; ) => any;
'submit-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any; 'expand-after'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
'expand-before'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
'reset-before'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
'submit-before'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
}; };
export type VbenFormComponent< export type VbenFormComponent<
TValues extends FormValues = FormValues, TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TValues,
> = new () => { > = new () => {
$props: VbenFormProps<T, P, TValues>; $props: VbenFormProps<T, P, TValues, TSubmitValues>;
$slots: VbenFormSlots<TValues, T, P>; $slots: VbenFormSlots<TValues, T, P, TSubmitValues>;
}; };
export interface FormSchemaContext<TValues extends FormValues = FormValues> { export interface FormSchemaContext<TValues extends FormValues = FormValues> {
@@ -512,6 +547,7 @@ type MappedComponentProps<P, TValues extends FormValues = FormValues> =
* 把一个字段拆分写入到其他字段,例如 `startTime` / `endTime` * 把一个字段拆分写入到其他字段,例如 `startTime` / `endTime`
* - 返回其他值:会将当前字段恢复/写回为该返回值 * - 返回其他值:会将当前字段恢复/写回为该返回值
* - `setValue` 回调签名为 `(key, nextValue) => void` * - `setValue` 回调签名为 `(key, nextValue) => void`
* @deprecated Use the form-level `codec` instead.
*/ */
export type FormValueFormat<TValues extends FormValues = FormValues> = ( export type FormValueFormat<TValues extends FormValues = FormValues> = (
value: any, value: any,
@@ -548,6 +584,7 @@ interface FormSchemaBody<TValues extends FormValues = FormValues> extends Omit<
* 获取表单值时格式化当前字段。 * 获取表单值时格式化当前字段。
* - 返回值不为 `undefined` 时,会回写到当前 fieldName * - 返回值不为 `undefined` 时,会回写到当前 fieldName
* - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段 * - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段
* @deprecated Use the form-level `codec` instead.
*/ */
valueFormat?: FormValueFormat<TValues>; valueFormat?: FormValueFormat<TValues>;
} }
@@ -637,15 +674,19 @@ export interface VbenFormFieldArrayProps<
showIndex?: boolean; showIndex?: boolean;
} }
export type HandleSubmitFn<TValues extends FormValues = FormValues> = ( export type HandleSubmitFn<
values: TValues, TFormValues extends FormValues = FormValues,
rawValues: Readonly<TValues>, TSubmitValues extends FormValues = TFormValues,
> = (
values: TSubmitValues,
rawValues: Readonly<TFormValues>,
) => Promise<void> | void; ) => Promise<void> | void;
export type HandleResetFn<TValues extends FormValues = FormValues> = ( export type HandleResetFn<TSubmitValues extends FormValues = FormValues> = (
values: TValues, values: TSubmitValues,
) => Promise<void> | void; ) => Promise<void> | void;
/** @deprecated Use the form-level `codec` instead. */
export type FieldMappingTimeItem = [ export type FieldMappingTimeItem = [
string, string,
[string, string], [string, string],
@@ -657,8 +698,10 @@ export type FieldMappingTimeItem = [
)?, )?,
]; ];
/** @deprecated Use the form-level `codec` instead. */
export type FieldMappingTime = FieldMappingTimeItem[]; export type FieldMappingTime = FieldMappingTimeItem[];
/** @deprecated Use the form-level `codec` instead. */
export type ArrayToStringFields = Array< export type ArrayToStringFields = Array<
| [string[], string?] // 嵌套数组格式,可选分隔符 | [string[], string?] // 嵌套数组格式,可选分隔符
| string // 单个字段,使用默认分隔符 | string // 单个字段,使用默认分隔符
@@ -682,6 +725,7 @@ export interface FormRenderProps<
> { > {
/** /**
* 表单字段数组映射字符串配置 默认使用"," * 表单字段数组映射字符串配置 默认使用","
* @deprecated Use the form-level `codec` instead.
*/ */
arrayToStringFields?: ArrayToStringFields; arrayToStringFields?: ArrayToStringFields;
/** /**
@@ -717,6 +761,7 @@ export interface FormRenderProps<
componentMap: Record<BaseFormComponentType, Component>; componentMap: Record<BaseFormComponentType, Component>;
/** /**
* 表单字段映射到时间格式 * 表单字段映射到时间格式
* @deprecated Use the form-level `codec` instead.
*/ */
fieldMappingTime?: FieldMappingTime; fieldMappingTime?: FieldMappingTime;
/** /**
@@ -757,6 +802,7 @@ export interface VbenFormProps<
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues, TValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TValues,
> extends Omit< > extends Omit<
FormRenderProps<T, P, TValues>, FormRenderProps<T, P, TValues>,
'componentBindEventMap' | 'componentMap' | 'form' 'componentBindEventMap' | 'componentMap' | 'form'
@@ -780,6 +826,7 @@ export interface VbenFormProps<
actionWrapperClass?: ClassType; actionWrapperClass?: ClassType;
/** /**
* 表单字段数组映射字符串配置 默认使用"," * 表单字段数组映射字符串配置 默认使用","
* @deprecated Use the form-level `codec` instead.
*/ */
arrayToStringFields?: ArrayToStringFields; arrayToStringFields?: ArrayToStringFields;
@@ -787,8 +834,11 @@ export interface VbenFormProps<
* submitOnChange改变时防抖时间 | 默认300ms * submitOnChange改变时防抖时间 | 默认300ms
*/ */
changeDebouncedTime?: number; changeDebouncedTime?: number;
/** 表单组件值与提交值之间的双向编解码器。 */
codec?: FormCodec<TValues, TSubmitValues>;
/** /**
* 表单字段映射 * 表单字段映射
* @deprecated Use the form-level `codec` instead.
*/ */
fieldMappingTime?: FieldMappingTime; fieldMappingTime?: FieldMappingTime;
/** /**
@@ -798,18 +848,18 @@ export interface VbenFormProps<
/** /**
* 表单重置回调 * 表单重置回调
*/ */
handleReset?: HandleResetFn<TValues>; handleReset?: HandleResetFn<NoInfer<TSubmitValues>>;
/** /**
* 表单提交回调 * 表单提交回调
*/ */
handleSubmit?: HandleSubmitFn<TValues>; handleSubmit?: HandleSubmitFn<TValues, TSubmitValues>;
/** /**
* 表单值变化回调 * 表单值变化回调
*/ */
handleValuesChange?: ( handleValuesChange?: (
values: Readonly<TValues>, values: Readonly<TValues>,
fieldsChanged: string[], fieldsChanged: string[],
getFormattedValues: () => TValues, getFormattedValues: () => TSubmitValues,
) => void; ) => void;
/** /**
@@ -851,9 +901,12 @@ export type ExtendedFormApi<
TValues extends FormValues = FormValues, TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
> = FormApi<TValues, T, P> & { TSubmitValues extends FormValues = TValues,
useStore: <TResult = NoInfer<VbenFormProps<T, P, TValues>>>( > = FormApi<TValues, T, P, TSubmitValues> & {
selector?: (state: NoInfer<VbenFormProps<T, P, TValues>>) => TResult, useStore: <TResult = NoInfer<VbenFormProps<T, P, TValues, TSubmitValues>>>(
selector?: (
state: NoInfer<VbenFormProps<T, P, TValues, TSubmitValues>>,
) => TResult,
) => Readonly<Ref<TResult>>; ) => Readonly<Ref<TResult>>;
}; };

View File

@@ -17,7 +17,11 @@ type UseVbenFormReturn<
TValues extends FormValues, TValues extends FormValues,
T extends BaseFormComponentType, T extends BaseFormComponentType,
P extends Record<string, any>, P extends Record<string, any>,
> = readonly [VbenFormComponent<TValues, T, P>, ExtendedFormApi<TValues, T, P>]; TSubmitValues extends FormValues = TValues,
> = readonly [
VbenFormComponent<TValues, T, P, TSubmitValues>,
ExtendedFormApi<TValues, T, P, TSubmitValues>,
];
export function useVbenForm< export function useVbenForm<
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
@@ -28,14 +32,17 @@ export function useVbenForm<
TValues extends FormValues, TValues extends FormValues,
T extends BaseFormComponentType = BaseFormComponentType, T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
>(options: VbenFormProps<T, P, TValues>): UseVbenFormReturn<TValues, T, P>; TSubmitValues extends FormValues = TValues,
>(
options: VbenFormProps<T, P, TValues, TSubmitValues>,
): UseVbenFormReturn<TValues, T, P, TSubmitValues>;
export function useVbenForm( export function useVbenForm(
options: VbenFormProps<any, any, any>, options: VbenFormProps<any, any, any, any>,
): UseVbenFormReturn<any, any, any> { ): UseVbenFormReturn<any, any, any, any> {
const IS_REACTIVE = isReactive(options); const IS_REACTIVE = isReactive(options);
const api = new FormApi<any, any, any>(options); const api = new FormApi<any, any, any, any>(options);
const extendedApi = api as ExtendedFormApi<any, any, any>; const extendedApi = api as ExtendedFormApi<any, any, any, any>;
extendedApi.useStore = (selector: any) => { extendedApi.useStore = (selector: any) => {
return useSelector(api.store, selector); return useSelector(api.store, selector);
}; };
@@ -65,5 +72,10 @@ export function useVbenForm(
); );
} }
return [Form, extendedApi] as unknown as UseVbenFormReturn<any, any, any>; return [Form, extendedApi] as unknown as UseVbenFormReturn<
any,
any,
any,
any
>;
} }

View File

@@ -23,7 +23,7 @@ import {
// 通过 extends 会导致热更新卡死,所以重复写了一遍 // 通过 extends 会导致热更新卡死,所以重复写了一遍
interface Props extends VbenFormProps { interface Props extends VbenFormProps {
formApi?: ExtendedFormApi<any, any, any>; formApi?: ExtendedFormApi<any, any, any, any>;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();

View File

@@ -43,18 +43,20 @@ const close = () => {
open.value = false; open.value = false;
}; };
const handleViewAll = () => { function handleViewAll() {
emit('viewAll'); emit('viewAll');
close(); close();
}; }
const handleMakeAll = () => { function handleMakeAll() {
emit('makeAll'); emit('makeAll');
}; }
const handleClear = () => { function handleClear() {
emit('clear'); emit('clear');
}; }
defineExpose({ toggle });
</script> </script>
<template> <template>
<VbenPopover v-model:open="open" content-class="relative right-2 w-90 p-0"> <VbenPopover v-model:open="open" content-class="relative right-2 w-90 p-0">

View File

@@ -8,6 +8,8 @@ import { $t } from '@vben/locales';
import Sortable from 'sortablejs'; import Sortable from 'sortablejs';
import SelectItem from './select-item.vue';
interface Item { interface Item {
key: string; key: string;
label: string; label: string;
@@ -67,14 +69,13 @@ onUnmounted(() => {
sortableInstance = null; sortableInstance = null;
}); });
function setPosition(key: string, event: Event) { function setPosition(key: string, value: string | undefined) {
const value = (event.target as HTMLSelectElement).value as if (!value) return;
| 'auto' emit(
| 'fixed' 'updatePosition',
| 'header' key,
| 'none' value as 'auto' | 'fixed' | 'header' | 'none' | 'user-dropdown',
| 'user-dropdown'; );
emit('updatePosition', key, value);
nextTick(() => { nextTick(() => {
emit( emit(
'updateOrder', 'updateOrder',
@@ -91,25 +92,21 @@ function setPosition(key: string, event: Event) {
v-for="item in sortableList" v-for="item in sortableList"
:key="item.key" :key="item.key"
:data-key="item.key" :data-key="item.key"
class="bg-accent flex items-center gap-2 rounded-md px-2 py-1.5" class="bg-accent flex items-center rounded-md pl-2"
> >
<GripVertical <GripVertical
class="drag-handle size-4 shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing" class="drag-handle size-4 shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing"
/> />
<span class="min-w-0 flex-1 truncate text-sm">{{ item.label }}</span> <SelectItem
<select :items="item.positionItems ?? positionItems"
:value="item.position" :model-value="item.position"
class="bg-background h-7 w-28 shrink-0 rounded border px-1 text-xs" class="min-w-0 flex-1"
@change="(e) => setPosition(item.key, e)" @update:model-value="
(v: string | undefined) => setPosition(item.key, v)
"
> >
<option <span class="truncate">{{ item.label }}</span>
v-for="opt in item.positionItems ?? positionItems" </SelectItem>
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
</div> </div>
</div> </div>
<div v-if="hiddenList.length > 0" class="pt-2"> <div v-if="hiddenList.length > 0" class="pt-2">
@@ -119,26 +116,22 @@ function setPosition(key: string, event: Event) {
<div <div
v-for="item in hiddenList" v-for="item in hiddenList"
:key="item.key" :key="item.key"
class="flex items-center gap-2 rounded-md px-2 py-1" class="flex items-center rounded-md"
> >
<span <SelectItem
class="text-muted-foreground min-w-0 flex-1 truncate text-sm line-through decoration-dotted" :items="item.positionItems ?? positionItems"
:model-value="item.position"
class="min-w-0 flex-1"
@update:model-value="
(v: string | undefined) => setPosition(item.key, v)
"
> >
{{ item.label }} <span
</span> class="text-muted-foreground truncate line-through decoration-dotted"
<select
:value="item.position"
class="bg-background h-7 w-28 shrink-0 rounded border px-1 text-xs"
@change="(e) => setPosition(item.key, e)"
>
<option
v-for="opt in item.positionItems ?? positionItems"
:key="opt.value"
:value="opt.value"
> >
{{ opt.label }} {{ item.label }}
</option> </span>
</select> </SelectItem>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -94,10 +94,10 @@ const positionMap: Record<string, string> = {
languageToggle: 'widgetLanguageToggleButtonPosition', languageToggle: 'widgetLanguageToggleButtonPosition',
timezone: 'widgetTimezoneButtonPosition', timezone: 'widgetTimezoneButtonPosition',
fullscreen: 'widgetFullscreenButtonPosition', fullscreen: 'widgetFullscreenButtonPosition',
refresh: 'widgetRefreshButtonPosition',
notification: 'widgetNotificationButtonPosition', notification: 'widgetNotificationButtonPosition',
lockScreenBtn: 'widgetLockScreenButtonPosition', lockScreenBtn: 'widgetLockScreenButtonPosition',
logoutBtn: 'widgetLogoutButtonPosition', logoutBtn: 'widgetLogoutButtonPosition',
refresh: 'widgetRefreshButtonPosition',
}; };
const labelMap: Record<string, string> = { const labelMap: Record<string, string> = {

View File

@@ -12,6 +12,8 @@ import {
VbenIconButton, VbenIconButton,
} from '@vben-core/shadcn-ui'; } from '@vben-core/shadcn-ui';
withDefaults(defineProps<{ showButton?: boolean }>(), { showButton: true });
const TimezoneIcon = createIconifyIcon('fluent-mdl2:world-clock'); const TimezoneIcon = createIconifyIcon('fluent-mdl2:world-clock');
const timezoneStore = useTimezoneStore(); const timezoneStore = useTimezoneStore();
@@ -47,17 +49,20 @@ const [Modal, modalApi] = useVbenModal({
}, },
}); });
const handleClick = () => { function open() {
modalApi.open(); modalApi.open();
}; }
defineExpose({ open });
</script> </script>
<template> <template>
<div> <div>
<VbenIconButton <VbenIconButton
v-if="showButton"
:tooltip="$t('ui.widgets.timezone.setTimezone')" :tooltip="$t('ui.widgets.timezone.setTimezone')"
class="hover:animate-[shrink_0.3s_ease-in-out]" class="hover:animate-[shrink_0.3s_ease-in-out]"
@click="handleClick" @click="open"
> >
<TimezoneIcon class="size-4 text-foreground" /> <TimezoneIcon class="size-4 text-foreground" />
</VbenIconButton> </VbenIconButton>

View File

@@ -8,6 +8,7 @@ import { computed, ref, useTemplateRef, watch } from 'vue';
import { SUPPORT_LANGUAGES } from '@vben/constants'; import { SUPPORT_LANGUAGES } from '@vben/constants';
import { useHoverToggle, useRefresh } from '@vben/hooks'; import { useHoverToggle, useRefresh } from '@vben/hooks';
import { import {
createIconifyIcon,
Languages, Languages,
LockKeyhole, LockKeyhole,
LogOut, LogOut,
@@ -40,7 +41,7 @@ import {
VbenIconButton, VbenIconButton,
} from '@vben-core/shadcn-ui'; } from '@vben-core/shadcn-ui';
import { useMagicKeys, whenever } from '@vueuse/core'; import { useFullscreen, useMagicKeys, whenever } from '@vueuse/core';
import { GlobalSearch } from '../global-search'; import { GlobalSearch } from '../global-search';
import { LockScreenModal } from '../lock-screen'; import { LockScreenModal } from '../lock-screen';
@@ -101,9 +102,11 @@ const {
globalLogoutShortcutKey, globalLogoutShortcutKey,
globalLockScreenShortcutKey, globalLockScreenShortcutKey,
globalSearchShortcutKey, globalSearchShortcutKey,
isDark,
preferencesButtonPosition, preferencesButtonPosition,
} = usePreferences(); } = usePreferences();
const { refresh } = useRefresh(); const { refresh } = useRefresh();
const { toggle: toggleFullscreen } = useFullscreen();
const accessStore = useAccessStore(); const accessStore = useAccessStore();
const [LockModal, lockModalApi] = useVbenModal({ const [LockModal, lockModalApi] = useVbenModal({
connectedComponent: LockScreenModal, connectedComponent: LockScreenModal,
@@ -118,6 +121,10 @@ const refTrigger = useTemplateRef('refTrigger');
const refContent = useTemplateRef('refContent'); const refContent = useTemplateRef('refContent');
const refPreferences = useTemplateRef('refPreferences'); const refPreferences = useTemplateRef('refPreferences');
const refGlobalSearch = useTemplateRef('refGlobalSearch'); const refGlobalSearch = useTemplateRef('refGlobalSearch');
const refTimezone = useTemplateRef('refTimezone');
const refNotification = useTemplateRef('refNotification');
const TimezoneIcon = createIconifyIcon('fluent-mdl2:world-clock');
const [openPopover, hoverWatcher] = useHoverToggle( const [openPopover, hoverWatcher] = useHoverToggle(
[refTrigger, refContent], [refTrigger, refContent],
() => props.hoverDelay, () => props.hoverDelay,
@@ -247,6 +254,28 @@ function handleGlobalSearch() {
refGlobalSearch.value?.open(); refGlobalSearch.value?.open();
} }
// 主题切换
function handleThemeToggleSelect(event?: Event) {
event?.preventDefault();
updatePreferences({ theme: { mode: isDark.value ? 'light' : 'dark' } });
}
// 时区
function handleTimezoneSelect() {
refTimezone.value?.open();
}
// 全屏切换
function handleFullscreenSelect() {
toggleFullscreen();
}
// 通知
function handleNotificationSelect(event?: Event) {
event?.preventDefault();
refNotification.value?.toggle();
}
// 语言切换 - 阻止 Radix 默认关闭外层 dropdown,就地展开/收起 locale 列表 // 语言切换 - 阻止 Radix 默认关闭外层 dropdown,就地展开/收起 locale 列表
const showLanguageList = ref(false); const showLanguageList = ref(false);
function handleLanguageToggleSelect(event?: Event) { function handleLanguageToggleSelect(event?: Event) {
@@ -322,6 +351,12 @@ if (preferences.shortcutKeys.enable) {
:show-button="false" :show-button="false"
/> />
<TimezoneButton
v-if="showTimezoneInDropdown"
ref="refTimezone"
:show-button="false"
/>
<DropdownMenu v-model:open="openPopover" :modal="false"> <DropdownMenu v-model:open="openPopover" :modal="false">
<DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'"> <DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'">
<div class="mr-2 ml-1 cursor-pointer rounded-full p-1.5 hover:bg-accent"> <div class="mr-2 ml-1 cursor-pointer rounded-full p-1.5 hover:bg-accent">
@@ -428,6 +463,7 @@ if (preferences.shortcutKeys.enable) {
<DropdownMenuItem <DropdownMenuItem
v-if="showThemeToggleInDropdown" v-if="showThemeToggleInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@select="handleThemeToggleSelect"
> >
<ThemeToggle class="mr-2" /> <ThemeToggle class="mr-2" />
{{ $t('preferences.theme.title') }} {{ $t('preferences.theme.title') }}
@@ -461,22 +497,27 @@ if (preferences.shortcutKeys.enable) {
<DropdownMenuItem <DropdownMenuItem
v-if="showTimezoneInDropdown" v-if="showTimezoneInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@select="handleTimezoneSelect"
> >
<TimezoneButton class="mr-2" /> <VbenIconButton class="mr-2" @click="handleTimezoneSelect">
<TimezoneIcon class="size-4" />
</VbenIconButton>
{{ $t('ui.widgets.timezone.setTimezone') }} {{ $t('ui.widgets.timezone.setTimezone') }}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
v-if="showFullscreenInDropdown" v-if="showFullscreenInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@select="handleFullscreenSelect"
> >
<VbenFullScreen class="mr-2" /> <VbenFullScreen class="mr-2" @click.stop />
{{ $t('preferences.widget.fullscreen') }} {{ $t('preferences.widget.fullscreen') }}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
v-if="showNotificationInDropdown" v-if="showNotificationInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8" class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@select="handleNotificationSelect"
> >
<Notification class="mr-2" /> <Notification ref="refNotification" class="mr-2" />
{{ $t('preferences.widget.notification') }} {{ $t('preferences.widget.notification') }}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem

View File

@@ -3,6 +3,7 @@ import type { VxeGridInstance } from 'vxe-table';
import type { import type {
BaseFormComponentType, BaseFormComponentType,
ExtendedFormApi, ExtendedFormApi,
FormValues,
} from '@vben-core/form-ui'; } from '@vben-core/form-ui';
import type { VxeGridProps } from './types'; import type { VxeGridProps } from './types';
@@ -34,14 +35,16 @@ export class VxeGridApi<
T extends Record<string, any> = any, T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType, D extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> { > {
public formApi = {} as ExtendedFormApi; public formApi = {} as ExtendedFormApi<TFormValues, D, P, TSubmitValues>;
// private prevState: null | VxeGridProps = null; // private prevState: null | VxeGridProps = null;
public grid = {} as VxeGridInstance<T>; public grid = {} as VxeGridInstance<T>;
public state: null | VxeGridProps<T, D, P> = null; public state: null | VxeGridProps<T, D, P, TFormValues, TSubmitValues> = null;
public store: Store<VxeGridProps<T, D, P>>; public store: Store<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>;
/** /**
* 已读行 helper(在 mount 中初始化,业务能力全部封装在 useViewedRow 中) * 已读行 helper(在 mount 中初始化,业务能力全部封装在 useViewedRow 中)
@@ -52,12 +55,26 @@ export class VxeGridApi<
private stateHandler: StateHandler; private stateHandler: StateHandler;
constructor(options: VxeGridProps<T, D, P> = {} as VxeGridProps<T, D, P>) { constructor(
options: VxeGridProps<
T,
D,
P,
TFormValues,
TSubmitValues
> = {} as VxeGridProps<T, D, P, TFormValues, TSubmitValues>,
) {
const storeState = { ...options }; const storeState = { ...options };
const defaultState = getDefaultState(); const defaultState = getDefaultState();
this.store = new Store<VxeGridProps<T, D, P>>( this.store = new Store<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>(
mergeWithArrayOverride(storeState, defaultState) as VxeGridProps<T, D, P>, mergeWithArrayOverride(storeState, defaultState) as VxeGridProps<
T,
D,
P,
TFormValues,
TSubmitValues
>,
); );
this.store.subscribe((state) => { this.store.subscribe((state) => {
@@ -106,7 +123,10 @@ export class VxeGridApi<
this.viewedRowHelper?.markAsViewed(record); this.viewedRowHelper?.markAsViewed(record);
} }
mount(instance: null | VxeGridInstance, formApi: ExtendedFormApi) { mount(
instance: null | VxeGridInstance,
formApi: ExtendedFormApi<TFormValues, D, P, TSubmitValues>,
) {
if (!this.isMounted && instance) { if (!this.isMounted && instance) {
this.grid = instance; this.grid = instance;
this.formApi = formApi; this.formApi = formApi;
@@ -138,7 +158,11 @@ export class VxeGridApi<
this.viewedRowHelper?.removeKeys(keys); this.viewedRowHelper?.removeKeys(keys);
} }
setGridOptions(options: Partial<VxeGridProps<T, D, P>['gridOptions']>) { setGridOptions(
options: Partial<
VxeGridProps<T, D, P, TFormValues, TSubmitValues>['gridOptions']
>,
) {
this.setState({ this.setState({
gridOptions: options, gridOptions: options,
}); });
@@ -154,8 +178,10 @@ export class VxeGridApi<
setState( setState(
stateOrFn: stateOrFn:
| ((prev: VxeGridProps<T, D, P>) => Partial<VxeGridProps<T, D, P>>) | ((
| Partial<VxeGridProps<T, D, P>>, prev: VxeGridProps<T, D, P, TFormValues, TSubmitValues>,
) => Partial<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>)
| Partial<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>,
) { ) {
if (isFunction(stateOrFn)) { if (isFunction(stateOrFn)) {
this.store.setState((prev) => { this.store.setState((prev) => {

View File

@@ -10,7 +10,11 @@ import type { Ref } from 'vue';
import type { ClassType, DeepPartial } from '@vben/types'; import type { ClassType, DeepPartial } from '@vben/types';
import type { BaseFormComponentType, VbenFormProps } from '@vben-core/form-ui'; import type {
BaseFormComponentType,
FormValues,
VbenFormProps,
} from '@vben-core/form-ui';
import type { VxeGridApi } from './api'; import type { VxeGridApi } from './api';
@@ -124,6 +128,8 @@ export interface VxeGridProps<
T extends Record<string, any> = any, T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType, D extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> { > {
/** /**
* 数据 * 数据
@@ -156,7 +162,7 @@ export interface VxeGridProps<
/** /**
* 表单配置 * 表单配置
*/ */
formOptions?: VbenFormProps<D, P>; formOptions?: VbenFormProps<D, P, TFormValues, TSubmitValues>;
/** /**
* 显示搜索表单 * 显示搜索表单
*/ */
@@ -175,9 +181,13 @@ export type ExtendedVxeGridApi<
D extends Record<string, any> = any, D extends Record<string, any> = any,
F extends BaseFormComponentType = BaseFormComponentType, F extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
> = VxeGridApi<D, F, P> & { TFormValues extends FormValues = FormValues,
useStore: <S = NoInfer<VxeGridProps<D, F, P>>>( TSubmitValues extends FormValues = TFormValues,
selector?: (state: NoInfer<VxeGridProps<D, F, P>>) => S, > = VxeGridApi<D, F, P, TFormValues, TSubmitValues> & {
useStore: <S = NoInfer<VxeGridProps<D, F, P, TFormValues, TSubmitValues>>>(
selector?: (
state: NoInfer<VxeGridProps<D, F, P, TFormValues, TSubmitValues>>,
) => S,
) => Readonly<Ref<S>>; ) => Readonly<Ref<S>>;
}; };

View File

@@ -2,7 +2,7 @@ import type { VxeGridSlots, VxeGridSlotTypes } from 'vxe-table';
import type { SlotsType } from 'vue'; import type { SlotsType } from 'vue';
import type { BaseFormComponentType } from '@vben-core/form-ui'; import type { BaseFormComponentType, FormValues } from '@vben-core/form-ui';
import type { ExtendedVxeGridApi, VxeGridProps } from './types'; import type { ExtendedVxeGridApi, VxeGridProps } from './types';
@@ -23,24 +23,28 @@ export function useVbenVxeGrid<
T extends Record<string, any> = any, T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType, D extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>, P extends Record<string, any> = Record<never, never>,
>(options: VxeGridProps<T, D, P>) { TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(options: VxeGridProps<T, D, P, TFormValues, TSubmitValues>) {
// const IS_REACTIVE = isReactive(options); // const IS_REACTIVE = isReactive(options);
const api = new VxeGridApi<T, D, P>(options); const api = new VxeGridApi<T, D, P, TFormValues, TSubmitValues>(options);
const extendedApi: ExtendedVxeGridApi<T, D, P> = api as ExtendedVxeGridApi< const extendedApi: ExtendedVxeGridApi<T, D, P, TFormValues, TSubmitValues> =
T, api as ExtendedVxeGridApi<T, D, P, TFormValues, TSubmitValues>;
D,
P
>;
extendedApi.useStore = (selector) => { extendedApi.useStore = (selector) => {
return useStore(api.store, selector); return useStore(api.store, selector);
}; };
const Grid = defineComponent( const Grid = defineComponent(
(props: VxeGridProps<T, D, P>, { attrs, slots }) => { (
props: VxeGridProps<T, D, P, TFormValues, TSubmitValues>,
{ attrs, slots },
) => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
api.unmount(); api.unmount();
}); });
api.setState({ ...props, ...attrs } as Partial<VxeGridProps<T, D, P>>); api.setState({ ...props, ...attrs } as Partial<
VxeGridProps<T, D, P, TFormValues, TSubmitValues>
>);
return () => return () =>
h( h(
VxeGrid, VxeGrid,
@@ -48,7 +52,7 @@ export function useVbenVxeGrid<
...props, ...props,
...attrs, ...attrs,
api: extendedApi as ExtendedVxeGridApi, api: extendedApi as ExtendedVxeGridApi,
}, } as any,
slots, slots,
); );
}, },

View File

@@ -194,24 +194,24 @@
"title": "小部件", "title": "小部件",
"globalSearch": "全局搜索", "globalSearch": "全局搜索",
"globalSearchPosition": "全局搜索位置", "globalSearchPosition": "全局搜索位置",
"fullscreen": "启用全屏", "fullscreen": "全屏",
"fullscreenPosition": "全屏按钮位置", "fullscreenPosition": "全屏按钮位置",
"themeToggle": "启用主题切换", "themeToggle": "主题切换",
"themeTogglePosition": "主题切换位置", "themeTogglePosition": "主题切换位置",
"languageToggle": "语言切换", "languageToggle": "语言切换",
"languageTogglePosition": "语言切换位置", "languageTogglePosition": "语言切换位置",
"notification": "启用通知", "notification": "通知",
"notificationPosition": "通知位置", "notificationPosition": "通知位置",
"sidebarToggle": "启用侧边栏切换", "sidebarToggle": "启用侧边栏切换",
"lockScreen": "启用锁屏", "lockScreen": "锁屏",
"lockScreenPosition": "锁屏按钮位置", "lockScreenPosition": "锁屏按钮位置",
"logoutButtonPosition": "退出按钮位置", "logoutButtonPosition": "退出按钮位置",
"header": "顶栏", "header": "顶栏",
"userDropdown": "用户下拉窗", "userDropdown": "用户下拉窗",
"hidden": "已隐藏", "hidden": "已隐藏",
"refresh": "启用刷新", "refresh": "刷新",
"refreshPosition": "刷新按钮位置", "refreshPosition": "刷新按钮位置",
"timezone": "启用时区", "timezone": "时区",
"timezonePosition": "时区按钮位置" "timezonePosition": "时区按钮位置"
}, },
"antd": { "antd": {

View File

@@ -41,18 +41,27 @@ async function initSetupVbenForm() {
}); });
} }
function useVbenForm<TValues extends FormValues = FormValues>( function useVbenForm<
options: FormProps<ComponentType, ComponentPropsMap, TValues>, TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
options: FormProps<
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>,
) { ) {
return useForm<TValues, ComponentType, ComponentPropsMap>(options); return useForm<TFormValues, ComponentType, ComponentPropsMap, TSubmitValues>(
options,
);
} }
export { initSetupVbenForm, useVbenForm, z }; export { initSetupVbenForm, useVbenForm, z };
export type VbenFormSchema<TValues extends FormValues = FormValues> = export type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>; FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps< export type VbenFormProps<
ComponentType, TFormValues extends FormValues = FormValues,
ComponentPropsMap, TSubmitValues extends FormValues = TFormValues,
TValues > = FormProps<ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>;
>;

View File

@@ -1,4 +1,4 @@
import type { TableActionProps } from '@vben/common-ui'; import type { FormValues, TableActionProps } from '@vben/common-ui';
import type { VxeTableGridOptions } from '@vben/plugins/vxe-table'; import type { VxeTableGridOptions } from '@vben/plugins/vxe-table';
import type { Recordable } from '@vben/types'; import type { Recordable } from '@vben/types';
@@ -287,9 +287,24 @@ setupVbenVxeTable({
}, },
}); });
export const useVbenVxeGrid = <T extends Record<string, any>>( export const useVbenVxeGrid = <
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>> T extends Record<string, any>,
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest); TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(
...rest: Parameters<
typeof useGrid<
T,
ComponentType,
ComponentPropsMap,
TFormValues,
TSubmitValues
>
>
) =>
useGrid<T, ComponentType, ComponentPropsMap, TFormValues, TSubmitValues>(
...rest,
);
/** /**
* 表格操作按钮组件 * 表格操作按钮组件

View File

@@ -91,7 +91,6 @@ contacts[0].name;
- `help` - `help`
- `suffix` - `suffix`
- `renderComponentContent` - `renderComponentContent`
- `valueFormat`
- `formFieldProps` - `formFieldProps`
- `disabled` - `disabled`
- `hide` - `hide`
@@ -188,29 +187,29 @@ dependencies: {
triggerFields: ['$row.role']; triggerFields: ['$row.role'];
``` ```
## valueFormat 用法 ## 提交值转换
children 里的 `valueFormat` 也会按行执行: 数组字段的提交转换使用表单级 `codec`,一次处理完整表单值:
```ts ```ts
{ function encodeArrayFormValues(values: Readonly<ArrayFormValues>) {
component: 'Input', return {
fieldName: 'phone', ...values,
label: '电话', contacts: values.contacts.map((contact) => ({
valueFormat: (value, setValue) => { ...contact,
const nextValue = value?.trim(); name: contact.name.trim(),
if (!nextValue) { phone: contact.phone?.trim() || undefined,
return; })),
} };
setValue('phone', nextValue);
},
} }
```
在 `contacts[0]` 中,`setValue('phone', nextValue)` 会自动写到: const [Form] = useVbenForm({
codec: {
```ts decode: decodeArrayFormValues,
contacts[0].phone; encode: encodeArrayFormValues,
},
schema,
});
``` ```
如果要写根字段,用 `$root.`: 如果要写根字段,用 `$root.`:
@@ -284,7 +283,7 @@ flowchart TD
H --> I["createArrayChildSchema"] H --> I["createArrayChildSchema"]
I --> J["child.fieldName 转为 contacts[index].xxx"] I --> J["child.fieldName 转为 contacts[index].xxx"]
I --> K["scope dependencies triggerFields"] I --> K["scope dependencies triggerFields"]
I --> L["包装 componentProps/help/render/valueFormat ctx"] I --> L["包装 componentProps/help/render ctx"]
J --> M["继续复用 FormField 渲染 child"] J --> M["继续复用 FormField 渲染 child"]
``` ```
@@ -294,7 +293,7 @@ flowchart TD
- 具体展示仍复用内部 `VbenFormFieldArray`。 - 具体展示仍复用内部 `VbenFormFieldArray`。
- child 最终仍然走 `FormField`,所以现有 FormSchema 能力不会丢。 - child 最终仍然走 `FormField`,所以现有 FormSchema 能力不会丢。
- `dependencies` 不改核心调用链,而是在 `createArrayChildSchema` 里做路径和 ctx 适配。 - `dependencies` 不改核心调用链,而是在 `createArrayChildSchema` 里做路径和 ctx 适配。
- `valueFormat` 和 `updateSchema` 在 `FormApi` 里递归处理 children。 - `updateSchema` 在 `FormApi` 里递归处理 children;提交转换由表单级 `codec` 统一完成。
## 小屏幕展示 ## 小屏幕展示

View File

@@ -9,7 +9,48 @@ import { Button, Card, message, Space } from 'antdv-next';
import { useVbenForm, z } from '#/adapter/form'; import { useVbenForm, z } from '#/adapter/form';
const submitValues = ref<Record<string, any>>({}); interface ContactFormValues {
enabled: boolean;
name: string;
phone?: string;
role: 'member' | 'owner' | 'viewer';
}
interface ArrayFormValues extends Record<string, unknown> {
contacts: ContactFormValues[];
description?: string;
planName: string;
}
function encodeArrayFormValues(values: Readonly<ArrayFormValues>) {
return {
...values,
contacts: values.contacts.map(({ phone, ...contact }) => {
const trimmedPhone = phone?.trim();
return {
...contact,
name: contact.name.trim(),
...(trimmedPhone ? { phone: trimmedPhone } : {}),
};
}),
};
}
type ArraySubmitValues = ReturnType<typeof encodeArrayFormValues>;
function decodeArrayFormValues(
values: Readonly<ArraySubmitValues>,
): ArrayFormValues {
return {
...values,
contacts: values.contacts.map((contact) => ({
...contact,
phone: contact.phone ?? '',
})),
};
}
const submitValues = ref<Partial<ArraySubmitValues>>({});
const formattedSubmitValues = computed(() => const formattedSubmitValues = computed(() =>
JSON.stringify(submitValues.value, null, 2), JSON.stringify(submitValues.value, null, 2),
); );
@@ -23,7 +64,7 @@ const outputClass = [
'text-xs', 'text-xs',
]; ];
const schema: VbenFormSchema[] = [ const schema: VbenFormSchema<ArrayFormValues>[] = [
{ {
component: 'Input', component: 'Input',
componentProps: { componentProps: {
@@ -38,7 +79,7 @@ const schema: VbenFormSchema[] = [
component: 'Textarea', component: 'Textarea',
dependencies: { dependencies: {
componentProps: (values) => { componentProps: (values) => {
const planName = values.planName as string | undefined; const planName = values.planName;
return { return {
disabled: !planName, disabled: !planName,
placeholder: planName ? `${planName} 的补充说明` : '请先填写方案名称', placeholder: planName ? `${planName} 的补充说明` : '请先填写方案名称',
@@ -81,7 +122,6 @@ const schema: VbenFormSchema[] = [
fieldName: 'name', fieldName: 'name',
label: '姓名', label: '姓名',
rules: z.string().min(1, '请输入姓名'), rules: z.string().min(1, '请输入姓名'),
valueFormat: (value) => value?.trim(),
}, },
{ {
component: 'Select', component: 'Select',
@@ -110,13 +150,6 @@ const schema: VbenFormSchema[] = [
fieldName: 'phone', fieldName: 'phone',
label: '电话', label: '电话',
rules: z.string().optional(), rules: z.string().optional(),
valueFormat: (value, setValue) => {
const nextValue = value?.trim();
if (!nextValue) {
return;
}
setValue('phone', nextValue);
},
}, },
{ {
component: 'Switch', component: 'Switch',
@@ -146,6 +179,10 @@ const schema: VbenFormSchema[] = [
]; ];
const [Form, formApi] = useVbenForm({ const [Form, formApi] = useVbenForm({
codec: {
decode: decodeArrayFormValues,
encode: encodeArrayFormValues,
},
commonConfig: { commonConfig: {
labelWidth: 90, labelWidth: 90,
}, },

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { UploadFile } from 'antdv-next'; import type { UploadFile } from 'antdv-next';
import type { Dayjs } from 'dayjs';
import { h, ref, toRaw } from 'vue'; import { h, ref, toRaw } from 'vue';
@@ -18,6 +19,36 @@ import DocButton from '../doc-button.vue';
const keyword = ref(''); const keyword = ref('');
const fetching = ref(false); const fetching = ref(false);
interface BasicFormValues extends Record<string, any> {
cropImage?: UploadFile[];
files?: UploadFile[];
rangePicker?: [Dayjs, Dayjs];
}
function encodeBasicFormValues(values: Readonly<BasicFormValues>) {
const { rangePicker, ...formValues } = values;
return {
...formValues,
endTime: rangePicker?.[1]?.format('YYYY-MM-DD'),
startTime: rangePicker?.[0]?.format('YYYY-MM-DD'),
};
}
type BasicSubmitValues = ReturnType<typeof encodeBasicFormValues>;
function decodeBasicFormValues(
values: Readonly<BasicSubmitValues>,
): BasicFormValues {
const { endTime, startTime, ...formValues } = values;
return {
...formValues,
...(startTime && endTime
? { rangePicker: [dayjs(startTime), dayjs(endTime)] }
: {}),
};
}
// 模拟远程获取数据 // 模拟远程获取数据
function fetchRemoteOptions({ keyword = '选项' }: Record<string, any>) { function fetchRemoteOptions({ keyword = '选项' }: Record<string, any>) {
fetching.value = true; fetching.value = true;
@@ -34,6 +65,10 @@ function fetchRemoteOptions({ keyword = '选项' }: Record<string, any>) {
} }
const [BaseForm, baseFormApi] = useVbenForm({ const [BaseForm, baseFormApi] = useVbenForm({
codec: {
decode: decodeBasicFormValues,
encode: encodeBasicFormValues,
},
// 所有表单项共用,可单独在表单内覆盖 // 所有表单项共用,可单独在表单内覆盖
commonConfig: { commonConfig: {
// 在label后显示一个冒号 // 在label后显示一个冒号
@@ -43,7 +78,6 @@ const [BaseForm, baseFormApi] = useVbenForm({
class: 'w-full', class: 'w-full',
}, },
}, },
fieldMappingTime: [['rangePicker', ['startTime', 'endTime'], 'YYYY-MM-DD']],
// 提交函数 // 提交函数
handleSubmit: onSubmit, handleSubmit: onSubmit,
handleValuesChange(_values, fieldsChanged) { handleValuesChange(_values, fieldsChanged) {
@@ -425,8 +459,8 @@ const [BaseForm, baseFormApi] = useVbenForm({
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: BasicSubmitValues) {
const files = toRaw(values.files) as UploadFile[]; const files = (toRaw(values.files) ?? []) as UploadFile[];
const cropImage = (toRaw(values.cropImage) ?? []) as UploadFile[]; const cropImage = (toRaw(values.cropImage) ?? []) as UploadFile[];
const doneFiles = files.filter((file) => file.status === 'done'); const doneFiles = files.filter((file) => file.status === 'done');
const failedFiles = files.filter((file) => file.status !== 'done'); const failedFiles = files.filter((file) => file.status !== 'done');

View File

@@ -9,7 +9,40 @@ import { useVbenForm, z } from '#/adapter/form';
import TwoFields from './modules/two-fields.vue'; import TwoFields from './modules/two-fields.vue';
interface CustomFormValues extends Record<string, unknown> {
field?: string;
field1?: string;
field2?: string;
field3?: string;
field4?: [string | undefined, string];
}
function encodeCustomFormValues(values: Readonly<CustomFormValues>) {
const { field4, ...formValues } = values;
return {
...formValues,
phoneNumber: field4?.[1],
phoneType: field4?.[0],
};
}
type CustomSubmitValues = ReturnType<typeof encodeCustomFormValues>;
function decodeCustomFormValues(
values: Readonly<CustomSubmitValues>,
): CustomFormValues {
const { phoneNumber, phoneType, ...formValues } = values;
return {
...formValues,
field4: [phoneType, phoneNumber ?? ''],
};
}
const [Form] = useVbenForm({ const [Form] = useVbenForm({
codec: {
decode: decodeCustomFormValues,
encode: encodeCustomFormValues,
},
// 所有表单项共用,可单独在表单内覆盖 // 所有表单项共用,可单独在表单内覆盖
commonConfig: { commonConfig: {
// 所有表单项 // 所有表单项
@@ -18,7 +51,6 @@ const [Form] = useVbenForm({
}, },
labelClass: 'w-2/6', labelClass: 'w-2/6',
}, },
fieldMappingTime: [['field4', ['phoneType', 'phoneNumber'], null]],
// 提交函数 // 提交函数
handleSubmit: onSubmit, handleSubmit: onSubmit,
// 垂直布局,label和input在不同行,值为vertical // 垂直布局,label和input在不同行,值为vertical
@@ -80,7 +112,7 @@ const [Form] = useVbenForm({
wrapperClass: 'grid-cols-1 md:grid-cols-2', wrapperClass: 'grid-cols-1 md:grid-cols-2',
}); });
function onSubmit(values: Record<string, any>) { function onSubmit(values: CustomSubmitValues) {
message.success({ message.success({
content: `form values: ${JSON.stringify(values)}`, content: `form values: ${JSON.stringify(values)}`,
}); });

View File

@@ -1,5 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'; import type { Dayjs } from 'dayjs';
import { computed, nextTick, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
@@ -10,35 +12,65 @@ import { useVbenForm } from '#/adapter/form';
import DocButton from '../doc-button.vue'; import DocButton from '../doc-button.vue';
const transformedValues = ref<Record<string, any>>({}); interface ValueFormatFormValues {
const liveValues = ref<Record<string, any>>({}); deadline?: Dayjs;
keyword?: string;
reportRange?: [Dayjs, Dayjs];
}
function encodeValueFormatValues(values: Readonly<ValueFormatFormValues>) {
return {
deadline: values.deadline?.valueOf(),
endTime: values.reportRange?.[1].valueOf(),
keyword: values.keyword,
startTime: values.reportRange?.[0].valueOf(),
};
}
type ValueFormatSubmitValues = ReturnType<typeof encodeValueFormatValues>;
function decodeValueFormatValues(
values: Readonly<ValueFormatSubmitValues>,
): ValueFormatFormValues {
let reportRange: [Dayjs, Dayjs] | undefined;
if (values.startTime !== undefined && values.endTime !== undefined) {
reportRange = [dayjs(values.startTime), dayjs(values.endTime)];
}
return {
deadline:
values.deadline === undefined ? undefined : dayjs(values.deadline),
keyword: values.keyword,
reportRange,
};
}
const transformedValues = ref<Partial<ValueFormatSubmitValues>>({});
const liveValues = ref<Partial<ValueFormatFormValues>>({});
const [Form, formApi] = useVbenForm({ const [Form, formApi] = useVbenForm({
codec: {
decode: decodeValueFormatValues,
encode: encodeValueFormatValues,
},
commonConfig: { commonConfig: {
componentProps: { componentProps: {
class: 'w-full', class: 'w-full',
}, },
}, },
handleSubmit, handleSubmit,
handleValuesChange,
schema: [ schema: [
{ {
component: 'RangePicker', component: 'RangePicker',
fieldName: 'reportRange', fieldName: 'reportRange',
help: '通过 setValue 拆分为 startTime / endTime,并移除原字段', help: '由表单 codec 拆分为 startTime / endTime',
label: '统计时间范围', label: '统计时间范围',
valueFormat(value, setValue) {
setValue('startTime', value?.[0]?.valueOf());
setValue('endTime', value?.[1]?.valueOf());
},
}, },
{ {
component: 'DatePicker', component: 'DatePicker',
fieldName: 'deadline', fieldName: 'deadline',
help: '直接 return 时间戳,保留原字段名', help: '由表单 codec 编码为时间戳',
label: '截止时间', label: '截止时间',
valueFormat(value) {
return value?.valueOf();
},
}, },
{ {
component: 'Input', component: 'Input',
@@ -58,7 +90,7 @@ const transformedValuesPreview = computed(() => {
return formatJsonPreview(transformedValues.value); return formatJsonPreview(transformedValues.value);
}); });
function formatJsonPreview(value: Record<string, any>) { function formatJsonPreview(value: unknown) {
return JSON.stringify( return JSON.stringify(
value, value,
(_key, currentValue) => { (_key, currentValue) => {
@@ -83,49 +115,50 @@ function handleSetExampleValue() {
}); });
} }
function handleSubmit(values: Record<string, any>) { function handleSubmit(values: ValueFormatSubmitValues) {
transformedValues.value = values; transformedValues.value = values;
message.success({ message.success({
content: `getValues output: ${JSON.stringify(values)}`, content: `getValues output: ${JSON.stringify(values)}`,
}); });
} }
async function syncPreviewValues(values?: Record<string, any>) { function handleValuesChange(
liveValues.value = values ?? formApi.form?.values ?? {}; values: Readonly<ValueFormatFormValues>,
_fieldsChanged: string[],
getFormattedValues: () => ValueFormatSubmitValues,
) {
liveValues.value = { ...values };
transformedValues.value = getFormattedValues();
}
async function syncPreviewValues(values?: Readonly<ValueFormatFormValues>) {
liveValues.value = { ...(values ?? formApi.form?.values) };
transformedValues.value = await formApi.getValues(); transformedValues.value = await formApi.getValues();
} }
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
watch( await syncPreviewValues();
() => formApi.form?.values,
async (values) => {
await syncPreviewValues(values);
},
{
deep: true,
immediate: true,
},
);
}); });
</script> </script>
<template> <template>
<Page <Page
content-class="flex flex-col gap-4" content-class="flex flex-col gap-4"
description="演示 schema.valueFormat 如何把组件值转换为提交/查询所需的 payload。" description="演示表单级 codec 如何双向转换组件值和提交 payload。"
title="表单 valueFormat" title="表单 Codec"
> >
<template #description> <template #description>
<div class="text-muted-foreground space-y-2"> <div class="text-muted-foreground space-y-2">
<p> <p>
<code>form.values</code> 保持组件原始值,<code>getValues()</code> / <code>form.values</code> 保持组件原始值,<code>getValues()</code> /
提交时会按 <code>schema.valueFormat</code> 输出转换后的 payload。 提交时会按 <code>codec.encode</code> 输出 payload,回填时通过
<code>codec.decode</code> 恢复组件值。
</p> </p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Tag color="processing">return 值:回写当前字段</Tag> <Tag color="processing">encode:生成完整提交值</Tag>
<Tag color="success">setValue:拆分写入其他字段</Tag> <Tag color="success">decode:恢复完整表单值</Tag>
<Tag color="warning">return undefined:保持原字段删除</Tag> <Tag color="warning">多字段转换原子执行</Tag>
</div> </div>
</div> </div>
</template> </template>
@@ -133,7 +166,7 @@ onMounted(async () => {
<DocButton path="/components/common-ui/vben-form" /> <DocButton path="/components/common-ui/vben-form" />
</template> </template>
<Card title="valueFormat 示例"> <Card title="Codec 示例">
<template #extra> <template #extra>
<Space wrap> <Space wrap>
<Button @click="handleSetExampleValue">填充示例数据</Button> <Button @click="handleSetExampleValue">填充示例数据</Button>
@@ -151,7 +184,7 @@ onMounted(async () => {
liveValuesPreview liveValuesPreview
}}</pre> }}</pre>
</Card> </Card>
<Card title="getValues / submit 输出(valueFormat 后)"> <Card title="getValues / submit 输出(codec.encode 后)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{ <pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
transformedValuesPreview transformedValuesPreview
}}</pre> }}</pre>

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { VbenFormProps } from '#/adapter/form'; import type { VbenFormProps } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
@@ -19,10 +21,42 @@ interface RowType {
releaseDate: string; releaseDate: string;
} }
const formOptions: VbenFormProps = { interface SearchFormValues extends Record<string, unknown> {
category?: string;
color?: string;
date?: [Dayjs, Dayjs];
price?: string;
productName?: string;
}
function encodeSearchFormValues(values: Readonly<SearchFormValues>) {
const { date, ...formValues } = values;
return {
...formValues,
end: date?.[1]?.format('YYYY-MM-DD'),
start: date?.[0]?.format('YYYY-MM-DD'),
};
}
type SearchSubmitValues = ReturnType<typeof encodeSearchFormValues>;
function decodeSearchFormValues(
values: Readonly<SearchSubmitValues>,
): SearchFormValues {
const { end, start, ...formValues } = values;
return {
...formValues,
...(start && end ? { date: [dayjs(start), dayjs(end)] } : {}),
};
}
const formOptions: VbenFormProps<SearchFormValues, SearchSubmitValues> = {
codec: {
decode: decodeSearchFormValues,
encode: encodeSearchFormValues,
},
// 默认展开 // 默认展开
collapsed: false, collapsed: false,
fieldMappingTime: [['date', ['start', 'end']]],
schema: [ schema: [
{ {
component: 'Input', component: 'Input',
@@ -94,7 +128,7 @@ const gridOptions: VxeTableGridOptions<RowType> = {
pagerConfig: {}, pagerConfig: {},
proxyConfig: { proxyConfig: {
ajax: { ajax: {
query: async ({ page }, formValues) => { query: async ({ page }, formValues: SearchSubmitValues) => {
message.success(`Query params: ${JSON.stringify(formValues)}`); message.success(`Query params: ${JSON.stringify(formValues)}`);
return await getExampleTableApi({ return await getExampleTableApi({
page: page.currentPage, page: page.currentPage,

View File

@@ -31,7 +31,42 @@ const emit = defineEmits<{
}>(); }>();
const formData = ref<SystemMenuApi.SystemMenu>(); const formData = ref<SystemMenuApi.SystemMenu>();
const titleSuffix = ref<string>(); const titleSuffix = ref<string>();
const schema: VbenFormSchema[] = [
type MenuSubmitValues = Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>;
type MenuFormValues = MenuSubmitValues & { linkSrc?: string };
function encodeMenuFormValues(
values: Readonly<MenuFormValues>,
): MenuSubmitValues {
const { linkSrc, ...formValues } = values;
if (values.type === 'link') {
return {
...formValues,
meta: { ...values.meta, link: linkSrc },
};
}
if (values.type === 'embedded') {
return {
...formValues,
meta: { ...values.meta, iframeSrc: linkSrc },
};
}
return formValues;
}
function decodeMenuFormValues(
values: Readonly<MenuSubmitValues>,
): MenuFormValues {
let linkSrc: string | undefined;
if (values.type === 'link') {
linkSrc = values.meta?.link;
} else if (values.type === 'embedded') {
linkSrc = values.meta?.iframeSrc;
}
return { ...values, linkSrc };
}
const schema: VbenFormSchema<MenuFormValues>[] = [
{ {
component: 'RadioGroup', component: 'RadioGroup',
componentProps: { componentProps: {
@@ -428,6 +463,10 @@ const breakpoints = useBreakpoints(breakpointsTailwind);
const isHorizontal = computed(() => breakpoints.greaterOrEqual('md').value); const isHorizontal = computed(() => breakpoints.greaterOrEqual('md').value);
const [Form, formApi] = useVbenForm({ const [Form, formApi] = useVbenForm({
codec: {
decode: decodeMenuFormValues,
encode: encodeMenuFormValues,
},
commonConfig: { commonConfig: {
colon: true, colon: true,
formItemClass: 'col-span-2 md:col-span-1', formItemClass: 'col-span-2 md:col-span-1',
@@ -438,17 +477,12 @@ const [Form, formApi] = useVbenForm({
}); });
const [Drawer, drawerApi] = useVbenDrawer({ const [Drawer, drawerApi] = useVbenDrawer({
onConfirm: onSubmit, onConfirm: onSubmit,
onOpenChange(isOpen) { async onOpenChange(isOpen) {
if (isOpen) { if (isOpen) {
const data = drawerApi.getData<SystemMenuApi.SystemMenu>(); const data = drawerApi.getData<SystemMenuApi.SystemMenu>();
if (data?.type === 'link') {
data.linkSrc = data.meta?.link;
} else if (data?.type === 'embedded') {
data.linkSrc = data.meta?.iframeSrc;
}
if (data) { if (data) {
formData.value = data; formData.value = data;
formApi.setValues(formData.value); await formApi.setSubmitValues(data);
titleSuffix.value = formData.value.meta?.title titleSuffix.value = formData.value.meta?.title
? $t(formData.value.meta.title) ? $t(formData.value.meta.title)
: ''; : '';
@@ -464,16 +498,7 @@ async function onSubmit() {
const { valid } = await formApi.validate(); const { valid } = await formApi.validate();
if (valid) { if (valid) {
drawerApi.lock(); drawerApi.lock();
const data = const data = await formApi.getValues();
await formApi.getValues<
Omit<SystemMenuApi.SystemMenu, 'children' | 'id'>
>();
if (data.type === 'link') {
data.meta = { ...data.meta, link: data.linkSrc };
} else if (data.type === 'embedded') {
data.meta = { ...data.meta, iframeSrc: data.linkSrc };
}
delete data.linkSrc;
try { try {
await (formData.value?.id await (formData.value?.id
? updateMenu(formData.value.id, data) ? updateMenu(formData.value.id, data)

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { Recordable } from '@vben/types'; import type { Recordable } from '@vben/types';
import type { import type {
@@ -11,6 +13,7 @@ import { Page, useVbenDrawer } from '@vben/common-ui';
import { Plus } from '@vben/icons'; import { Plus } from '@vben/icons';
import { Button, message, Modal } from 'antdv-next'; import { Button, message, Modal } from 'antdv-next';
import dayjs from 'dayjs';
import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteRole, getRoleList, updateRole } from '#/api'; import { deleteRole, getRoleList, updateRole } from '#/api';
@@ -19,6 +22,33 @@ import { $t } from '#/locales';
import { useColumns, useGridFormSchema } from './data'; import { useColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue'; import Form from './modules/form.vue';
interface RoleSearchFormValues extends Record<string, unknown> {
createTime?: [Dayjs, Dayjs];
}
function encodeRoleSearchValues(values: Readonly<RoleSearchFormValues>) {
const { createTime, ...formValues } = values;
return {
...formValues,
endTime: createTime?.[1]?.format('YYYY-MM-DD'),
startTime: createTime?.[0]?.format('YYYY-MM-DD'),
};
}
type RoleSearchSubmitValues = ReturnType<typeof encodeRoleSearchValues>;
function decodeRoleSearchValues(
values: Readonly<RoleSearchSubmitValues>,
): RoleSearchFormValues {
const { endTime, startTime, ...formValues } = values;
return {
...formValues,
...(startTime && endTime
? { createTime: [dayjs(startTime), dayjs(endTime)] }
: {}),
};
}
const [FormDrawer, formDrawerApi] = useVbenDrawer({ const [FormDrawer, formDrawerApi] = useVbenDrawer({
connectedComponent: Form, connectedComponent: Form,
destroyOnClose: true, destroyOnClose: true,
@@ -26,7 +56,10 @@ const [FormDrawer, formDrawerApi] = useVbenDrawer({
const [Grid, gridApi] = useVbenVxeGrid({ const [Grid, gridApi] = useVbenVxeGrid({
formOptions: { formOptions: {
fieldMappingTime: [['createTime', ['startTime', 'endTime']]], codec: {
decode: decodeRoleSearchValues,
encode: encodeRoleSearchValues,
},
schema: useGridFormSchema(), schema: useGridFormSchema(),
submitOnChange: true, submitOnChange: true,
}, },
@@ -36,7 +69,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
keepSource: true, keepSource: true,
proxyConfig: { proxyConfig: {
ajax: { ajax: {
query: async ({ page }, formValues) => { query: async ({ page }, formValues: RoleSearchSubmitValues) => {
return await getRoleList({ return await getRoleList({
page: page.currentPage, page: page.currentPage,
pageSize: page.pageSize, pageSize: page.pageSize,

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { Recordable } from '@vben/types'; import type { Recordable } from '@vben/types';
import type { VxeTableGridOptions } from '#/adapter/vxe-table'; import type { VxeTableGridOptions } from '#/adapter/vxe-table';
@@ -10,6 +12,7 @@ import { Page, Tree, useVbenDrawer } from '@vben/common-ui';
import { Plus } from '@vben/icons'; import { Plus } from '@vben/icons';
import { Button, Card, InputSearch, message, Modal } from 'antdv-next'; import { Button, Card, InputSearch, message, Modal } from 'antdv-next';
import dayjs from 'dayjs';
import { useVbenVxeGrid, VbenTableAction } from '#/adapter/vxe-table'; import { useVbenVxeGrid, VbenTableAction } from '#/adapter/vxe-table';
import { deleteUser, getDeptList, getUserList, updateUser } from '#/api'; import { deleteUser, getDeptList, getUserList, updateUser } from '#/api';
@@ -19,6 +22,33 @@ import { useColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.vue'; import Detail from './modules/detail.vue';
import Form from './modules/form.vue'; import Form from './modules/form.vue';
interface UserSearchFormValues extends Record<string, unknown> {
createTime?: [Dayjs, Dayjs];
}
function encodeUserSearchValues(values: Readonly<UserSearchFormValues>) {
const { createTime, ...formValues } = values;
return {
...formValues,
endTime: createTime?.[1]?.format('YYYY-MM-DD'),
startTime: createTime?.[0]?.format('YYYY-MM-DD'),
};
}
type UserSearchSubmitValues = ReturnType<typeof encodeUserSearchValues>;
function decodeUserSearchValues(
values: Readonly<UserSearchSubmitValues>,
): UserSearchFormValues {
const { endTime, startTime, ...formValues } = values;
return {
...formValues,
...(startTime && endTime
? { createTime: [dayjs(startTime), dayjs(endTime)] }
: {}),
};
}
const deptList = ref<SystemDeptApi.SystemDept[]>([]); const deptList = ref<SystemDeptApi.SystemDept[]>([]);
const inputSearchValue = ref(''); const inputSearchValue = ref('');
const selectedDeptId = ref<string>(''); const selectedDeptId = ref<string>('');
@@ -35,7 +65,10 @@ const [DetailDrawer, detailDrawerApi] = useVbenDrawer({
const [Grid, gridApi] = useVbenVxeGrid({ const [Grid, gridApi] = useVbenVxeGrid({
formOptions: { formOptions: {
fieldMappingTime: [['createTime', ['startTime', 'endTime']]], codec: {
decode: decodeUserSearchValues,
encode: encodeUserSearchValues,
},
schema: useGridFormSchema(), schema: useGridFormSchema(),
submitOnChange: true, submitOnChange: true,
}, },
@@ -45,7 +78,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
keepSource: true, keepSource: true,
proxyConfig: { proxyConfig: {
ajax: { ajax: {
query: async ({ page }, formValues) => { query: async ({ page }, formValues: UserSearchSubmitValues) => {
return await getUserList({ return await getUserList({
page: page.currentPage, page: page.currentPage,
pageSize: page.pageSize, pageSize: page.pageSize,