feat(form-ui): support schema valueFormat for getValues payload shaping (#7804)

* feat(@vben-core/form-ui): support schema valueFormat on getValues

Some form fields emit UI-friendly structures such as time-range arrays,
while consumers and backend APIs often need a different payload shape.
This adds schema-level `valueFormat` hooks so `getValues()` can
normalize field output at read time without forcing callers to
post-process every submission path.

Constraint: Must preserve existing range-time mapping and nested field behavior
Constraint: Must not mutate live vee-validate form state while formatting output
Rejected: Global formatter config | too coarse for per-field payload shaping
Rejected: Post-submit-only transform | misses reset/query/change handlers
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep `getValues()` output derivation side-effect free
Directive: Clone raw form values before formatting derived payloads
Tested: vitest form-api test for valueFormat and existing getValues paths
Tested: oxlint on changed form-ui source and test files
Not-tested: Full repo typecheck baseline has unrelated .vue module resolution errors

* fix(@vben-core/form-ui): restore mount compatibility and share field path parsing

Follow-up review found two real regressions and one missing assertion in the
new value formatting flow. `FormApi.mount()` had become breaking by requiring
`componentRefMap`, and delete path resolution duplicated field-name parsing
instead of sharing the reader grammar. This patch restores backward
compatibility, centralizes field-name path parsing, and extends the test to
prove formatting does not mutate live form values.

Constraint: Must preserve current valueFormat behavior and nested field support
Constraint: Must not reintroduce mutation of live vee-validate values
Rejected: Keep duplicated delete parsing | risks grammar drift from read path
Rejected: Only loosen mount tests | would leave consumer-facing API breakage
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Reuse shared field-name parsing for read/delete semantics in form-ui
Tested: vitest form-api test suite
Tested: oxlint on changed form-ui files
Not-tested: Full repo typecheck baseline has unrelated .vue module resolution errors
EOF && git push hekx feature-form-value-format

* fix(@vben-core/form-ui): clear stale component refs on unmount

A follow-up review found that `unmount()` left the private component ref map
populated. Because `mount()` now accepts an optional `componentRefMap`, a later
mount without a new map could silently reuse stale refs from a prior form
instance. This change clears the ref map on unmount and adds a regression test
covering remount behavior without a new ref map.

Constraint: Must preserve backward-compatible optional `mount()` ref map behavior
Constraint: Focus and field-ref lookups must not observe stale refs after unmount
Rejected: Clear refs only during next mount | stale state would still leak between lifecycle calls
Rejected: Remove mount fallback entirely | would undo the compatibility fix
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: When mount falls back to internal refs, unmount must always reset that cache
Tested: vitest form-api test suite
Tested: oxlint on changed form-api source and test files
Not-tested: Full repo typecheck baseline has unrelated .vue module resolution errors

* refactor(@vben-core/form-ui): trim redundant valueFormat plumbing

Review feedback identified a few small cleanups in the value formatting path.
This removes an unnecessary shallow clone in `getValues()`, reuses the
already-parsed `rawKey` from `resolveFieldNamePath()` instead of re-resolving
it in multiple helpers, and clarifies the `FormValueFormat` contract for
undefined-as-delete decomposition behavior.

Constraint: Must not change runtime valueFormat behavior or payload shape
Constraint: Documentation and helper cleanup should stay behavior-preserving
Rejected: Leave duplicate raw-key resolution in place | adds needless parsing churn
Rejected: Expand the formatter API further | outside the scope of this cleanup
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep read/format helper plumbing lean and avoid duplicate field-name parsing
Tested: vitest form-api test suite
Tested: oxlint on changed form-ui source and test files
Not-tested: Full repo typecheck baseline has unrelated .vue module resolution errors

* feat(@vben-core/form-ui): document valueFormat with live examples

The new `valueFormat` feature needed a concrete usage path in both the
playground and the docs so users can understand how raw component values differ
from the final payload returned by `getValues()`. This adds a dedicated form
example, wires it into the playground menu, and documents the API with an
interactive docs demo. The preview panels now stay in sync when values are set,
reset, or submitted.

Constraint: Must demonstrate both return-value and setValue decomposition flows
Constraint: Example previews must react to setValues, reset, and manual edits
Rejected: Only document via markdown snippet | insufficient for verifying live payload behavior
Rejected: Reuse an existing basic form page | would bury feature-specific behavior
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep playground and docs demos behaviorally aligned when extending valueFormat examples
Tested: eslint on playground/docs valueFormat demo files and route module
Tested: oxlint on playground route module
Not-tested: Full docs/playground app runtime was not launched in this session

* chore(@vben-core/form-ui): normalize valueFormat demo formatting

The previous feature/docs commit left a few formatter-only adjustments unstaged
after hooks rewrote line wrapping in the new demo and docs pages. This commit
captures those final non-behavioral formatting updates so the branch matches the
current working tree.

Constraint: Must not change runtime behavior or docs meaning
Rejected: Leave post-hook diffs unstaged | branch would not reflect local state
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: After hook-driven rewrites, verify the working tree is clean before final push
Tested: Git diff inspection of remaining changes
Not-tested: No additional runtime verification needed; formatting-only follow-up
EOF && git push hekx feature-form-value-format

* fix(@vben-core/form-ui): remove docs demo dayjs dependency

The docs valueFormat demo imported `dayjs` directly even though the docs
package does not declare it as a dependency. That caused `@vben/docs:build`
to fail in CI during VitePress bundling. This change removes the direct
import, keeps the preview formatter generic for day-like values, and drops
the docs-only preset button that required constructing dayjs instances.

Constraint: Docs build must succeed without adding new package dependencies
Constraint: Playground example should remain unchanged and fully interactive
Rejected: Add dayjs to docs dependencies | unnecessary for a small display demo
Rejected: Externalize dayjs in VitePress build | hides a package boundary issue
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Docs demos should avoid imports only available through transitive deps
Tested: pnpm exec eslint docs/src/demos/vben-form/value-format/index.vue
Tested: pnpm --dir docs run build
Not-tested: No browser-side manual verification of the docs demo in this session

---------

Co-authored-by: caisin <caisin@caisins-Mac-mini.local>
This commit is contained in:
Caisin
2026-04-13 11:22:04 +08:00
committed by GitHub
parent 6be3a0e204
commit 5b84ac5b13
12 changed files with 601 additions and 11 deletions

View File

@@ -230,6 +230,16 @@ export { initComponentAdapter };
<DemoPreview dir="demos/vben-form/query" />
## 值格式化
当组件的展示值与后端真正需要的 payload 不一致时,可以在 schema 上使用 `valueFormat`。它会在 `getValues()`、提交、以及依赖这些输出的方法中生效。
- `return xxx`:回写当前字段
- `setValue('startTime', xxx)`:写入其他字段
- `return undefined`:保持当前字段已被移除,适合把一个字段拆成多个字段
<DemoPreview dir="demos/vben-form/value-format" />
## 表单校验
表单校验是一个非常重要的功能,可以通过 `rules` 属性进行校验。
@@ -343,6 +353,32 @@ useVbenForm 返回的第二个参数,是一个对象,包含了一些表单
:::
::: 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());
},
}
```
:::
### TS 类型说明
::: details ActionButtonOptions
@@ -464,11 +500,29 @@ export interface FormSchema<
rules?: FormSchemaRuleType;
/** 后缀 */
suffix?: CustomRenderType;
/** 获取 getValues() 输出时格式化当前字段 */
valueFormat?: FormValueFormat;
}
```
:::
::: details FormValueFormat
```ts
type FormValueFormat = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: Record<string, any>,
) => any;
```
- 返回 `undefined`:保持当前字段已被移除
- 返回其他值:将当前字段恢复/写回为该值
- `setValue(fieldName, value)`:用于把一个字段拆分写入其他字段
:::
### 表单联动
表单联动需要通过 schema 内的 `dependencies` 属性进行联动,允许您添加字段之间的依赖项,以根据其他字段的值控制字段。

View File

@@ -0,0 +1,138 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { Button, Card, message, Space, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
const transformedValues = ref<Record<string, any>>({});
const liveValues = ref<Record<string, any>>({});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
},
handleSubmit,
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',
componentProps: {
placeholder: '请输入关键字',
},
fieldName: 'keyword',
label: '关键字',
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
const liveValuesPreview = computed(() => formatJsonPreview(liveValues.value));
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;
}
async function handleInspectValues() {
await syncPreviewValues();
message.success('已刷新 getValues 输出');
}
function handleSubmit(values: Record<string, any>) {
transformedValues.value = values;
message.success({
content: `getValues output: ${JSON.stringify(values)}`,
});
}
async function syncPreviewValues(values?: Record<string, any>) {
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,
},
);
});
</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>
</div>
<Card title="valueFormat 示例">
<template #extra>
<Space wrap>
<Button type="primary" @click="handleInspectValues">
查看 getValues 输出
</Button>
</Space>
</template>
<Form />
</Card>
<div class="grid gap-4 lg:grid-cols-2">
<Card title="原始 form.values组件值">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
liveValuesPreview
}}</pre>
</Card>
<Card title="getValues / submit 输出valueFormat 后)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
transformedValuesPreview
}}</pre>
</Card>
</div>
</div>
</template>

View File

@@ -191,12 +191,23 @@ Create the form through `useVbenForm`:
<DemoPreview dir="demos/vben-form/basic" />
## Value Formatting
Use `schema.valueFormat` when the component value is convenient for the UI but the final payload returned by `getValues()` should use a different shape.
- 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
<DemoPreview dir="demos/vben-form/value-format" />
## Key API Notes
- `useVbenForm` returns `[Form, formApi]`
- `formApi.getFieldComponentRef()` and `formApi.getFocusedField()` are available in current versions
- `handleValuesChange(values, fieldsChanged)` includes the second parameter in newer versions
- `fieldMappingTime` and `scrollToFirstError` are part of the current form props
- `schema.valueFormat` lets `getValues()` transform UI values into backend-friendly payloads
## Reference

View File

@@ -44,6 +44,7 @@ describe('formApi', () => {
await formApi.mount(formActions);
expect(formApi.isMounted).toBe(true);
expect(formApi.form).toEqual(formActions);
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
});
it('should get values from form', async () => {
@@ -52,11 +53,54 @@ describe('formApi', () => {
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
const values = await formApi.getValues();
expect(values).toEqual({ name: 'test' });
});
it('should format schema values when getting values', async () => {
formApi.setState({
schema: [
{
component: 'range-picker',
fieldName: 'filters.range',
valueFormat: (value, setValue) => {
setValue('filters.startTime', value?.[0]);
setValue('filters.endTime', value?.[1]);
},
},
],
});
const formActions: any = {
meta: {},
values: {
filters: {
range: [1_710_000_000_000, 1_720_000_000_000],
},
},
};
const originalValuesSnapshot = structuredClone(formActions.values);
await formApi.mount(formActions, new Map());
expect(formApi.getLatestSubmissionValues()).toEqual({
filters: {
endTime: 1_720_000_000_000,
startTime: 1_710_000_000_000,
},
});
const values = await formApi.getValues();
expect(values).toEqual({
filters: {
endTime: 1_720_000_000_000,
startTime: 1_710_000_000_000,
},
});
expect(formActions.values).toEqual(originalValuesSnapshot);
});
it('should set field value', async () => {
const setFieldValueMock = vi.fn();
const formActions: any = {
@@ -65,7 +109,7 @@ describe('formApi', () => {
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
await formApi.setFieldValue('name', 'new value');
expect(setFieldValueMock).toHaveBeenCalledWith(
'name',
@@ -82,7 +126,7 @@ describe('formApi', () => {
values: { name: 'test' },
};
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
await formApi.resetForm();
expect(resetFormMock).toHaveBeenCalled();
});
@@ -100,7 +144,7 @@ describe('formApi', () => {
};
formApi.setState(state);
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
const result = await formApi.submitForm();
expect(formActions.submitForm).toHaveBeenCalled();
@@ -113,6 +157,39 @@ describe('formApi', () => {
expect(formApi.isMounted).toBe(false);
});
it('should clear component refs on unmount before mounting again', async () => {
const formActions: any = {
meta: {},
resetForm: vi.fn(),
values: { name: 'test' },
};
const staleMap = new Map<string, unknown>([
[
'name',
{
$: {
type: { name: 'MockComponent' },
},
$el: {},
},
],
]);
await formApi.mount(formActions, staleMap);
expect(formApi.getFieldComponentRef('name')).toEqual({
$: {
type: { name: 'MockComponent' },
},
$el: {},
});
formApi.unmount();
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
await formApi.mount(formActions);
expect(formApi.getFieldComponentRef('name')).toBeUndefined();
});
it('should validate form', async () => {
const validateMock = vi.fn().mockResolvedValue(true);
const formActions: any = {
@@ -120,7 +197,7 @@ describe('formApi', () => {
validate: validateMock,
};
await formApi.mount(formActions);
await formApi.mount(formActions, new Map());
const isValid = await formApi.validate();
expect(validateMock).toHaveBeenCalled();
expect(isValid).toBe(true);

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import { get, isBoolean, isFunction } from '@vben-core/shared/utils';
import { useFormValues } from 'vee-validate';
import { resolveFieldNamePath } from '../field-name';
import { injectRenderFormProps } from './context';
/**
@@ -22,8 +23,8 @@ function resolveValueByFieldName(
fieldName: string,
) {
// vee-validate[] 表示禁用嵌套
if (fieldName.startsWith('[') && fieldName.endsWith(']')) {
const rawKey = fieldName.slice(1, -1);
const { rawKey } = resolveFieldNamePath(fieldName);
if (rawKey) {
return values[rawKey];
}

View File

@@ -228,6 +228,19 @@ type MappedComponentProps<P> =
) => P & Record<string, any>)
| (P & Record<string, any>);
/**
* 格式化 `getValues()` 输出中的当前字段值。
* - 返回 `undefined`:保留当前字段已被移除的状态,通常配合 `setValue(key, nextValue)`
* 把一个字段拆分写入到其他字段,例如 `startTime` / `endTime`
* - 返回其他值:会将当前字段恢复/写回为该返回值
* - `setValue` 回调签名为 `(key, nextValue) => void`
*/
export type FormValueFormat = (
value: any,
setValue: (fieldName: string, value: any) => void,
values: Record<string, any>,
) => any;
interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
/** 默认值 */
defaultValue?: any;
@@ -249,6 +262,12 @@ interface FormSchemaBody extends Omit<FormCommonConfig, 'componentProps'> {
rules?: FormSchemaRuleType;
/** 后缀 */
suffix?: CustomRenderType;
/**
* 获取表单值时格式化当前字段。
* - 返回值不为 `undefined` 时,会回写到当前 fieldName
* - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段
*/
valueFormat?: FormValueFormat;
}
type FormSchemaDiscriminated<

View File

@@ -14,6 +14,7 @@
"basic": "Basic Form",
"layout": "Custom Layout",
"query": "Query Form",
"valueFormat": "Value Format",
"rules": "Form Rules",
"dynamic": "Dynamic Form",
"custom": "Custom Component",

View File

@@ -17,6 +17,7 @@
"basic": "基础表单",
"layout": "自定义布局",
"query": "查询表单",
"valueFormat": "值格式化",
"rules": "表单校验",
"dynamic": "动态表单",
"custom": "自定义组件",

View File

@@ -37,6 +37,14 @@ const routes: RouteRecordRaw[] = [
title: $t('examples.form.query'),
},
},
{
name: 'FormValueFormatExample',
path: '/examples/form/value-format',
component: () => import('#/views/examples/form/value-format.vue'),
meta: {
title: $t('examples.form.valueFormat'),
},
},
{
name: 'FormRulesExample',
path: '/examples/form/rules',

View File

@@ -0,0 +1,161 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, message, Space, Tag } from 'ant-design-vue';
import dayjs from 'dayjs';
import { useVbenForm } from '#/adapter/form';
import DocButton from '../doc-button.vue';
const transformedValues = ref<Record<string, any>>({});
const liveValues = ref<Record<string, any>>({});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
},
handleSubmit,
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',
componentProps: {
placeholder: '请输入关键字',
},
fieldName: 'keyword',
label: '关键字',
},
],
wrapperClass: 'grid-cols-1 md:grid-cols-2',
});
const liveValuesPreview = computed(() => formatJsonPreview(liveValues.value));
const transformedValuesPreview = computed(() => {
return formatJsonPreview(transformedValues.value);
});
function formatJsonPreview(value: Record<string, any>) {
return JSON.stringify(
value,
(_key, currentValue) => {
return dayjs.isDayjs(currentValue)
? currentValue.format('YYYY-MM-DD HH:mm:ss')
: currentValue;
},
2,
);
}
async function handleInspectValues() {
await syncPreviewValues();
message.success('已刷新 getValues 输出');
}
function handleSetExampleValue() {
formApi.setValues({
deadline: dayjs('2026-04-12 18:30:00'),
keyword: 'invoice',
reportRange: [dayjs('2026-04-01 00:00:00'), dayjs('2026-04-12 23:59:59')],
});
}
function handleSubmit(values: Record<string, any>) {
transformedValues.value = values;
message.success({
content: `getValues output: ${JSON.stringify(values)}`,
});
}
async function syncPreviewValues(values?: Record<string, any>) {
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,
},
);
});
</script>
<template>
<Page
content-class="flex flex-col gap-4"
description="演示 schema.valueFormat 如何把组件值转换为提交/查询所需的 payload。"
title="表单 valueFormat"
>
<template #description>
<div class="text-muted-foreground space-y-2">
<p>
<code>form.values</code> 保持组件原始值<code>getValues()</code> /
提交时会按 <code>schema.valueFormat</code> 输出转换后的 payload
</p>
<div class="flex flex-wrap gap-2">
<Tag color="processing">return 回写当前字段</Tag>
<Tag color="success">setValue拆分写入其他字段</Tag>
<Tag color="warning">return undefined保持原字段删除</Tag>
</div>
</div>
</template>
<template #extra>
<DocButton path="/components/common-ui/vben-form" />
</template>
<Card title="valueFormat 示例">
<template #extra>
<Space wrap>
<Button @click="handleSetExampleValue">填充示例数据</Button>
<Button type="primary" @click="handleInspectValues">
查看 getValues 输出
</Button>
</Space>
</template>
<Form />
</Card>
<div class="grid gap-4 lg:grid-cols-2">
<Card title="原始 form.values组件值">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
liveValuesPreview
}}</pre>
</Card>
<Card title="getValues / submit 输出valueFormat 后)">
<pre class="bg-muted overflow-auto rounded-md p-4 text-sm">{{
transformedValuesPreview
}}</pre>
</Card>
</div>
</Page>
</template>