---
title: "控件"
description: "通过 type 字段将 schema 映射到 Nuxt UI 控件，或注册、直接传入自定义 Vue 组件扩展表单字段。"
seo_title: "Controls"
seo_description: "Map AutoForm schema fields to Nuxt UI controls via type, and extend AutoForm with custom Vue components through defineControl or inline component injection."
canonical_url: "https://nuxt.mhaibaraai.cn/docs/auto-form/controls"
---
# 控件

> 通过 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`       | 颜色选择器       |
| `slideVerify`        | `SlideVerify`        | 滑动验证控件      |
| `pillGroup`          | `PillGroup`          | 胶囊单选组       |

### 控件总览

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

```vue [AutoFormControlsExample.vue]
<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',

      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' }),

      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` 字段引用：

```ts
import { TagSelector } from '#components'

const { afz, controls } = useAutoForm({
  tagSelector: defineControl({
    component: TagSelector,
    controlProps: { class: 'w-full' } // 全局 controlProps（可被字段级覆盖）
  })
})
```

> \[\!TIP\]
> 
> 推荐优先使用 
> 
> defineControl
> 
>  注册自定义控件，以获得更稳定的 
> 
> controlProps
> 
>  / 
> 
> controlSlots
> 
>  类型提示。

> \[\!NOTE\]
> 
> 直接对象写法（
> 
> tagSelector: { component: TagSelector }
> 
> ）仍然可用，且支持类型推导。

在 schema 中使用注册的控件类型：

```ts
const schema = afz.object({
  skills: afz
    .array(afz.string(), {
      type: 'tagSelector',       // 指定控件类型
      controlProps: {
        options: ['Vue.js', 'Nuxt', 'TypeScript'],
        max: 5
      }
    })
    .meta({ label: '技能标签' })
})
```

将 `controls` 传给 `MAutoForm`：

```vue
<MAutoForm
  :schema="schema"
  :state="form"
  :controls="controls"
  @submit="onSubmit"
/>
```

### 直接传入组件

无需注册，直接在 schema 的 meta 中传入组件实例（`UInputRating` 需 `@nuxt/ui >=4.10.0`）：

```ts
import { UInputRating } from '#components'

const schema = afz.object({
  rating: afz.number({
    component: UInputRating,
    controlProps: { length: 10 }
  }).meta({ label: '评分' })
})
```

> \[\!NOTE\]
> 
> 直接传入组件时，
> 
> type
> 
>  与 
> 
> component
> 
>  互斥，同时存在时以 
> 
> component
> 
>  为准。

### 组件规范

自定义控件组件需满足以下要求。

#### 支持 v-model

```vue
<script setup>
// Vue 3.4+ 推荐写法
const model = defineModel()

// 或传统写法
const props = defineProps(['modelValue'])
const emits = defineEmits(['update:modelValue'])
</script>
```

#### 接受 controlProps

`controlProps` 中的字段会作为 props 透传给控件组件。组件应声明对应的 props：

```vue
<script setup>
defineProps<{
  modelValue?: string[]
  options?: string[]  // 对应 controlProps.options
  max?: number        // 对应 controlProps.max
  disabled?: boolean
}>()
</script>
```

## 示例

通过 `defineControl` 注册 `TagSelector` 为 `tagSelector` 控件类型，schema 中以 `type: 'tagSelector'` 引用，并通过 `controlProps` 传入候选标签与最大选中数：

```vue [AutoFormCustomControlExample.vue]
<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>
```

```vue [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>
```


## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
