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
.atomcode
datalog
.playwright-mcp

View File

@@ -42,18 +42,27 @@ async function initSetupVbenForm() {
});
}
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
function useVbenForm<
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 type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = 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 { ComponentPropsMap, ComponentType } from './component';
@@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export const useVbenVxeGrid = <
T extends Record<string, any>,
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';

View File

@@ -41,18 +41,27 @@ async function initSetupVbenForm() {
},
});
}
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
function useVbenForm<
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 type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = 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 { ComponentPropsMap, ComponentType } from './component';
@@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export const useVbenVxeGrid = <
T extends Record<string, any>,
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';

View File

@@ -34,18 +34,27 @@ async function initSetupVbenForm() {
});
}
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
function useVbenForm<
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 type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = 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 { ComponentPropsMap, ComponentType } from './component';
@@ -71,8 +72,23 @@ setupVbenVxeTable({
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export const useVbenVxeGrid = <
T extends Record<string, any>,
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';

View File

@@ -38,18 +38,27 @@ async function initSetupVbenForm() {
});
}
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
function useVbenForm<
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 type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = 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 { ComponentPropsMap, ComponentType } from './component';
@@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export const useVbenVxeGrid = <
T extends Record<string, any>,
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';

View File

@@ -42,18 +42,27 @@ async function initSetupVbenForm() {
});
}
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
function useVbenForm<
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 type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = 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 { ComponentPropsMap, ComponentType } from './component';
@@ -70,8 +71,23 @@ setupVbenVxeTable({
useVbenForm,
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export const useVbenVxeGrid = <
T extends Record<string, any>,
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';

View File

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

View File

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

View File

@@ -1,47 +1,80 @@
<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 { useVbenForm } from '#/adapter/form';
const transformedValues = ref<Record<string, any>>({});
const liveValues = ref<Record<string, any>>({});
interface ValueFormatFormValues {
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({
codec: {
decode: decodeValueFormatValues,
encode: encodeValueFormatValues,
},
commonConfig: {
componentProps: {
class: 'w-full',
},
},
handleSubmit,
handleValuesChange,
schema: [
{
component: 'RangePicker',
fieldName: 'reportRange',
help: '通过 setValue 拆分为 startTime / endTime并移除原字段',
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',
fieldName: 'firstName',
help: '与姓氏一起编码为 fullName',
label: '名字',
},
{
component: 'Input',
fieldName: 'lastName',
help: '与名字一起编码为 fullName',
label: '姓氏',
},
{
component: 'Select',
componentProps: {
placeholder: '请输入关键字',
mode: 'multiple',
options: [
{ label: '管理员', value: 'admin' },
{ label: '审核员', value: 'reviewer' },
{ label: '访客', value: 'guest' },
],
placeholder: '请选择标签',
},
fieldName: 'keyword',
label: '关键字',
fieldName: 'tags',
help: '数组编码为逗号分隔字符串',
label: '标签',
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2',
@@ -53,22 +86,8 @@ const transformedValuesPreview = computed(() => {
return formatJsonPreview(transformedValues.value);
});
function formatJsonPreview(value: Record<string, any>) {
return JSON.stringify(
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;
function formatJsonPreview(value: unknown) {
return JSON.stringify(value, null, 2);
}
async function handleInspectValues() {
@@ -76,44 +95,54 @@ async function handleInspectValues() {
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;
message.success({
content: `getValues output: ${JSON.stringify(values)}`,
});
}
async function syncPreviewValues(values?: Record<string, any>) {
liveValues.value = values ?? formApi.form?.values ?? {};
function handleValuesChange(
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();
}
onMounted(async () => {
await nextTick();
watch(
() => formApi.form?.values,
async (values) => {
await syncPreviewValues(values);
},
{
deep: true,
immediate: true,
},
);
await syncPreviewValues();
});
</script>
<template>
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<Tag color="processing">return 回写当前字段</Tag>
<Tag color="success">setValue拆分写入其他字段</Tag>
<Tag color="warning">return undefined保持原字段删除</Tag>
<Tag color="processing">encode生成完整提交值</Tag>
<Tag color="success">decode恢复完整表单值</Tag>
<Tag color="warning">多字段转换原子执行</Tag>
</div>
<Card title="valueFormat 示例">
<Card title="Codec 示例">
<template #extra>
<Space wrap>
<Button @click="handleSetSubmitValues">从提交值回填</Button>
<Button type="primary" @click="handleInspectValues">
查看 getValues 输出
</Button>
@@ -128,7 +157,7 @@ onMounted(async () => {
liveValuesPreview
}}</pre>
</Card>
<Card title="getValues / submit 输出(valueFormat 后)">
<Card title="getValues / submit 输出(codec.encode 后)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
transformedValuesPreview
}}</pre>

View File

@@ -207,7 +207,7 @@ Create the form through `useVbenForm`:
## 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
<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.
## 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
- call `setValue(key, nextValue)` to write derived fields
- return `undefined` to keep the original field removed after decomposition
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:
```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" />
`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
- `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`
- `resetForm`, `submitForm`, `validateAndSubmitForm`, and `resetValidate` remain deprecated aliases that warn once in development
- `clearValidation` invalidates in-flight async results before clearing errors
- `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
- `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
- `fieldMappingTime` and `scrollToFirstError` are part of the current form props
- `schema.valueFormat` lets `getValues()` transform UI values into backend-friendly payloads
- `fieldMappingTime`, `arrayToStringFields`, and `schema.valueFormat` are deprecated compatibility options
- `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
- 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

View File

@@ -23,7 +23,7 @@ The following Vben APIs remain supported:
- `useVbenForm(options)` returning `[Form, formApi]`
- 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
- 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. |
| `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. |
| `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. |
| `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. |
| `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. |
| 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. |
| `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
@@ -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.
- 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.
- 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
@@ -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.
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:
@@ -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`.
`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

View File

@@ -23,7 +23,7 @@ outline: deep
- `useVbenForm(options)` 仍返回 `[Form, formApi]`
- `FormApi` 的值、校验、提交、重置、schema 更新和组件引用能力
- `FormSchema``fieldName``component``componentProps``rules``dependencies``defaultValue``valueFormat` 和数组字段结构
- `FormSchema``fieldName``component``componentProps``rules``dependencies``defaultValue`已弃用的 `valueFormat` 和数组字段结构
- `dependencies.triggerFields` 与回调参数
- 组件适配器和 `z` 重导出路径
- 自定义 slot 中原有的 `componentField` 绑定对象
@@ -43,9 +43,9 @@ outline: deep
| `useFieldValue(fieldName)` | `FormContextApi` | 订阅单字段值,避免无关字段变化触发组件更新。 |
| `useFieldValues(fieldNames)` | `FormContextApi` | 订阅一组字段值,主要用于声明式依赖计算。 |
| `useFieldError(fieldName)` | `FormContextApi` | 订阅单字段错误,不再依赖全量错误对象。 |
| `getRawValues()` | `FormApi` | 返回未执行字段映射与 `valueFormat` 的独立原始值快照。 |
| `getRawValues()` | `FormApi` | 返回未执行 codec 或旧格式化管道的独立表单值快照。 |
| `formatValues(rawValues)` | `FormApi` | 对指定原始值执行统一格式化流水线。 |
| `getValueSnapshot()` | `FormApi` | 同时返回 `{ rawValues, values }`,其中 `values`格式化结果。 |
| `getValueSnapshot()` | `FormApi` | 同时返回 `{ rawValues, values }`,其中 `values` `TSubmitValues`。 |
| `asyncDebounceMs` | `FormFieldOptions` | 设置 TanStack Field 异步校验防抖时间。 |
| `changeEventFallback` | `FormCommonConfig` / adapter config | 为只发送 `change`、不发送 `update:*` 的旧组件启用事件回退,默认 `false`。 |
@@ -61,7 +61,7 @@ outline: deep
| change 事件兼容 | `disabledOnChangeListener: false` 表示启用 | `changeEventFallback: true` 表示启用,改为正向语义。 |
| 顶层动态渲染回调 | `componentProps(values, actions, ctx)``help(values, actions, ctx)``renderComponentContent(values, actions, ctx)` | 仅接收轻量 `FormSchemaContext`。依赖表单值的动态逻辑迁移到 `dependencies.resolve`。 |
| `validateAndSubmit()` | 自行调用底层校验并重复实现错误滚动,提交阶段可能再次校验 | 委托统一 `validate()` 与共享提交逻辑,无效时不提交,错误滚动只有一个实现。 |
| `getValues()` | 隐式完成所有字段转换 | 语义保持为“返回格式化值”;需要原始值时显式使用 `getRawValues()`。 |
| `getValues()` | 隐式完成所有字段转换 | 返回 codec 编码后的 `TSubmitValues`;无 codec 时保持旧格式化行为。 |
### 删除的 API
@@ -87,7 +87,7 @@ outline: deep
- 字段组件改用细粒度 value/error selector全量错误聚合退出普通输入热路径。
- async validator 通过 Vben generation 丢弃过期 Promise不读取 TanStack 私有 AbortController 或 meta 字段。
- 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` 处理。
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
@@ -245,7 +245,7 @@ shadcn form primitive 使用 Vben 自有字段上下文,不再注入 vee 的 `
`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 { FormApi } from '../src/form-api';
@@ -106,6 +108,99 @@ describe('formApi', () => {
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 () => {
formApi.setState({
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 { setupVbenForm } from '../src/config';
@@ -14,6 +16,83 @@ afterEach(() => {
});
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 () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const legacyRule = () => 'legacy error';

View File

@@ -653,4 +653,181 @@ describe('useVbenForm integration', () => {
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,
FormItemDependencies,
FormValidationResult,
FormValueSnapshot,
VbenFormAdapterOptions,
VbenFormProps,
} from '../src/types';
@@ -22,6 +23,12 @@ interface AccountFormValues {
roles: string[];
}
interface AccountSubmitValues {
email: string;
nickname: string;
roles: string;
}
describe('form public types', () => {
it('keeps the compatibility alias and stable method signatures', () => {
expectTypeOf<FormActions>().toEqualTypeOf<FormContextApi>();
@@ -142,6 +149,96 @@ describe('form public types', () => {
>().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', () => {
expectTypeOf<FormContextApi['reset']>().toEqualTypeOf<
FormContextApi['resetForm']

View File

@@ -29,14 +29,16 @@ import {
import { warnDeprecatedOnce } from './deprecation';
import { resolveFieldNamePath } from './field-name';
import { decodeFormValues, encodeFormValues } from './form-codec';
import { updateFormSchemaList } from './form-render/schema';
import { formatFormValues } from './form-value-transform';
type FormApiProps<
TValues extends FormValues,
TFormValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> = VbenFormProps<T, P, TValues>;
TSubmitValues extends FormValues,
> = VbenFormProps<T, P, TFormValues, TSubmitValues>;
type FormApiSchema<
TValues extends FormValues,
@@ -45,10 +47,11 @@ type FormApiSchema<
> = FormSchema<T, P, TValues>;
function getDefaultState<
TValues extends FormValues,
TFormValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
>(): FormApiProps<TValues, T, P> {
TSubmitValues extends FormValues,
>(): FormApiProps<TFormValues, T, P, TSubmitValues> {
return {
actionWrapperClass: '',
collapsed: false,
@@ -73,18 +76,19 @@ function getDefaultState<
}
export class FormApi<
TValues extends FormValues = FormValues,
TFormValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TFormValues,
> {
// private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
public form = {} as FormActions<TValues>;
public form = {} as FormActions<TFormValues>;
isMounted = false;
public state: FormApiProps<TValues, T, P> | null = null;
public state: FormApiProps<TFormValues, T, P, TSubmitValues> | null = null;
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 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 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,
...storeState,
});
@@ -118,21 +128,31 @@ export class FormApi<
}
async clearValidation(
fieldNames?: FormFieldName<TValues> | FormFieldName<TValues>[],
fieldNames?: FormFieldName<TFormValues> | FormFieldName<TFormValues>[],
) {
const form = await this.getForm();
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>,
) {
): TResult;
formatValues(rawValues: Readonly<FormValues>): FormValues {
this.warnLegacyValueTransforms();
if (this.state?.codec) {
return encodeFormValues(
this.state.codec,
rawValues as Readonly<TFormValues>,
);
}
return formatFormValues(
toRaw(rawValues),
this.state?.schema ?? [],
this.state?.fieldMappingTime,
this.state?.arrayToStringFields,
) as TResult;
);
}
/**
@@ -196,41 +216,52 @@ export class FormApi<
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();
return cloneDeep(toRaw(form.values ?? {})) as unknown as TResult;
return cloneDeep(toRaw(form.values ?? {}));
}
getState() {
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();
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>
> {
const rawValues = await this.getRawValues<TResult>();
>;
async getValueSnapshot(): Promise<FormValueSnapshot> {
const rawValues = await this.getRawValues();
return {
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();
return form.isFieldValid(fieldName);
}
merge(formApi: FormApi<any, any, any>) {
merge(formApi: FormApi<any, any, any, any>) {
const chain = [this, formApi];
const proxy = new Proxy(formApi, {
get(target: any, prop: any) {
if (prop === 'merge') {
return (nextFormApi: FormApi<any, any, any>) => {
return (nextFormApi: FormApi<any, any, any, any>) => {
chain.push(nextFormApi);
return proxy;
};
@@ -266,7 +297,7 @@ export class FormApi<
}
mount(
formActions: FormActions<TValues>,
formActions: FormActions<TFormValues>,
componentRefMap?: Map<string, unknown>,
) {
if (!this.isMounted) {
@@ -275,9 +306,7 @@ export class FormApi<
const initialValues = this.form.values
? this.formatValues(toRaw(this.form.values))
: {};
this.setLatestSubmissionValues({
...initialValues,
} as Partial<TValues>);
this.setLatestSubmissionValues(initialValues);
this.componentRefMap =
componentRefMap ?? this.componentRefMap ?? new Map();
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();
return form.reset(state, opts);
}
/** @deprecated Use `reset` instead. */
async resetForm(state?: FormResetState<TValues>, opts?: FormResetOptions) {
async resetForm(
state?: FormResetState<TFormValues>,
opts?: FormResetOptions,
) {
warnDeprecatedOnce(
'form-api-reset-form',
'[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,
value: FormFieldValue<TValues, NoInfer<TFieldName>>,
value: FormFieldValue<TFormValues, NoInfer<TFieldName>>,
shouldValidate?: boolean,
) {
const form = await this.getForm();
await form.setFieldValue(field, value, shouldValidate);
}
setLatestSubmissionValues(values: null | Partial<TValues>) {
setLatestSubmissionValues(values: null | Partial<TSubmitValues>) {
this.latestSubmissionValues = {
...toRaw(values),
} as Partial<TValues>;
} as Partial<TSubmitValues>;
}
setState(
stateOrFn:
| ((
prev: FormApiProps<TValues, T, P>,
) => Partial<FormApiProps<TValues, T, P>>)
| Partial<FormApiProps<TValues, T, P>>,
prev: FormApiProps<TFormValues, T, P, TSubmitValues>,
) => Partial<FormApiProps<TFormValues, T, P, TSubmitValues>>)
| Partial<FormApiProps<TFormValues, T, P, TSubmitValues>>,
) {
if (isFunction(stateOrFn)) {
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
@@ -397,7 +444,7 @@ export class FormApi<
* @param shouldValidate
*/
async setValues(
fields: Partial<TValues>,
fields: Partial<TFormValues>,
filterFields: boolean = true,
shouldValidate: boolean = false,
) {
@@ -445,8 +492,8 @@ export class FormApi<
}
return result;
};
const filteredFields = filterValue(fields) as Partial<TValues>;
form.setValues(filteredFields as Partial<TValues>, shouldValidate);
const filteredFields = filterValue(fields) as Partial<TFormValues>;
form.setValues(filteredFields as Partial<TFormValues>, shouldValidate);
}
async submit(e?: Event) {
@@ -475,8 +522,8 @@ export class FormApi<
this.stateHandler.reset();
}
updateSchema(schema: Partial<FormApiSchema<TValues, T, P>>[]) {
const updated: Partial<FormApiSchema<TValues, T, P>>[] = [...schema];
updateSchema(schema: Partial<FormApiSchema<TFormValues, T, P>>[]) {
const updated: Partial<FormApiSchema<TFormValues, T, P>>[] = [...schema];
const hasField = updated.every(
(item) => Reflect.has(item, 'fieldName') && item.fieldName,
);
@@ -523,7 +570,7 @@ export class FormApi<
return this.validateAndSubmit();
}
async validateField(fieldName: FormFieldName<TValues>) {
async validateField(fieldName: FormFieldName<TFormValues>) {
const form = await this.getForm();
const validateResult = await form.validateField(fieldName);
@@ -568,9 +615,74 @@ export class FormApi<
for (const schema of deletedSchema) {
this.form?.setFieldValue?.(
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(() => {
return props.form
? {
onSubmit: props.form.handleSubmit((val) => emits('submit', val)),
onSubmit: props.form.handleSubmit(() => emits('submit', undefined)),
}
: {
onSubmit: (event: Event) => {

View File

@@ -10,6 +10,8 @@ import type {
import { computed, shallowRef } from 'vue';
import { mergeWithArrayOverride } from '@vben-core/shared/utils';
import { useForm } from '@tanstack/vue-form';
import { createRuntimeFieldComponent } from './form-runtime-field';
@@ -218,7 +220,19 @@ export function useFormRuntime<TValues extends FormValues>(
invalidateFieldValidation(fieldName);
}
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() {
@@ -244,13 +258,13 @@ export function useFormRuntime<TValues extends FormValues>(
typeof fieldName
>;
},
handleSubmit(callback) {
return async (event) => {
handleSubmit(callback?) {
return async (event?: Event) => {
event?.preventDefault();
event?.stopPropagation();
const result = await validate();
if (result.valid) {
await callback(values.value);
await callback?.(values.value as TValues);
}
};
},

View File

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

View File

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

View File

@@ -17,7 +17,11 @@ type UseVbenFormReturn<
TValues extends FormValues,
T extends BaseFormComponentType,
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<
T extends BaseFormComponentType = BaseFormComponentType,
@@ -28,14 +32,17 @@ export function useVbenForm<
TValues extends FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
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(
options: VbenFormProps<any, any, any>,
): UseVbenFormReturn<any, any, any> {
options: VbenFormProps<any, any, any, any>,
): UseVbenFormReturn<any, any, any, any> {
const IS_REACTIVE = isReactive(options);
const api = new FormApi<any, any, any>(options);
const extendedApi = api as ExtendedFormApi<any, any, any>;
const api = new FormApi<any, any, any, any>(options);
const extendedApi = api as ExtendedFormApi<any, any, any, any>;
extendedApi.useStore = (selector: any) => {
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 会导致热更新卡死,所以重复写了一遍
interface Props extends VbenFormProps {
formApi?: ExtendedFormApi<any, any, any>;
formApi?: ExtendedFormApi<any, any, any, any>;
}
const props = defineProps<Props>();

View File

@@ -43,18 +43,20 @@ const close = () => {
open.value = false;
};
const handleViewAll = () => {
function handleViewAll() {
emit('viewAll');
close();
};
}
const handleMakeAll = () => {
function handleMakeAll() {
emit('makeAll');
};
}
const handleClear = () => {
function handleClear() {
emit('clear');
};
}
defineExpose({ toggle });
</script>
<template>
<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 SelectItem from './select-item.vue';
interface Item {
key: string;
label: string;
@@ -67,14 +69,13 @@ onUnmounted(() => {
sortableInstance = null;
});
function setPosition(key: string, event: Event) {
const value = (event.target as HTMLSelectElement).value as
| 'auto'
| 'fixed'
| 'header'
| 'none'
| 'user-dropdown';
emit('updatePosition', key, value);
function setPosition(key: string, value: string | undefined) {
if (!value) return;
emit(
'updatePosition',
key,
value as 'auto' | 'fixed' | 'header' | 'none' | 'user-dropdown',
);
nextTick(() => {
emit(
'updateOrder',
@@ -91,25 +92,21 @@ function setPosition(key: string, event: Event) {
v-for="item in sortableList"
: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
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>
<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)"
<SelectItem
:items="item.positionItems ?? positionItems"
:model-value="item.position"
class="min-w-0 flex-1"
@update:model-value="
(v: string | undefined) => setPosition(item.key, v)
"
>
<option
v-for="opt in item.positionItems ?? positionItems"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<span class="truncate">{{ item.label }}</span>
</SelectItem>
</div>
</div>
<div v-if="hiddenList.length > 0" class="pt-2">
@@ -119,26 +116,22 @@ function setPosition(key: string, event: Event) {
<div
v-for="item in hiddenList"
:key="item.key"
class="flex items-center gap-2 rounded-md px-2 py-1"
class="flex items-center rounded-md"
>
<span
class="text-muted-foreground min-w-0 flex-1 truncate text-sm line-through decoration-dotted"
<SelectItem
: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>
<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"
<span
class="text-muted-foreground truncate line-through decoration-dotted"
>
{{ opt.label }}
</option>
</select>
{{ item.label }}
</span>
</SelectItem>
</div>
</div>
</div>

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import { computed, ref, useTemplateRef, watch } from 'vue';
import { SUPPORT_LANGUAGES } from '@vben/constants';
import { useHoverToggle, useRefresh } from '@vben/hooks';
import {
createIconifyIcon,
Languages,
LockKeyhole,
LogOut,
@@ -40,7 +41,7 @@ import {
VbenIconButton,
} from '@vben-core/shadcn-ui';
import { useMagicKeys, whenever } from '@vueuse/core';
import { useFullscreen, useMagicKeys, whenever } from '@vueuse/core';
import { GlobalSearch } from '../global-search';
import { LockScreenModal } from '../lock-screen';
@@ -101,9 +102,11 @@ const {
globalLogoutShortcutKey,
globalLockScreenShortcutKey,
globalSearchShortcutKey,
isDark,
preferencesButtonPosition,
} = usePreferences();
const { refresh } = useRefresh();
const { toggle: toggleFullscreen } = useFullscreen();
const accessStore = useAccessStore();
const [LockModal, lockModalApi] = useVbenModal({
connectedComponent: LockScreenModal,
@@ -118,6 +121,10 @@ const refTrigger = useTemplateRef('refTrigger');
const refContent = useTemplateRef('refContent');
const refPreferences = useTemplateRef('refPreferences');
const refGlobalSearch = useTemplateRef('refGlobalSearch');
const refTimezone = useTemplateRef('refTimezone');
const refNotification = useTemplateRef('refNotification');
const TimezoneIcon = createIconifyIcon('fluent-mdl2:world-clock');
const [openPopover, hoverWatcher] = useHoverToggle(
[refTrigger, refContent],
() => props.hoverDelay,
@@ -247,6 +254,28 @@ function handleGlobalSearch() {
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 列表
const showLanguageList = ref(false);
function handleLanguageToggleSelect(event?: Event) {
@@ -322,6 +351,12 @@ if (preferences.shortcutKeys.enable) {
:show-button="false"
/>
<TimezoneButton
v-if="showTimezoneInDropdown"
ref="refTimezone"
:show-button="false"
/>
<DropdownMenu v-model:open="openPopover" :modal="false">
<DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'">
<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
v-if="showThemeToggleInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@select="handleThemeToggleSelect"
>
<ThemeToggle class="mr-2" />
{{ $t('preferences.theme.title') }}
@@ -461,22 +497,27 @@ if (preferences.shortcutKeys.enable) {
<DropdownMenuItem
v-if="showTimezoneInDropdown"
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') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showFullscreenInDropdown"
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') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showNotificationInDropdown"
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') }}
</DropdownMenuItem>
<DropdownMenuItem

View File

@@ -3,6 +3,7 @@ import type { VxeGridInstance } from 'vxe-table';
import type {
BaseFormComponentType,
ExtendedFormApi,
FormValues,
} from '@vben-core/form-ui';
import type { VxeGridProps } from './types';
@@ -34,14 +35,16 @@ export class VxeGridApi<
T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType,
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;
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 中)
@@ -52,12 +55,26 @@ export class VxeGridApi<
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 defaultState = getDefaultState();
this.store = new Store<VxeGridProps<T, D, P>>(
mergeWithArrayOverride(storeState, defaultState) as VxeGridProps<T, D, P>,
this.store = new Store<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>(
mergeWithArrayOverride(storeState, defaultState) as VxeGridProps<
T,
D,
P,
TFormValues,
TSubmitValues
>,
);
this.store.subscribe((state) => {
@@ -106,7 +123,10 @@ export class VxeGridApi<
this.viewedRowHelper?.markAsViewed(record);
}
mount(instance: null | VxeGridInstance, formApi: ExtendedFormApi) {
mount(
instance: null | VxeGridInstance,
formApi: ExtendedFormApi<TFormValues, D, P, TSubmitValues>,
) {
if (!this.isMounted && instance) {
this.grid = instance;
this.formApi = formApi;
@@ -138,7 +158,11 @@ export class VxeGridApi<
this.viewedRowHelper?.removeKeys(keys);
}
setGridOptions(options: Partial<VxeGridProps<T, D, P>['gridOptions']>) {
setGridOptions(
options: Partial<
VxeGridProps<T, D, P, TFormValues, TSubmitValues>['gridOptions']
>,
) {
this.setState({
gridOptions: options,
});
@@ -154,8 +178,10 @@ export class VxeGridApi<
setState(
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)) {
this.store.setState((prev) => {

View File

@@ -10,7 +10,11 @@ import type { Ref } from 'vue';
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';
@@ -124,6 +128,8 @@ export interface VxeGridProps<
T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType,
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,
F extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = VxeGridApi<D, F, P> & {
useStore: <S = NoInfer<VxeGridProps<D, F, P>>>(
selector?: (state: NoInfer<VxeGridProps<D, F, P>>) => S,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = 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>>;
};

View File

@@ -2,7 +2,7 @@ import type { VxeGridSlots, VxeGridSlotTypes } from 'vxe-table';
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';
@@ -23,24 +23,28 @@ export function useVbenVxeGrid<
T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType,
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 api = new VxeGridApi<T, D, P>(options);
const extendedApi: ExtendedVxeGridApi<T, D, P> = api as ExtendedVxeGridApi<
T,
D,
P
>;
const api = new VxeGridApi<T, D, P, TFormValues, TSubmitValues>(options);
const extendedApi: ExtendedVxeGridApi<T, D, P, TFormValues, TSubmitValues> =
api as ExtendedVxeGridApi<T, D, P, TFormValues, TSubmitValues>;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
};
const Grid = defineComponent(
(props: VxeGridProps<T, D, P>, { attrs, slots }) => {
(
props: VxeGridProps<T, D, P, TFormValues, TSubmitValues>,
{ attrs, slots },
) => {
onBeforeUnmount(() => {
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 () =>
h(
VxeGrid,
@@ -48,7 +52,7 @@ export function useVbenVxeGrid<
...props,
...attrs,
api: extendedApi as ExtendedVxeGridApi,
},
} as any,
slots,
);
},

View File

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

View File

@@ -41,18 +41,27 @@ async function initSetupVbenForm() {
});
}
function useVbenForm<TValues extends FormValues = FormValues>(
options: FormProps<ComponentType, ComponentPropsMap, TValues>,
function useVbenForm<
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 type VbenFormSchema<TValues extends FormValues = FormValues> =
FormSchema<ComponentType, ComponentPropsMap, TValues>;
export type VbenFormProps<TValues extends FormValues = FormValues> = FormProps<
ComponentType,
ComponentPropsMap,
TValues
>;
export type VbenFormProps<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = 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 { Recordable } from '@vben/types';
@@ -287,9 +287,24 @@ setupVbenVxeTable({
},
});
export const useVbenVxeGrid = <T extends Record<string, any>>(
...rest: Parameters<typeof useGrid<T, ComponentType, ComponentPropsMap>>
) => useGrid<T, ComponentType, ComponentPropsMap>(...rest);
export const useVbenVxeGrid = <
T extends Record<string, any>,
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`
- `suffix`
- `renderComponentContent`
- `valueFormat`
- `formFieldProps`
- `disabled`
- `hide`
@@ -188,29 +187,29 @@ dependencies: {
triggerFields: ['$row.role'];
```
## valueFormat 用法
## 提交值转换
children 里的 `valueFormat` 也会按行执行
数组字段的提交转换使用表单级 `codec`,一次处理完整表单值
```ts
{
component: 'Input',
fieldName: 'phone',
label: '电话',
valueFormat: (value, setValue) => {
const nextValue = value?.trim();
if (!nextValue) {
return;
}
setValue('phone', nextValue);
},
function encodeArrayFormValues(values: Readonly<ArrayFormValues>) {
return {
...values,
contacts: values.contacts.map((contact) => ({
...contact,
name: contact.name.trim(),
phone: contact.phone?.trim() || undefined,
})),
};
}
```
`contacts[0]` 中,`setValue('phone', nextValue)` 会自动写到:
```ts
contacts[0].phone;
const [Form] = useVbenForm({
codec: {
decode: decodeArrayFormValues,
encode: encodeArrayFormValues,
},
schema,
});
```
如果要写根字段,用 `$root.`
@@ -284,7 +283,7 @@ flowchart TD
H --> I["createArrayChildSchema"]
I --> J["child.fieldName 转为 contacts[index].xxx"]
I --> K["scope dependencies triggerFields"]
I --> L["包装 componentProps/help/render/valueFormat ctx"]
I --> L["包装 componentProps/help/render ctx"]
J --> M["继续复用 FormField 渲染 child"]
```
@@ -294,7 +293,7 @@ flowchart TD
- 具体展示仍复用内部 `VbenFormFieldArray`
- child 最终仍然走 `FormField`,所以现有 FormSchema 能力不会丢。
- `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';
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(() =>
JSON.stringify(submitValues.value, null, 2),
);
@@ -23,7 +64,7 @@ const outputClass = [
'text-xs',
];
const schema: VbenFormSchema[] = [
const schema: VbenFormSchema<ArrayFormValues>[] = [
{
component: 'Input',
componentProps: {
@@ -38,7 +79,7 @@ const schema: VbenFormSchema[] = [
component: 'Textarea',
dependencies: {
componentProps: (values) => {
const planName = values.planName as string | undefined;
const planName = values.planName;
return {
disabled: !planName,
placeholder: planName ? `${planName} 的补充说明` : '请先填写方案名称',
@@ -81,7 +122,6 @@ const schema: VbenFormSchema[] = [
fieldName: 'name',
label: '姓名',
rules: z.string().min(1, '请输入姓名'),
valueFormat: (value) => value?.trim(),
},
{
component: 'Select',
@@ -110,13 +150,6 @@ const schema: VbenFormSchema[] = [
fieldName: 'phone',
label: '电话',
rules: z.string().optional(),
valueFormat: (value, setValue) => {
const nextValue = value?.trim();
if (!nextValue) {
return;
}
setValue('phone', nextValue);
},
},
{
component: 'Switch',
@@ -146,6 +179,10 @@ const schema: VbenFormSchema[] = [
];
const [Form, formApi] = useVbenForm({
codec: {
decode: decodeArrayFormValues,
encode: encodeArrayFormValues,
},
commonConfig: {
labelWidth: 90,
},

View File

@@ -1,5 +1,6 @@
<script lang="ts" setup>
import type { UploadFile } from 'antdv-next';
import type { Dayjs } from 'dayjs';
import { h, ref, toRaw } from 'vue';
@@ -18,6 +19,36 @@ import DocButton from '../doc-button.vue';
const keyword = ref('');
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>) {
fetching.value = true;
@@ -34,6 +65,10 @@ function fetchRemoteOptions({ keyword = '选项' }: Record<string, any>) {
}
const [BaseForm, baseFormApi] = useVbenForm({
codec: {
decode: decodeBasicFormValues,
encode: encodeBasicFormValues,
},
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 在label后显示一个冒号
@@ -43,7 +78,6 @@ const [BaseForm, baseFormApi] = useVbenForm({
class: 'w-full',
},
},
fieldMappingTime: [['rangePicker', ['startTime', 'endTime'], 'YYYY-MM-DD']],
// 提交函数
handleSubmit: onSubmit,
handleValuesChange(_values, fieldsChanged) {
@@ -425,8 +459,8 @@ const [BaseForm, baseFormApi] = useVbenForm({
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
});
function onSubmit(values: Record<string, any>) {
const files = toRaw(values.files) as UploadFile[];
function onSubmit(values: BasicSubmitValues) {
const files = (toRaw(values.files) ?? []) as UploadFile[];
const cropImage = (toRaw(values.cropImage) ?? []) as UploadFile[];
const doneFiles = 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';
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({
codec: {
decode: decodeCustomFormValues,
encode: encodeCustomFormValues,
},
// 所有表单项共用,可单独在表单内覆盖
commonConfig: {
// 所有表单项
@@ -18,7 +51,6 @@ const [Form] = useVbenForm({
},
labelClass: 'w-2/6',
},
fieldMappingTime: [['field4', ['phoneType', 'phoneNumber'], null]],
// 提交函数
handleSubmit: onSubmit,
// 垂直布局label和input在不同行值为vertical
@@ -80,7 +112,7 @@ const [Form] = useVbenForm({
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
function onSubmit(values: Record<string, any>) {
function onSubmit(values: CustomSubmitValues) {
message.success({
content: `form values: ${JSON.stringify(values)}`,
});

View File

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

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { VbenFormProps } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
@@ -19,10 +21,42 @@ interface RowType {
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,
fieldMappingTime: [['date', ['start', 'end']]],
schema: [
{
component: 'Input',
@@ -94,7 +128,7 @@ const gridOptions: VxeTableGridOptions<RowType> = {
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
query: async ({ page }, formValues: SearchSubmitValues) => {
message.success(`Query params: ${JSON.stringify(formValues)}`);
return await getExampleTableApi({
page: page.currentPage,

View File

@@ -31,7 +31,42 @@ const emit = defineEmits<{
}>();
const formData = ref<SystemMenuApi.SystemMenu>();
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',
componentProps: {
@@ -428,6 +463,10 @@ const breakpoints = useBreakpoints(breakpointsTailwind);
const isHorizontal = computed(() => breakpoints.greaterOrEqual('md').value);
const [Form, formApi] = useVbenForm({
codec: {
decode: decodeMenuFormValues,
encode: encodeMenuFormValues,
},
commonConfig: {
colon: true,
formItemClass: 'col-span-2 md:col-span-1',
@@ -438,17 +477,12 @@ const [Form, formApi] = useVbenForm({
});
const [Drawer, drawerApi] = useVbenDrawer({
onConfirm: onSubmit,
onOpenChange(isOpen) {
async onOpenChange(isOpen) {
if (isOpen) {
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) {
formData.value = data;
formApi.setValues(formData.value);
await formApi.setSubmitValues(data);
titleSuffix.value = formData.value.meta?.title
? $t(formData.value.meta.title)
: '';
@@ -464,16 +498,7 @@ async function onSubmit() {
const { valid } = await formApi.validate();
if (valid) {
drawerApi.lock();
const data =
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;
const data = await formApi.getValues();
try {
await (formData.value?.id
? updateMenu(formData.value.id, data)

View File

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

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup>
import type { Dayjs } from 'dayjs';
import type { Recordable } from '@vben/types';
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 { Button, Card, InputSearch, message, Modal } from 'antdv-next';
import dayjs from 'dayjs';
import { useVbenVxeGrid, VbenTableAction } from '#/adapter/vxe-table';
import { deleteUser, getDeptList, getUserList, updateUser } from '#/api';
@@ -19,6 +22,33 @@ import { useColumns, useGridFormSchema } from './data';
import Detail from './modules/detail.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 inputSearchValue = ref('');
const selectedDeptId = ref<string>('');
@@ -35,7 +65,10 @@ const [DetailDrawer, detailDrawerApi] = useVbenDrawer({
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
fieldMappingTime: [['createTime', ['startTime', 'endTime']]],
codec: {
decode: decodeUserSearchValues,
encode: encodeUserSearchValues,
},
schema: useGridFormSchema(),
submitOnChange: true,
},
@@ -45,7 +78,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
query: async ({ page }, formValues: UserSearchSubmitValues) => {
return await getUserList({
page: page.currentPage,
pageSize: page.pageSize,