diff --git a/docs/src/components/common-ui/vben-form.md b/docs/src/components/common-ui/vben-form.md index 0a5f2edce..608511377 100644 --- a/docs/src/components/common-ui/vben-form.md +++ b/docs/src/components/common-ui/vben-form.md @@ -230,6 +230,16 @@ export { initComponentAdapter }; +## 值格式化 + +当组件的展示值与后端真正需要的 payload 不一致时,可以在 schema 上使用 `valueFormat`。它会在 `getValues()`、提交、以及依赖这些输出的方法中生效。 + +- `return xxx`:回写当前字段 +- `setValue('startTime', xxx)`:写入其他字段 +- `return undefined`:保持当前字段已被移除,适合把一个字段拆成多个字段 + + + ## 表单校验 表单校验是一个非常重要的功能,可以通过 `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, +) => any; +``` + +- 返回 `undefined`:保持当前字段已被移除 +- 返回其他值:将当前字段恢复/写回为该值 +- `setValue(fieldName, value)`:用于把一个字段拆分写入其他字段 + +::: + ### 表单联动 表单联动需要通过 schema 内的 `dependencies` 属性进行联动,允许您添加字段之间的依赖项,以根据其他字段的值控制字段。 diff --git a/docs/src/demos/vben-form/value-format/index.vue b/docs/src/demos/vben-form/value-format/index.vue new file mode 100644 index 000000000..0bd870d99 --- /dev/null +++ b/docs/src/demos/vben-form/value-format/index.vue @@ -0,0 +1,138 @@ + + + diff --git a/docs/src/en/components/common-ui/vben-form.md b/docs/src/en/components/common-ui/vben-form.md index d23e869bc..58cd7a4d4 100644 --- a/docs/src/en/components/common-ui/vben-form.md +++ b/docs/src/en/components/common-ui/vben-form.md @@ -191,12 +191,23 @@ Create the form through `useVbenForm`: +## 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 + + + ## 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 diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts index 31aa3554c..a4c05b54e 100644 --- a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts +++ b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts @@ -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([ + [ + '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); diff --git a/packages/@core/ui-kit/form-ui/src/field-name.ts b/packages/@core/ui-kit/form-ui/src/field-name.ts new file mode 100644 index 000000000..0e2a2da7d --- /dev/null +++ b/packages/@core/ui-kit/form-ui/src/field-name.ts @@ -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, + }; +} diff --git a/packages/@core/ui-kit/form-ui/src/form-api.ts b/packages/@core/ui-kit/form-ui/src/form-api.ts index e6c5a6bdb..1b842463a 100644 --- a/packages/@core/ui-kit/form-ui/src/form-api.ts +++ b/packages/@core/ui-kit/form-ui/src/form-api.ts @@ -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>() { 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) { + mount(formActions: FormActions, componentRefMap?: Map) { 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, + 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 | 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) => { + 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, + fieldName: string, + ) { + const { rawKey } = resolveFieldNamePath(fieldName); + if (rawKey) { + return values[rawKey]; + } + + return get(values, fieldName); + } + + private setValueByFieldName( + values: Record, + 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 ?? []; diff --git a/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts b/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts index 2a330c9f6..8813beadc 100644 --- a/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts +++ b/packages/@core/ui-kit/form-ui/src/form-render/dependencies.ts @@ -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]; } diff --git a/packages/@core/ui-kit/form-ui/src/types.ts b/packages/@core/ui-kit/form-ui/src/types.ts index e30e8f14f..78114ee30 100644 --- a/packages/@core/ui-kit/form-ui/src/types.ts +++ b/packages/@core/ui-kit/form-ui/src/types.ts @@ -228,6 +228,19 @@ type MappedComponentProps

= ) => P & Record) | (P & Record); +/** + * 格式化 `getValues()` 输出中的当前字段值。 + * - 返回 `undefined`:保留当前字段已被移除的状态,通常配合 `setValue(key, nextValue)` + * 把一个字段拆分写入到其他字段,例如 `startTime` / `endTime` + * - 返回其他值:会将当前字段恢复/写回为该返回值 + * - `setValue` 回调签名为 `(key, nextValue) => void` + */ +export type FormValueFormat = ( + value: any, + setValue: (fieldName: string, value: any) => void, + values: Record, +) => any; + interface FormSchemaBody extends Omit { /** 默认值 */ defaultValue?: any; @@ -249,6 +262,12 @@ interface FormSchemaBody extends Omit { rules?: FormSchemaRuleType; /** 后缀 */ suffix?: CustomRenderType; + /** + * 获取表单值时格式化当前字段。 + * - 返回值不为 `undefined` 时,会回写到当前 fieldName + * - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段 + */ + valueFormat?: FormValueFormat; } type FormSchemaDiscriminated< diff --git a/playground/src/locales/langs/en-US/examples.json b/playground/src/locales/langs/en-US/examples.json index 4e65d7fbf..d60062174 100644 --- a/playground/src/locales/langs/en-US/examples.json +++ b/playground/src/locales/langs/en-US/examples.json @@ -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", diff --git a/playground/src/locales/langs/zh-CN/examples.json b/playground/src/locales/langs/zh-CN/examples.json index 22e9e2933..6a5be2358 100644 --- a/playground/src/locales/langs/zh-CN/examples.json +++ b/playground/src/locales/langs/zh-CN/examples.json @@ -17,6 +17,7 @@ "basic": "基础表单", "layout": "自定义布局", "query": "查询表单", + "valueFormat": "值格式化", "rules": "表单校验", "dynamic": "动态表单", "custom": "自定义组件", diff --git a/playground/src/router/routes/modules/examples.ts b/playground/src/router/routes/modules/examples.ts index a0cedc09c..5bac90ac3 100644 --- a/playground/src/router/routes/modules/examples.ts +++ b/playground/src/router/routes/modules/examples.ts @@ -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', diff --git a/playground/src/views/examples/form/value-format.vue b/playground/src/views/examples/form/value-format.vue new file mode 100644 index 000000000..bf1449f88 --- /dev/null +++ b/playground/src/views/examples/form/value-format.vue @@ -0,0 +1,161 @@ + + +