SearchForm

View source
Schema 驱动的可折叠搜索表单组件。

简介

MSearchForm 是一个 Schema 驱动的搜索表单组件,内置网格布局、搜索 / 重置按钮和折叠行为。当搜索项较多时,超出可见行数的字段会自动折叠,用户可点击展开 / 收起按钮查看全部搜索项。

复用 AutoForm 基础设施(schema 内省、控件映射、字段渲染器),通过 Zod schema 定义搜索字段。

用法

按 AutoForm schema 渲染字段,cols 控制栅格,内置搜索与重置按钮;点「搜索」触发校验并发出 @submit

<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui'
import type z from 'zod'

const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入姓名' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入部门' } }).meta({ label: '部门' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入关键词' } }).meta({ label: '关键词' }).optional(),
  email: afz.email({ controlProps: { placeholder: '请输入邮箱' } }).meta({ label: '邮箱' }).optional()
})

const state = ref<Partial<z.output<typeof schema>>>({})
const result = ref('')

function handleSearch(event: FormSubmitEvent<Record<string, unknown>>) {
  result.value = JSON.stringify(event.data, null, 2)
}

function handleReset() {
  result.value = ''
}
</script>

<template>
  <div class="space-y-4">
    <MSearchForm
      v-model="state"
      :schema="schema"
      @submit="handleSearch"
      @reset="handleReset"
    />
    <pre v-if="result" class="text-sm bg-muted p-3 rounded-(--ui-radius)">{{ result }}</pre>
  </div>
</template>

v-model 绑定

v-model 双向绑定表单数据,传入的初始值会被记录为重置基准:点「重置」恢复到该初始值而非清空。

{
  "name": "张三",
  "status": "启用"
}
<script setup lang="ts">
import type z from 'zod'

const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入姓名' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入关键词' } }).meta({ label: '关键词' }).optional()
})

const state = ref<Partial<z.output<typeof schema>>>({
  name: '张三',
  status: '启用'
})
</script>

<template>
  <div class="space-y-4">
    <MSearchForm v-model="state" :schema="schema" />
    <pre class="text-sm bg-muted p-3 rounded-(--ui-radius)">{{ JSON.stringify(state, null, 2) }}</pre>
    <UButton
      size="sm"
      color="neutral"
      variant="outline"
      @click="state = { name: '李四', status: '禁用', keyword: '测试' }"
    >
      外部设值
    </UButton>
  </div>
</template>

cols 网格列数

cols 控制网格列数:传数字固定列数,传断点对象则列数随窗口宽度在 smmdlgxl 间切换。

<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '部门' }).optional(),
  role: afz.enum(['管理员', '编辑', '查看者']).meta({ label: '角色' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})
</script>

<template>
  <MSearchForm :schema="schema" />
</template>

调整浏览器窗口宽度查看效果:sm=1, md=2, lg=3, xl=4

<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '部门' }).optional(),
  email: afz.email({ controlProps: { placeholder: '请输入' } }).meta({ label: '邮箱' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})

const params = ref({})
</script>

<template>
  <div class="space-y-2">
    <p class="text-sm text-muted">
      调整浏览器窗口宽度查看效果:sm=1, md=2, lg=3, xl=4
    </p>
    <MSearchForm
      v-model="params"
      :schema="schema"
      :cols="{ sm: 1, md: 2, lg: 3, xl: 4 }"
    />
  </div>
</template>

visibleRows 折叠行为

visibleRows 控制可见行数,超出的字段折叠到展开区。下方拖动 colsvisibleRows 实时改变折叠阈值。

<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '部门' }).optional(),
  role: afz.enum(['管理员', '编辑', '查看者']).meta({ label: '角色' }).optional(),
  email: afz.email({ controlProps: { placeholder: '请输入' } }).meta({ label: '邮箱' }).optional(),
  phone: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '手机号' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})
</script>

<template>
  <MSearchForm :schema="schema" />
</template>

expanded 受控展开

v-model:expanded 接管展开状态,优先级高于 defaultExpanded,可由外部按钮或逻辑驱动。

expanded = false
<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '部门' }).optional(),
  role: afz.enum(['管理员', '编辑', '查看者']).meta({ label: '角色' }).optional(),
  email: afz.email({ controlProps: { placeholder: '请输入' } }).meta({ label: '邮箱' }).optional(),
  phone: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '手机号' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})

const expanded = ref(false)
</script>

<template>
  <div class="space-y-3">
    <div class="flex items-center gap-2 text-sm text-muted">
      <UButton size="sm" color="neutral" variant="outline" @click="expanded = !expanded">
        外部{{ expanded ? '收起' : '展开' }}
      </UButton>
      <span>expanded = {{ expanded }}</span>
    </div>
    <MSearchForm v-model:expanded="expanded" :schema="schema" />
  </div>
</template>

expandText / collapseText / icon 展开按钮

expandTextcollapseText 自定义展开 / 收起文案,icon 切换按钮图标,collapseButtonProps 透传按钮属性。

<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用', '待审核']).meta({ label: '状态' }).optional(),
  department: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '部门' }).optional(),
  role: afz.enum(['管理员', '编辑', '查看者']).meta({ label: '角色' }).optional(),
  email: afz.email({ controlProps: { placeholder: '请输入' } }).meta({ label: '邮箱' }).optional(),
  phone: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '手机号' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})
</script>

<template>
  <MSearchForm :schema="schema" :collapse-button-props="{ color: 'primary', variant: 'soft' }" />
</template>

actions 操作按钮

actions 数组扩展或裁剪按钮:

  • 内置 key: search 自动绑定提交,key: reset 自动绑定重置
  • 自定义 key 需提供 onClick(ctx)ctxstateerrorssearchresetcleartoggleloadingexpanded
  • actions: [] 关闭所有内置按钮,配合 actions slot 完全自定义

仅保留搜索按钮(actions 只传 search)

关闭全部内置按钮(actions: []),通过 actions slot 完全自定义

<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '姓名' })
    .optional(),
  status: afz.enum(['启用', '禁用']).meta({ label: '状态' }).optional(),
  department: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '部门' })
    .optional(),
  keyword: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '关键词' })
    .optional()
})
</script>

<template>
  <div class="space-y-6">
    <div class="space-y-2">
      <p class="text-sm text-muted">仅保留搜索按钮(actions 只传 search)</p>
      <MSearchForm
        :schema="schema"
        :actions="[{ key: 'search', label: '搜索', icon: 'i-lucide-search', type: 'submit' }]"
      />
    </div>
    <div class="space-y-2">
      <p class="text-sm text-muted">
        关闭全部内置按钮(actions: []),通过 actions slot 完全自定义
      </p>
      <MSearchForm :schema="schema" :actions="[]">
        <template #actions="{ search, reset }">
          <div class="flex items-end gap-2 justify-end">
            <UButton color="primary" variant="solid" icon="i-lucide-filter" @click="search">
              筛选
            </UButton>
            <UButton color="neutral" variant="ghost" icon="i-lucide-x" @click="reset">
              清空
            </UButton>
          </div>
        </template>
      </MSearchForm>
    </div>
  </div>
</template>
<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '姓名' })
    .optional(),
  status: afz.enum(['启用', '禁用']).meta({ label: '状态' }).optional(),
  keyword: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '关键词' })
    .optional(),
  department: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '部门' })
    .optional()
})

function onExport() {
  console.log('导出')
}
</script>

<template>
  <MSearchForm
    :schema="schema"
    :actions="[
      {
        key: 'search',
        label: '查询',
        icon: 'i-lucide-search',
        type: 'submit',
        color: 'primary',
        variant: 'solid'
      },
      {
        key: 'reset',
        label: '清空',
        icon: 'i-lucide-rotate-ccw',
        color: 'error',
        variant: 'outline'
      },
      {
        key: 'export',
        label: '导出',
        icon: 'i-lucide-download',
        color: 'primary',
        variant: 'soft',
        onClick: onExport
      }
    ]"
  />
</template>

示例

接管操作区

#actions 插槽暴露 searchclearloading,可用自定义按钮替换默认操作区:

<script setup lang="ts">
import type z from 'zod'

const { afz } = useAutoForm()

const schema = afz.object({
  name: afz
    .string({ controlProps: { placeholder: '请输入' } })
    .meta({ label: '姓名' })
    .optional(),
  status: afz.enum(['启用', '禁用']).meta({ label: '状态' }).optional()
})

const state = ref<Partial<z.output<typeof schema>>>({})
</script>

<template>
  <MSearchForm v-model="state" :schema="schema" :cols="3">
    <template #actions="{ search, clear, loading }">
      <div class="flex items-end gap-2">
        <UButton
          color="primary"
          variant="solid"
          icon="i-lucide-filter"
          :loading="loading"
          @click="search"
        >
          筛选
        </UButton>
        <UButton color="neutral" variant="ghost" icon="i-lucide-x" @click="clear"> 清空 </UButton>
      </div>
    </template>
  </MSearchForm>
</template>

扩展布局区域

headerfooterextraActions 插入辅助内容,slot props 随展开与表单值更新:

#header · expanded=false
#footer · 当前关键词: —
<script setup lang="ts">
const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }).optional(),
  status: afz.enum(['启用', '禁用']).meta({ label: '状态' }).optional(),
  keyword: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '关键词' }).optional()
})
</script>

<template>
  <MSearchForm :schema="schema" :cols="3">
    <template #header="{ expanded }">
      <div class="rounded border border-dashed border-primary/40 bg-primary/5 px-3 py-2 text-xs text-primary">
        #header · expanded={{ expanded }}
      </div>
    </template>
    <template #extraActions>
      <UButton size="sm" color="neutral" variant="outline" icon="i-lucide-save">
        保存方案
      </UButton>
    </template>
    <template #footer="{ state: formState }">
      <div class="mt-2 rounded border border-dashed border-success/40 bg-success/5 px-3 py-2 text-xs text-success">
        #footer · 当前关键词: {{ (formState as Record<string, unknown>).keyword ?? '—' }}
      </div>
    </template>
  </MSearchForm>
</template>

异步提交与校验

loading 控制按钮加载态,@error 返回 Zod 校验失败的错误列表:

<script setup lang="ts">
import type { FormErrorEvent, FormSubmitEvent } from '@nuxt/ui'
import type z from 'zod'

const { afz } = useAutoForm()

const schema = afz.object({
  name: afz.string({ controlProps: { placeholder: '请输入' } }).meta({ label: '姓名' }),
  email: afz
    .email({ controlProps: { placeholder: '请输入合法邮箱' } })
    .meta({ label: '邮箱' })
    .optional()
})

type Schema = z.output<typeof schema>

const state = ref<Partial<Schema>>({})
const loading = ref(false)
const toast = useToast()

function onSearch(event: FormSubmitEvent<Schema>) {
  loading.value = true
  setTimeout(() => {
    loading.value = false
    toast.add({ title: '查询完成', description: JSON.stringify(event.data), color: 'success' })
  }, 1500)
}

function onError(event: FormErrorEvent) {
  toast.add({
    title: '校验失败',
    description: `${event.errors?.length ?? 0} 项错误`,
    color: 'error'
  })
}
</script>

<template>
  <MSearchForm
    v-model="state"
    :schema="schema"
    :loading="loading"
    :validate-on="['blur']"
    @submit="onSearch"
    @error="onError"
  />
</template>

API

Props

Prop Default Type
schemaS

Zod 对象 schema,定义表单字段

cols3number | { sm?: number; md?: number; lg?: number; xl?: number; }

网格列数

visibleRows1number

可见行数(折叠时显示的行数)

actions[{ key: 'search', ... }, { key: 'reset', ... }]SearchFormAction[]

动作按钮配置;不传时使用默认 search, reset;传 则关闭所有内置按钮

collapseButtonPropsButtonProps

收起按钮属性

icon'i-lucide-chevron-down'any

展开/收起按钮图标

expandText'展开'string

展开按钮文本

collapseText'收起'string

收起按钮文本

controlsAutoFormControls

自定义控件映射

globalMetaZodAutoFormFieldMeta

全局字段元数据配置

validateOn[]FormInputEvents[]

表单验证时机,详见 UForm 的 validateOn 属性

idstring | number
validate(state: Partial<InferInput<S>>) => FormError<string>[] | Promise<FormError<string>[]>

Custom validation function to validate the form state.

namestring

The name attribute of the form element. For nested forms (nested is true), this is also used as the path of the form's state within its parent form.

validateOnInputDelay`300`number

Delay in milliseconds before validating the form on input events.

transform`true`true

If true, applies schema transformations on submit.

nested`false`false

If true, this form will attach to its parent Form and validate at the same time.

onSubmit(): void | Promise<void> | (event: FormSubmitEvent<InferOutput<S>>): void | Promise<void>
acceptcharsetstring
actionstring
autocompletestring
enctypestring
methodstring
novalidatefalse | true | "true" | "false"
targetstring
modelValue{}Partial<InferInput<S>>
loadingboolean

搜索按钮加载状态(作用于 type==='submit' 或 key==='search' 的按钮)

expandedboolean

受控展开状态;优先级高于 defaultExpanded

defaultExpandedfalseboolean

默认展开状态

loadingAutotrueboolean

是否启用自动 loading 功能。

disabledboolean

Disable all inputs inside the form.

uiRecord<string, ClassNameValue> & { root?: SlotClass; form?: SlotClass; visible?: SlotClass; grid?: SlotClass; header?: SlotClass; footer?: SlotClass; actions?: SlotClass; toggleWrapper?: SlotClass; toggle?: SlotClass; toggleIcon?: SlotClass; collapsed?: SlotClass; }

Emits

Event Type
reset[state: Partial<InferInput<S>>]
clear[state: Partial<InferInput<S>>]
expand[expanded: boolean]
update:expanded[expanded: boolean]
error[event: FormErrorEvent]
update:modelValue[value: Partial<InferInput<S>>]
@submit 转发自底层 UForm,未列入上表。校验通过后触发,返回 FormSubmitEvent,搜索条件位于 event.data——这是接收查询参数的主要出口。

Slots

Slot Type
headerSearchFormSlotProps<S>
footerSearchFormSlotProps<S>
actionsSearchFormSlotProps<S>
extraActionsSearchFormSlotProps<S>
field-label{ label?: string; } & AutoFormFieldContext<Partial<InferInput<S>>, string>
field-hint{ hint?: string; } & AutoFormFieldContext<Partial<InferInput<S>>, string>
field-description{ description?: string; } & AutoFormFieldContext<Partial<InferInput<S>>, string>
field-help{ help?: string; } & AutoFormFieldContext<Partial<InferInput<S>>, string>
field-error{ error?: string | boolean; } & AutoFormFieldContext<Partial<InferInput<S>>, string>
field-default{ error?: string | boolean; } & AutoFormFieldContext<Partial<InferInput<S>>, string>

Expose

您可以通过 useTemplateRef 访问该类型化组件实例。

NameType
formRefRef<InstanceType<typeof UForm>>

UForm 组件引用

submit()void

程序化触发表单提交(等价于点击 search 按钮)

reset()void

恢复到 baseline(首次挂载的 v-model 快照),并触发 reset 事件

clear()void

清空表单所有字段为空值,并触发 clear 事件

setBaseline(value?)void

设置 reset() 的恢复基准;不传参时使用当前 v-model 值

expandedComputedRef<boolean>

当前展开 / 收起状态

toggle()void

切换展开 / 收起,并 emit expandupdate:expanded

Theme

app.config.ts
export default defineAppConfig({
  ui: {
    searchForm: {
      slots: {
        root: 'group/search',
        form: 'space-y-4',
        visible: 'relative',
        grid: 'grid gap-4 [grid-template-columns:repeat(var(--m-search-cols,1),minmax(0,1fr))] sm:[grid-template-columns:repeat(var(--m-search-cols-sm,var(--m-search-cols)),minmax(0,1fr))] md:[grid-template-columns:repeat(var(--m-search-cols-md,var(--m-search-cols-sm,var(--m-search-cols))),minmax(0,1fr))] lg:[grid-template-columns:repeat(var(--m-search-cols-lg,var(--m-search-cols-md,var(--m-search-cols-sm,var(--m-search-cols)))),minmax(0,1fr))] xl:[grid-template-columns:repeat(var(--m-search-cols-xl,var(--m-search-cols-lg,var(--m-search-cols-md,var(--m-search-cols-sm,var(--m-search-cols))))),minmax(0,1fr))]',
        header: '',
        footer: '',
        actions: 'flex items-end gap-2 justify-end',
        toggleWrapper: 'flex justify-center pt-2',
        toggle: 'group opacity-75 hover:opacity-100 active:opacity-90 transition-all duration-200',
        toggleIcon: 'size-3.5 transition-all duration-200 opacity-80 group-data-[state=open]:rotate-180 group-data-[state=open]:opacity-100',
        collapsed: 'mt-4'
      }
    }
  }
})

Changelog

No recent changes
Copyright © 2025 - 2026 YiXuan - MIT License