feat(@vben-core/form-ui): add typed form value codecs (#8189)

* feat(@vben-core/form-ui): add typed form value codecs

* test(@vben-core/form-ui): cover codec value boundaries

* refactor(project): propagate form codec generics

* feat(@vben/plugins): propagate form codecs through vxe grids

* refactor(@vben/playground): migrate form codec examples

* refactor(@vben/playground): migrate query forms to codecs

* docs(@vben/docs): document form value codecs
This commit is contained in:
dream-weave
2026-07-23 15:09:31 +08:00
committed by GitHub
parent f43de252b5
commit 98dea92225
43 changed files with 1678 additions and 413 deletions

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>