控件

View source
通过 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日期选择器
inputDateUInputDate日期输入框
inputTimeUInputTime时间输入框

可用的 type 值

通过 type 字段在 schema 中指定以下内置控件:

type 值控件组件说明
textareaUTextarea多行文本输入框
switchUSwitch开关切换器
sliderUSlider滑块选择器
pinInputUPinInputPIN 码输入框
inputTagsUInputTags标签输入框
selectMenuUSelectMenu下拉菜单选择器
inputMenuUInputMenu输入菜单
listboxUListbox列表选择器
checkboxGroupUCheckboxGroup复选框组
radioGroupURadioGroup单选框组
inputDateUInputDate日期输入框
inputTimeUInputTime时间输入框
withClearWithClear带清除按钮的输入框
withPasswordToggleWithPasswordToggle带密码显示切换的输入框
withCopyWithCopy带复制按钮的输入框
withCharacterLimitWithCharacterLimit带字符计数的输入框
withFloatingLabelWithFloatingLabel带浮动标签的输入框
asPhoneNumberInputAsPhoneNumberInput电话号码输入框
colorChooserColorChooser颜色选择器
starRatingStarRating星级评分器
slideVerifySlideVerify滑动验证控件
pillGroupPillGroup胶囊单选组

控件总览

在 schema 中通过 type 引用内置控件,按文本、数字、布尔、选择、日期、自定义分组演示全部内置控件:

0/50
+86
<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: '评分' })
})
直接传入组件时,typecomponent 互斥,同时存在时以 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 注册 TagSelectortagSelector 控件类型,schema 中以 type: 'tagSelector' 引用,并通过 controlProps 传入候选标签与最大选中数:

您的主要技术方向

最多选择 5 项技能

Vue.jsNuxtTypeScriptReactNode.jsPythonGraphQLDockerTailwind CSSVite

已选 0 / 5

开启后将对招聘者可见
<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>
Copyright © 2025 - 2026 YiXuan - MIT License