通过 type 字段将 schema 映射到 Nuxt UI 控件,或注册、直接传入自定义 Vue 组件扩展表单字段。
概述
AutoForm 通过 type 字段将 schema 字段映射到具体的渲染控件,并支持两种方式扩展自定义控件:
- 注册类型:通过
useAutoForm()注册控件类型,在 schema 中通过type引用,适合需要复用的控件 - 直接传入:在 schema 中通过
component直接传入组件实例,适合一次性使用
内置控件
默认映射
| afz 方法 | 默认控件 | 说明 |
|---|---|---|
afz.string() | UInput | 文本输入框 |
afz.number() | UInputNumber | 数字输入框 |
afz.boolean() | UCheckbox | 复选框 |
afz.enum() | USelect | 下拉选择器 |
afz.file() | UFileUpload | 文件上传 |
afz.calendarDate() | DatePicker | 日期选择器 |
inputDate | UInputDate | 日期输入框 |
inputTime | UInputTime | 时间输入框 |
可用的 type 值
通过 type 字段在 schema 中指定以下内置控件:
| type 值 | 控件组件 | 说明 |
|---|---|---|
textarea | UTextarea | 多行文本输入框 |
switch | USwitch | 开关切换器 |
slider | USlider | 滑块选择器 |
pinInput | UPinInput | PIN 码输入框 |
inputTags | UInputTags | 标签输入框 |
selectMenu | USelectMenu | 下拉菜单选择器 |
inputMenu | UInputMenu | 输入菜单 |
listbox | UListbox | 列表选择器 |
checkboxGroup | UCheckboxGroup | 复选框组 |
radioGroup | URadioGroup | 单选框组 |
inputDate | UInputDate | 日期输入框 |
inputTime | UInputTime | 时间输入框 |
withClear | WithClear | 带清除按钮的输入框 |
withPasswordToggle | WithPasswordToggle | 带密码显示切换的输入框 |
withCopy | WithCopy | 带复制按钮的输入框 |
withCharacterLimit | WithCharacterLimit | 带字符计数的输入框 |
withFloatingLabel | WithFloatingLabel | 带浮动标签的输入框 |
asPhoneNumberInput | AsPhoneNumberInput | 电话号码输入框 |
colorChooser | ColorChooser | 颜色选择器 |
starRating | StarRating | 星级评分器 |
slideVerify | SlideVerify | 滑动验证控件 |
pillGroup | PillGroup | 胶囊单选组 |
控件总览
在 schema 中通过 type 引用内置控件,按文本、数字、布尔、选择、日期、自定义分组演示全部内置控件:
<script setup lang="ts">
import { UTabs } from '#components'
import type { z } from 'zod'
const { afz } = useAutoForm()
const schema = afz.object({
$tabs: afz.layout({
component: UTabs,
props: {
ui: { content: 'pt-4 space-y-4' },
items: [
{ label: '文本', icon: 'i-lucide-type', slot: 'text' },
{ label: '数字', icon: 'i-lucide-hash', slot: 'number' },
{ label: '布尔', icon: 'i-lucide-toggle-left', slot: 'boolean' },
{ label: '选择', icon: 'i-lucide-list', slot: 'select' },
{ label: '日期', icon: 'i-lucide-calendar', slot: 'date' },
{ label: '自定义', icon: 'i-lucide-palette', slot: 'custom' }
]
},
fieldSlots: {
string: 'text',
textarea: 'text',
withClear: 'text',
withCopy: 'text',
withPasswordToggle: 'text',
withCharacterLimit: 'text',
asPhoneNumberInput: 'text',
withFloatingLabel: 'text',
inputMenu: 'text',
pinInput: 'text',
number: 'number',
slider: 'number',
starRating: 'number',
boolean: 'boolean',
switch: 'boolean',
slideVerify: 'boolean',
enum: 'select',
selectMenu: 'select',
radioGroup: 'select',
listbox: 'select',
checkboxGroup: 'select',
inputTags: 'select',
pillGroup: 'select',
calendarDate: 'date',
inputDate: 'date',
inputTime: 'date',
colorChooser: 'custom'
},
fields: {
string: afz.string({ controlProps: { placeholder: '默认文本' } }).meta({ label: 'Input' }),
textarea: afz.string({ type: 'textarea', controlProps: { rows: 2 } }).meta({ label: 'Textarea' }),
withClear: afz.string({ type: 'withClear' }).default('movk').meta({ label: 'WithClear' }),
withCopy: afz.string({ type: 'withCopy' }).default('https://movk.dev').meta({ label: 'WithCopy' }),
withPasswordToggle: afz.string({ type: 'withPasswordToggle' }).default('s3cret').meta({ label: 'WithPasswordToggle' }),
withCharacterLimit: afz.string({ type: 'withCharacterLimit', controlProps: { maxlength: 30 } }).meta({ label: 'WithCharacterLimit' }),
asPhoneNumberInput: afz.string({ type: 'asPhoneNumberInput' }).default('13800138000').meta({ label: 'AsPhoneNumberInput' }),
withFloatingLabel: afz.string({ type: 'withFloatingLabel' }).meta({ label: 'WithFloatingLabel' }),
inputMenu: afz.string({ type: 'inputMenu', controlProps: { items: ['Vue', 'Nuxt', 'Nitro'] } }).meta({ label: 'InputMenu' }),
pinInput: afz.array(afz.string(), { type: 'pinInput', controlProps: { length: 4 } }).meta({ label: 'PinInput' }),
number: afz.number({ controlProps: { placeholder: '默认数字' } }).meta({ label: 'InputNumber' }),
slider: afz.number({ type: 'slider', controlProps: { min: 0, max: 100, step: 5 } }).default(40).meta({ label: 'Slider' }),
starRating: afz.number({ type: 'starRating', controlProps: { allowHalf: true } }).default(3.5).meta({ label: 'StarRating' }),
boolean: afz.boolean().meta({ label: 'Checkbox' }),
switch: afz.boolean({ type: 'switch' }).meta({ label: 'Switch' }),
slideVerify: afz.boolean({ type: 'slideVerify' }).meta({ label: 'SlideVerify' }),
enum: afz.enum(['low', 'medium', 'high']).default('medium').meta({ label: 'Select' }),
selectMenu: afz.enum(['北京', '上海', '广州', '深圳'], { type: 'selectMenu' }).meta({ label: 'SelectMenu' }),
radioGroup: afz.enum(['男', '女', '其他'], { type: 'radioGroup' }).meta({ label: 'RadioGroup' }),
listbox: afz.enum(['草稿', '已发布', '已归档'], { type: 'listbox' }).default('草稿').meta({ label: 'Listbox' }),
checkboxGroup: afz.array(afz.string(), { type: 'checkboxGroup', controlProps: { items: ['阅读', '游戏', '运动'] } }).meta({ label: 'CheckboxGroup' }),
inputTags: afz.array(afz.string(), { type: 'inputTags' }).default(['nuxt', 'vue']).meta({ label: 'InputTags' }),
pillGroup: afz.enum(['全部', '前端', '后端'], { type: 'pillGroup' }).default('全部').meta({ label: 'PillGroup' }),
calendarDate: afz.calendarDate({ controlProps: { valueFormat: 'iso' } }).meta({ label: 'DatePicker' }),
inputDate: afz.inputDate().meta({ label: 'InputDate' }),
inputTime: afz.inputTime().meta({ label: 'InputTime' }),
colorChooser: afz.string({ type: 'colorChooser', controlProps: { formats: ['hex', 'rgb'] } }).default('#0ea5e9').meta({ label: 'ColorChooser' })
}
})
})
const state = reactive<Partial<z.output<typeof schema>>>({})
</script>
<template>
<MAutoForm :schema="schema" :state="state" />
</template>
自定义控件
当内置控件无法满足需求时,可以通过注册或直接传入两种方式接入自定义 Vue 组件。
注册控件
通过 useAutoForm() 注册控件,然后在 schema 中通过 type 字段引用:
import { TagSelector } from '#components'
const { afz, controls } = useAutoForm({
tagSelector: defineControl({
component: TagSelector,
controlProps: { class: 'w-full' } // 全局 controlProps(可被字段级覆盖)
})
})
推荐优先使用
defineControl 注册自定义控件,以获得更稳定的 controlProps / controlSlots 类型提示。直接对象写法(
tagSelector: { component: TagSelector })仍然可用,且支持类型推导。在 schema 中使用注册的控件类型:
const schema = afz.object({
skills: afz
.array(afz.string(), {
type: 'tagSelector', // 指定控件类型
controlProps: {
options: ['Vue.js', 'Nuxt', 'TypeScript'],
max: 5
}
})
.meta({ label: '技能标签' })
})
将 controls 传给 MAutoForm:
<MAutoForm
:schema="schema"
:state="form"
:controls="controls"
@submit="onSubmit"
/>
直接传入组件
无需注册,直接在 schema 的 meta 中传入组件实例:
import { StarRating } from '#components'
const schema = afz.object({
rating: afz.number({
component: StarRating,
controlProps: { max: 10 }
}).meta({ label: '评分' })
})
直接传入组件时,
type 与 component 互斥,同时存在时以 component 为准。组件规范
自定义控件组件需满足以下要求。
支持 v-model
<script setup>
// Vue 3.4+ 推荐写法
const model = defineModel()
// 或传统写法
const props = defineProps(['modelValue'])
const emits = defineEmits(['update:modelValue'])
</script>
接受 controlProps
controlProps 中的字段会作为 props 透传给控件组件。组件应声明对应的 props:
<script setup>
defineProps<{
modelValue?: string[]
options?: string[] // 对应 controlProps.options
max?: number // 对应 controlProps.max
disabled?: boolean
}>()
</script>
示例
通过 defineControl 注册 TagSelector 为 tagSelector 控件类型,schema 中以 type: 'tagSelector' 引用,并通过 controlProps 传入候选标签与最大选中数:
<script lang="ts" setup>
import type { FormSubmitEvent } from '@nuxt/ui'
import type { z } from 'zod'
import { TagSelector } from '#components'
const toast = useToast()
const { afz, controls } = useAutoForm({
tagSelector: defineControl({
component: TagSelector,
controlProps: { class: 'w-full' }
})
})
const SKILL_OPTIONS = [
'Vue.js', 'Nuxt', 'TypeScript', 'React', 'Node.js',
'Python', 'GraphQL', 'Docker', 'Tailwind CSS', 'Vite'
]
const ROLE_OPTIONS = {
frontend: '前端开发',
backend: '后端开发',
fullstack: '全栈开发',
devops: 'DevOps',
designer: 'UI / 设计'
}
const schema = afz.object({
name: afz
.string({ controlProps: { placeholder: '请输入您的姓名' } })
.min(2, '姓名至少 2 个字符')
.meta({ label: '姓名' }),
role: afz
.enum(Object.keys(ROLE_OPTIONS) as [string, ...string[]])
.default('fullstack')
.meta({ label: '职位', description: '您的主要技术方向' }),
skills: afz
.array(afz.string(), {
type: 'tagSelector',
controlProps: {
options: SKILL_OPTIONS,
max: 5
}
})
.default([])
.meta({ label: '技能标签', description: '最多选择 5 项技能' }),
bio: afz
.string({
type: 'textarea',
controlProps: { placeholder: '简短介绍一下自己...' }
})
.optional()
.meta({ label: '个人简介' }),
openToWork: afz
.boolean()
.default(false)
.meta({ label: '求职中', hint: '开启后将对招聘者可见' })
})
type Schema = z.output<typeof schema>
const form = ref<Partial<Schema>>({})
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({
title: '提交成功',
color: 'success',
description: JSON.stringify(event.data, null, 2)
})
}
</script>
<template>
<MAutoForm
:schema="schema"
:state="form"
:controls="controls"
@submit="onSubmit"
/>
</template>
app/components/TagSelector.vue
<script setup lang="ts">
import { UBadge } from '#components'
export interface TagSelectorProps {
/**
* 可选标签列表
*/
options?: string[]
/**
* 最多可选数量(不填表示不限制)
*/
max?: number
/**
* 禁用状态
*/
disabled?: boolean
}
const props = withDefaults(defineProps<TagSelectorProps>(), {
options: () => [],
disabled: false
})
const model = defineModel<string[]>({ default: () => [] })
function isSelected(tag: string) {
return (model.value ?? []).includes(tag)
}
function isMaxReached(tag: string) {
const current = model.value ?? []
return !!props.max && current.length >= props.max && !current.includes(tag)
}
function toggle(tag: string) {
if (props.disabled || isMaxReached(tag)) return
const current = model.value ?? []
model.value = current.includes(tag)
? current.filter(t => t !== tag)
: [...current, tag]
}
</script>
<template>
<div class="space-y-2">
<div
v-if="options.length === 0"
class="text-sm text-muted"
>
暂无可选标签
</div>
<div
v-else
class="flex flex-wrap gap-2"
>
<UBadge
v-for="tag in options"
:key="tag"
:label="tag"
:variant="isSelected(tag) ? 'solid' : 'outline'"
color="primary"
class="transition-opacity select-none"
:class="[
disabled || isMaxReached(tag)
? 'opacity-40 cursor-not-allowed'
: 'cursor-pointer hover:opacity-80'
]"
@click="toggle(tag)"
/>
</div>
<p
v-if="max"
class="text-xs text-muted"
>
已选 {{ (model ?? []).length }} / {{ max }}
</p>
</div>
</template>