Merge branch 'vbenjs:main' into main
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -60,3 +60,4 @@ vite.config.ts.*
|
||||
skills-lock.json
|
||||
.atomcode
|
||||
datalog
|
||||
.playwright-mcp
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 操作和稳定行身份。
|
||||
|
||||
## 测试与验收
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
81
packages/@core/ui-kit/form-ui/__tests__/form-codec.test.ts
Normal file
81
packages/@core/ui-kit/form-ui/__tests__/form-codec.test.ts
Normal 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'] });
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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 ' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
packages/@core/ui-kit/form-ui/src/form-codec.ts
Normal file
40
packages/@core/ui-kit/form-ui/src/form-codec.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
/**
|
||||
* 表格操作按钮组件
|
||||
|
||||
@@ -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` 统一完成。
|
||||
|
||||
## 小屏幕展示
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)}`,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user