表单组件设计系统完全指南

HTMLPAGE 团队
28分钟 分钟阅读

深入解析表单组件的设计原则、组件拆分策略、校验系统实现。掌握从输入框到复杂表单的完整设计方法,构建一致、易用、可维护的表单体系。

#表单设计 #组件系统 #表单校验 #用户体验 #Vue组件

为什么表单设计如此重要

表单是用户与应用交互的核心入口。无论是注册登录、数据录入还是搜索筛选,表单无处不在。一个设计良好的表单系统能够:

  • 提升用户体验 - 清晰的布局、即时的反馈让用户操作顺畅
  • 减少错误率 - 合理的校验和引导帮助用户正确填写
  • 提高转化率 - 简洁高效的表单增加用户完成率
  • 降低开发成本 - 统一的组件复用减少重复开发

本文将从设计原则到代码实现,全面解析如何构建专业的表单组件系统。

表单设计原则

核心设计理念

┌─────────────────────────────────────────────────┐
│              表单设计金字塔                       │
├─────────────────────────────────────────────────┤
│                   可访问性                        │
│              (Accessibility)                     │
│         ─────────────────────────               │
│               一致性 (Consistency)               │
│         ─────────────────────────────           │
│            即时反馈 (Immediate Feedback)          │
│      ───────────────────────────────────        │
│         渐进式披露 (Progressive Disclosure)       │
│ ───────────────────────────────────────────     │
│                简洁性 (Simplicity)               │
└─────────────────────────────────────────────────┘

1. 简洁性原则

只收集必要信息,减少用户认知负担:

<!-- ❌ 过度收集信息 -->
<form>
  <input name="firstName" />
  <input name="middleName" />
  <input name="lastName" />
  <input name="nickname" />
  <input name="title" />
</form>

<!-- ✅ 精简必要字段 -->
<form>
  <input name="name" placeholder="姓名" />
  <input name="email" placeholder="邮箱" />
</form>

2. 一致性原则

统一的视觉语言和交互模式:

元素统一规范
标签位置全局统一(顶部/左侧)
必填标识统一使用红色星号
错误提示统一位置和样式
按钮顺序主按钮在右,取消在左
间距遵循8px网格系统

3. 即时反馈原则

用户操作后立即给予视觉反馈:

// 反馈类型和时机
const feedbackStrategy = {
  // 输入时 - 字符计数、密码强度
  onInput: ['characterCount', 'passwordStrength'],
  
  // 失焦时 - 格式校验
  onBlur: ['emailFormat', 'phoneFormat'],
  
  // 提交时 - 完整性校验
  onSubmit: ['requiredFields', 'businessRules'],
}

表单组件体系

组件分层架构

┌─────────────────────────────────────────────────┐
│                   应用层                         │
│    LoginForm  RegisterForm  SearchForm          │
├─────────────────────────────────────────────────┤
│                   组合层                         │
│    FormField  FormGroup  FormSection            │
├─────────────────────────────────────────────────┤
│                   基础层                         │
│    Input  Select  Checkbox  Radio  Switch       │
├─────────────────────────────────────────────────┤
│                   原子层                         │
│    Label  HelpText  ErrorMessage  Icon          │
└─────────────────────────────────────────────────┘

原子组件设计

Label 标签组件

<!-- components/form/FormLabel.vue -->
<script setup lang="ts">
interface Props {
  for?: string
  required?: boolean
  optional?: boolean
  tooltip?: string
}

const props = withDefaults(defineProps<Props>(), {
  required: false,
  optional: false,
})
</script>

<template>
  <label
    :for="props.for"
    class="form-label"
  >
    <slot />
    
    <!-- 必填标识 -->
    <span v-if="required" class="form-label__required">*</span>
    
    <!-- 可选标识 -->
    <span v-if="optional" class="form-label__optional">(可选)</span>
    
    <!-- 提示图标 -->
    <Tooltip v-if="tooltip" :content="tooltip">
      <Icon name="info" class="form-label__tooltip" />
    </Tooltip>
  </label>
</template>

<style scoped>
.form-label {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 14px;
  font-weight: 500;
  color: var(--color-text-primary);
  margin-bottom: 6px;
}

.form-label__required {
  color: var(--color-error);
}

.form-label__optional {
  font-weight: 400;
  color: var(--color-text-secondary);
}

.form-label__tooltip {
  width: 14px;
  height: 14px;
  color: var(--color-text-tertiary);
  cursor: help;
}
</style>

HelpText 帮助文本

<!-- components/form/FormHelpText.vue -->
<script setup lang="ts">
interface Props {
  type?: 'info' | 'warning' | 'error' | 'success'
}

const props = withDefaults(defineProps<Props>(), {
  type: 'info',
})

const typeClass = computed(() => `form-help-text--${props.type}`)
</script>

<template>
  <p :class="['form-help-text', typeClass]">
    <Icon v-if="type === 'error'" name="alert-circle" />
    <Icon v-if="type === 'warning'" name="alert-triangle" />
    <Icon v-if="type === 'success'" name="check-circle" />
    <slot />
  </p>
</template>

<style scoped>
.form-help-text {
  display: flex;
  align-items: flex-start;
  gap: 6px;
  font-size: 12px;
  margin-top: 6px;
  line-height: 1.5;
}

.form-help-text--info {
  color: var(--color-text-secondary);
}

.form-help-text--error {
  color: var(--color-error);
}

.form-help-text--warning {
  color: var(--color-warning);
}

.form-help-text--success {
  color: var(--color-success);
}
</style>

基础输入组件

通用 Input 组件

<!-- components/form/FormInput.vue -->
<script setup lang="ts">
interface Props {
  modelValue?: string | number
  type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'
  placeholder?: string
  disabled?: boolean
  readonly?: boolean
  size?: 'sm' | 'md' | 'lg'
  status?: 'default' | 'error' | 'success' | 'warning'
  prefix?: string
  suffix?: string
  clearable?: boolean
  maxlength?: number
  showCount?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  type: 'text',
  size: 'md',
  status: 'default',
  clearable: false,
  showCount: false,
})

const emit = defineEmits<{
  'update:modelValue': [value: string | number]
  'focus': [event: FocusEvent]
  'blur': [event: FocusEvent]
  'clear': []
}>()

const inputRef = ref<HTMLInputElement>()
const isFocused = ref(false)

// 输入值
const inputValue = computed({
  get: () => props.modelValue ?? '',
  set: (val) => emit('update:modelValue', val),
})

// 字符计数
const charCount = computed(() => String(inputValue.value).length)

// 样式类
const inputClasses = computed(() => [
  'form-input',
  `form-input--${props.size}`,
  `form-input--${props.status}`,
  {
    'form-input--focused': isFocused.value,
    'form-input--disabled': props.disabled,
    'form-input--readonly': props.readonly,
    'form-input--has-prefix': props.prefix,
    'form-input--has-suffix': props.suffix || props.clearable,
  },
])

// 清除内容
const handleClear = () => {
  inputValue.value = ''
  emit('clear')
  inputRef.value?.focus()
}

// 焦点事件
const handleFocus = (e: FocusEvent) => {
  isFocused.value = true
  emit('focus', e)
}

const handleBlur = (e: FocusEvent) => {
  isFocused.value = false
  emit('blur', e)
}

// 暴露方法
defineExpose({
  focus: () => inputRef.value?.focus(),
  blur: () => inputRef.value?.blur(),
  select: () => inputRef.value?.select(),
})
</script>

<template>
  <div :class="inputClasses">
    <!-- 前缀 -->
    <span v-if="prefix" class="form-input__prefix">
      {{ prefix }}
    </span>
    
    <!-- 输入框 -->
    <input
      ref="inputRef"
      v-model="inputValue"
      :type="type"
      :placeholder="placeholder"
      :disabled="disabled"
      :readonly="readonly"
      :maxlength="maxlength"
      class="form-input__native"
      @focus="handleFocus"
      @blur="handleBlur"
    />
    
    <!-- 清除按钮 -->
    <button
      v-if="clearable && inputValue"
      type="button"
      class="form-input__clear"
      @click="handleClear"
    >
      <Icon name="x" />
    </button>
    
    <!-- 后缀 -->
    <span v-if="suffix" class="form-input__suffix">
      {{ suffix }}
    </span>
    
    <!-- 字符计数 -->
    <span v-if="showCount && maxlength" class="form-input__count">
      {{ charCount }}/{{ maxlength }}
    </span>
  </div>
</template>

<style scoped>
.form-input {
  display: inline-flex;
  align-items: center;
  width: 100%;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg-primary);
  transition: all 0.2s ease;
}

/* 尺寸变体 */
.form-input--sm {
  height: 32px;
  padding: 0 10px;
  font-size: 13px;
}

.form-input--md {
  height: 40px;
  padding: 0 12px;
  font-size: 14px;
}

.form-input--lg {
  height: 48px;
  padding: 0 16px;
  font-size: 16px;
}

/* 状态变体 */
.form-input--focused {
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px var(--color-primary-light);
}

.form-input--error {
  border-color: var(--color-error);
}

.form-input--error.form-input--focused {
  box-shadow: 0 0 0 3px var(--color-error-light);
}

.form-input--success {
  border-color: var(--color-success);
}

.form-input--disabled {
  background: var(--color-bg-disabled);
  cursor: not-allowed;
}

/* 原生输入框 */
.form-input__native {
  flex: 1;
  border: none;
  outline: none;
  background: transparent;
  font: inherit;
  color: var(--color-text-primary);
}

.form-input__native::placeholder {
  color: var(--color-text-placeholder);
}

.form-input__native:disabled {
  cursor: not-allowed;
}

/* 前后缀 */
.form-input__prefix,
.form-input__suffix {
  color: var(--color-text-secondary);
  white-space: nowrap;
}

.form-input__prefix {
  margin-right: 8px;
}

.form-input__suffix {
  margin-left: 8px;
}

/* 清除按钮 */
.form-input__clear {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  margin-left: 8px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: var(--color-bg-tertiary);
  color: var(--color-text-secondary);
  cursor: pointer;
  transition: all 0.15s ease;
}

.form-input__clear:hover {
  background: var(--color-bg-quaternary);
  color: var(--color-text-primary);
}

/* 字符计数 */
.form-input__count {
  margin-left: 8px;
  font-size: 12px;
  color: var(--color-text-tertiary);
}
</style>

Select 选择器

<!-- components/form/FormSelect.vue -->
<script setup lang="ts">
interface Option {
  value: string | number
  label: string
  disabled?: boolean
}

interface Props {
  modelValue?: string | number | null
  options: Option[]
  placeholder?: string
  disabled?: boolean
  clearable?: boolean
  searchable?: boolean
  size?: 'sm' | 'md' | 'lg'
  status?: 'default' | 'error' | 'success'
}

const props = withDefaults(defineProps<Props>(), {
  size: 'md',
  status: 'default',
  clearable: false,
  searchable: false,
})

const emit = defineEmits<{
  'update:modelValue': [value: string | number | null]
  'change': [value: string | number | null]
}>()

const isOpen = ref(false)
const searchQuery = ref('')
const highlightedIndex = ref(-1)

// 当前选中项
const selectedOption = computed(() => 
  props.options.find(opt => opt.value === props.modelValue)
)

// 过滤选项
const filteredOptions = computed(() => {
  if (!props.searchable || !searchQuery.value) {
    return props.options
  }
  
  const query = searchQuery.value.toLowerCase()
  return props.options.filter(opt => 
    opt.label.toLowerCase().includes(query)
  )
})

// 选择选项
const selectOption = (option: Option) => {
  if (option.disabled) return
  
  emit('update:modelValue', option.value)
  emit('change', option.value)
  isOpen.value = false
  searchQuery.value = ''
}

// 清除选择
const handleClear = (e: Event) => {
  e.stopPropagation()
  emit('update:modelValue', null)
  emit('change', null)
}

// 键盘导航
const handleKeydown = (e: KeyboardEvent) => {
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault()
      if (!isOpen.value) {
        isOpen.value = true
      } else {
        highlightedIndex.value = Math.min(
          highlightedIndex.value + 1,
          filteredOptions.value.length - 1
        )
      }
      break
      
    case 'ArrowUp':
      e.preventDefault()
      highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
      break
      
    case 'Enter':
      e.preventDefault()
      if (isOpen.value && highlightedIndex.value >= 0) {
        selectOption(filteredOptions.value[highlightedIndex.value])
      } else {
        isOpen.value = true
      }
      break
      
    case 'Escape':
      isOpen.value = false
      break
  }
}

// 点击外部关闭
onClickOutside(ref(null), () => {
  isOpen.value = false
})
</script>

<template>
  <div 
    class="form-select"
    :class="[
      `form-select--${size}`,
      `form-select--${status}`,
      { 'form-select--open': isOpen, 'form-select--disabled': disabled }
    ]"
    @keydown="handleKeydown"
  >
    <!-- 触发器 -->
    <div 
      class="form-select__trigger"
      tabindex="0"
      @click="!disabled && (isOpen = !isOpen)"
    >
      <!-- 搜索框 -->
      <input
        v-if="searchable && isOpen"
        v-model="searchQuery"
        class="form-select__search"
        placeholder="搜索..."
        @click.stop
      />
      
      <!-- 显示值 -->
      <span v-else-if="selectedOption" class="form-select__value">
        {{ selectedOption.label }}
      </span>
      
      <!-- 占位符 -->
      <span v-else class="form-select__placeholder">
        {{ placeholder }}
      </span>
      
      <!-- 清除按钮 -->
      <button
        v-if="clearable && selectedOption"
        type="button"
        class="form-select__clear"
        @click="handleClear"
      >
        <Icon name="x" />
      </button>
      
      <!-- 箭头图标 -->
      <Icon 
        name="chevron-down" 
        class="form-select__arrow"
        :class="{ 'form-select__arrow--rotated': isOpen }"
      />
    </div>
    
    <!-- 下拉菜单 -->
    <Transition name="dropdown">
      <div v-if="isOpen" class="form-select__dropdown">
        <div
          v-for="(option, index) in filteredOptions"
          :key="option.value"
          class="form-select__option"
          :class="{
            'form-select__option--selected': option.value === modelValue,
            'form-select__option--highlighted': index === highlightedIndex,
            'form-select__option--disabled': option.disabled,
          }"
          @click="selectOption(option)"
          @mouseenter="highlightedIndex = index"
        >
          {{ option.label }}
          <Icon 
            v-if="option.value === modelValue" 
            name="check" 
            class="form-select__check"
          />
        </div>
        
        <!-- 无结果 -->
        <div v-if="filteredOptions.length === 0" class="form-select__empty">
          无匹配结果
        </div>
      </div>
    </Transition>
  </div>
</template>

<style scoped>
.form-select {
  position: relative;
  width: 100%;
}

.form-select__trigger {
  display: flex;
  align-items: center;
  width: 100%;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg-primary);
  cursor: pointer;
  transition: all 0.2s ease;
}

.form-select--md .form-select__trigger {
  height: 40px;
  padding: 0 12px;
  font-size: 14px;
}

.form-select--open .form-select__trigger {
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px var(--color-primary-light);
}

.form-select__value {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.form-select__placeholder {
  flex: 1;
  color: var(--color-text-placeholder);
}

.form-select__arrow {
  width: 16px;
  height: 16px;
  color: var(--color-text-secondary);
  transition: transform 0.2s ease;
}

.form-select__arrow--rotated {
  transform: rotate(180deg);
}

.form-select__dropdown {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  max-height: 240px;
  overflow-y: auto;
  background: var(--color-bg-primary);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  z-index: 1000;
}

.form-select__option {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 12px;
  cursor: pointer;
  transition: background 0.15s ease;
}

.form-select__option--highlighted {
  background: var(--color-bg-secondary);
}

.form-select__option--selected {
  color: var(--color-primary);
  font-weight: 500;
}

.form-select__option--disabled {
  color: var(--color-text-disabled);
  cursor: not-allowed;
}

.form-select__check {
  width: 16px;
  height: 16px;
  color: var(--color-primary);
}

.form-select__empty {
  padding: 20px;
  text-align: center;
  color: var(--color-text-secondary);
}

/* 下拉动画 */
.dropdown-enter-active,
.dropdown-leave-active {
  transition: all 0.2s ease;
}

.dropdown-enter-from,
.dropdown-leave-to {
  opacity: 0;
  transform: translateY(-8px);
}
</style>

组合组件

FormField 表单字段

<!-- components/form/FormField.vue -->
<script setup lang="ts">
interface Props {
  label?: string
  name?: string
  required?: boolean
  optional?: boolean
  tooltip?: string
  helpText?: string
  error?: string
  showError?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  showError: true,
})

const hasError = computed(() => props.showError && !!props.error)
</script>

<template>
  <div 
    class="form-field"
    :class="{ 'form-field--error': hasError }"
  >
    <!-- 标签 -->
    <FormLabel
      v-if="label"
      :for="name"
      :required="required"
      :optional="optional"
      :tooltip="tooltip"
    >
      {{ label }}
    </FormLabel>
    
    <!-- 输入控件插槽 -->
    <slot />
    
    <!-- 帮助文本或错误提示 -->
    <FormHelpText v-if="hasError" type="error">
      {{ error }}
    </FormHelpText>
    
    <FormHelpText v-else-if="helpText" type="info">
      {{ helpText }}
    </FormHelpText>
  </div>
</template>

<style scoped>
.form-field {
  margin-bottom: 20px;
}

.form-field--error :deep(.form-input) {
  border-color: var(--color-error);
}

.form-field--error :deep(.form-select__trigger) {
  border-color: var(--color-error);
}
</style>

FormGroup 表单分组

<!-- components/form/FormGroup.vue -->
<script setup lang="ts">
interface Props {
  title?: string
  description?: string
  collapsible?: boolean
  defaultCollapsed?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  collapsible: false,
  defaultCollapsed: false,
})

const isCollapsed = ref(props.defaultCollapsed)

const toggle = () => {
  if (props.collapsible) {
    isCollapsed.value = !isCollapsed.value
  }
}
</script>

<template>
  <fieldset class="form-group">
    <!-- 标题区 -->
    <legend 
      v-if="title" 
      class="form-group__header"
      :class="{ 'form-group__header--clickable': collapsible }"
      @click="toggle"
    >
      <span class="form-group__title">{{ title }}</span>
      
      <Icon 
        v-if="collapsible"
        name="chevron-down"
        class="form-group__icon"
        :class="{ 'form-group__icon--collapsed': isCollapsed }"
      />
    </legend>
    
    <!-- 描述 -->
    <p v-if="description && !isCollapsed" class="form-group__description">
      {{ description }}
    </p>
    
    <!-- 内容区 -->
    <Transition name="collapse">
      <div v-show="!isCollapsed" class="form-group__content">
        <slot />
      </div>
    </Transition>
  </fieldset>
</template>

<style scoped>
.form-group {
  border: 1px solid var(--color-border-light);
  border-radius: var(--radius-lg);
  padding: 20px;
  margin-bottom: 24px;
}

.form-group__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 16px;
}

.form-group__header--clickable {
  cursor: pointer;
}

.form-group__title {
  font-size: 16px;
  font-weight: 600;
  color: var(--color-text-primary);
}

.form-group__icon {
  width: 20px;
  height: 20px;
  transition: transform 0.2s ease;
}

.form-group__icon--collapsed {
  transform: rotate(-90deg);
}

.form-group__description {
  font-size: 14px;
  color: var(--color-text-secondary);
  margin-bottom: 16px;
}

.form-group__content {
  display: flex;
  flex-direction: column;
}

/* 折叠动画 */
.collapse-enter-active,
.collapse-leave-active {
  transition: all 0.3s ease;
  overflow: hidden;
}

.collapse-enter-from,
.collapse-leave-to {
  opacity: 0;
  max-height: 0;
}
</style>

表单校验系统

校验规则设计

// composables/useFormValidation.ts

// 校验规则类型
type ValidationRule = {
  validator: (value: any, formData?: Record<string, any>) => boolean
  message: string
  trigger?: 'blur' | 'change' | 'submit'
}

// 内置校验规则
export const validators = {
  // 必填
  required: (message = '此字段为必填项'): ValidationRule => ({
    validator: (value) => {
      if (Array.isArray(value)) return value.length > 0
      if (typeof value === 'string') return value.trim().length > 0
      return value !== null && value !== undefined
    },
    message,
    trigger: 'blur',
  }),
  
  // 邮箱格式
  email: (message = '请输入有效的邮箱地址'): ValidationRule => ({
    validator: (value) => {
      if (!value) return true // 空值交给 required 校验
      const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
      return pattern.test(value)
    },
    message,
    trigger: 'blur',
  }),
  
  // 手机号格式
  phone: (message = '请输入有效的手机号'): ValidationRule => ({
    validator: (value) => {
      if (!value) return true
      const pattern = /^1[3-9]\d{9}$/
      return pattern.test(value)
    },
    message,
    trigger: 'blur',
  }),
  
  // 最小长度
  minLength: (min: number, message?: string): ValidationRule => ({
    validator: (value) => {
      if (!value) return true
      return String(value).length >= min
    },
    message: message || `至少需要 ${min} 个字符`,
    trigger: 'blur',
  }),
  
  // 最大长度
  maxLength: (max: number, message?: string): ValidationRule => ({
    validator: (value) => {
      if (!value) return true
      return String(value).length <= max
    },
    message: message || `最多允许 ${max} 个字符`,
    trigger: 'change',
  }),
  
  // 数值范围
  range: (min: number, max: number, message?: string): ValidationRule => ({
    validator: (value) => {
      if (value === null || value === undefined) return true
      const num = Number(value)
      return !isNaN(num) && num >= min && num <= max
    },
    message: message || `数值应在 ${min} 到 ${max} 之间`,
    trigger: 'blur',
  }),
  
  // 正则匹配
  pattern: (regex: RegExp, message: string): ValidationRule => ({
    validator: (value) => {
      if (!value) return true
      return regex.test(value)
    },
    message,
    trigger: 'blur',
  }),
  
  // 确认密码
  confirm: (field: string, message = '两次输入不一致'): ValidationRule => ({
    validator: (value, formData) => {
      return value === formData?.[field]
    },
    message,
    trigger: 'blur',
  }),
  
  // 自定义校验
  custom: (
    fn: (value: any, formData?: Record<string, any>) => boolean | Promise<boolean>,
    message: string
  ): ValidationRule => ({
    validator: fn,
    message,
    trigger: 'blur',
  }),
}

// 表单校验 Composable
export function useFormValidation<T extends Record<string, any>>(
  formData: Ref<T>,
  rules: Record<keyof T, ValidationRule[]>
) {
  const errors = ref<Record<string, string>>({})
  const touched = ref<Record<string, boolean>>({})
  
  // 校验单个字段
  const validateField = async (
    field: keyof T,
    trigger: 'blur' | 'change' | 'submit' = 'submit'
  ): Promise<boolean> => {
    const fieldRules = rules[field] || []
    const value = formData.value[field]
    
    for (const rule of fieldRules) {
      // 检查触发时机
      if (rule.trigger && rule.trigger !== trigger && trigger !== 'submit') {
        continue
      }
      
      const isValid = await rule.validator(value, formData.value)
      
      if (!isValid) {
        errors.value[field as string] = rule.message
        return false
      }
    }
    
    // 清除错误
    delete errors.value[field as string]
    return true
  }
  
  // 校验所有字段
  const validateAll = async (): Promise<boolean> => {
    const results = await Promise.all(
      Object.keys(rules).map(field => validateField(field as keyof T, 'submit'))
    )
    return results.every(Boolean)
  }
  
  // 标记字段为已触碰
  const touchField = (field: keyof T) => {
    touched.value[field as string] = true
  }
  
  // 重置校验状态
  const resetValidation = () => {
    errors.value = {}
    touched.value = {}
  }
  
  // 获取字段错误
  const getError = (field: keyof T): string | undefined => {
    return errors.value[field as string]
  }
  
  // 是否有错误
  const hasErrors = computed(() => Object.keys(errors.value).length > 0)
  
  return {
    errors,
    touched,
    validateField,
    validateAll,
    touchField,
    resetValidation,
    getError,
    hasErrors,
  }
}

表单校验使用示例

<!-- pages/register.vue -->
<script setup lang="ts">
import { validators, useFormValidation } from '~/composables/useFormValidation'

// 表单数据
const formData = ref({
  username: '',
  email: '',
  password: '',
  confirmPassword: '',
  phone: '',
  age: null as number | null,
})

// 校验规则
const rules = {
  username: [
    validators.required('请输入用户名'),
    validators.minLength(3, '用户名至少3个字符'),
    validators.maxLength(20, '用户名最多20个字符'),
    validators.pattern(/^[a-zA-Z0-9_]+$/, '只能包含字母、数字和下划线'),
  ],
  email: [
    validators.required('请输入邮箱'),
    validators.email(),
  ],
  password: [
    validators.required('请输入密码'),
    validators.minLength(8, '密码至少8个字符'),
    validators.pattern(
      /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
      '密码必须包含大小写字母和数字'
    ),
  ],
  confirmPassword: [
    validators.required('请确认密码'),
    validators.confirm('password', '两次输入的密码不一致'),
  ],
  phone: [
    validators.phone(),
  ],
  age: [
    validators.range(18, 100, '年龄必须在18到100岁之间'),
  ],
}

// 使用校验
const {
  errors,
  validateField,
  validateAll,
  touchField,
  resetValidation,
  getError,
  hasErrors,
} = useFormValidation(formData, rules)

// 提交处理
const handleSubmit = async () => {
  const isValid = await validateAll()
  
  if (!isValid) {
    console.log('表单校验失败', errors.value)
    return
  }
  
  // 提交表单
  console.log('提交数据', formData.value)
}

// 字段失焦处理
const handleBlur = (field: keyof typeof formData.value) => {
  touchField(field)
  validateField(field, 'blur')
}
</script>

<template>
  <form @submit.prevent="handleSubmit" class="register-form">
    <FormGroup title="账户信息" description="创建您的账户">
      <FormField
        label="用户名"
        name="username"
        required
        :error="getError('username')"
        help-text="3-20个字符,只能包含字母、数字和下划线"
      >
        <FormInput
          v-model="formData.username"
          placeholder="请输入用户名"
          :status="getError('username') ? 'error' : 'default'"
          @blur="handleBlur('username')"
        />
      </FormField>
      
      <FormField
        label="邮箱"
        name="email"
        required
        :error="getError('email')"
      >
        <FormInput
          v-model="formData.email"
          type="email"
          placeholder="请输入邮箱"
          :status="getError('email') ? 'error' : 'default'"
          @blur="handleBlur('email')"
        />
      </FormField>
    </FormGroup>
    
    <FormGroup title="安全设置">
      <FormField
        label="密码"
        name="password"
        required
        :error="getError('password')"
        help-text="至少8个字符,包含大小写字母和数字"
      >
        <FormInput
          v-model="formData.password"
          type="password"
          placeholder="请输入密码"
          :status="getError('password') ? 'error' : 'default'"
          @blur="handleBlur('password')"
        />
      </FormField>
      
      <FormField
        label="确认密码"
        name="confirmPassword"
        required
        :error="getError('confirmPassword')"
      >
        <FormInput
          v-model="formData.confirmPassword"
          type="password"
          placeholder="请再次输入密码"
          :status="getError('confirmPassword') ? 'error' : 'default'"
          @blur="handleBlur('confirmPassword')"
        />
      </FormField>
    </FormGroup>
    
    <FormGroup title="个人信息" collapsible>
      <FormField
        label="手机号"
        name="phone"
        optional
        :error="getError('phone')"
      >
        <FormInput
          v-model="formData.phone"
          type="tel"
          placeholder="请输入手机号"
          :status="getError('phone') ? 'error' : 'default'"
          @blur="handleBlur('phone')"
        />
      </FormField>
      
      <FormField
        label="年龄"
        name="age"
        optional
        :error="getError('age')"
      >
        <FormInput
          v-model="formData.age"
          type="number"
          placeholder="请输入年龄"
          :status="getError('age') ? 'error' : 'default'"
          @blur="handleBlur('age')"
        />
      </FormField>
    </FormGroup>
    
    <!-- 提交按钮 -->
    <div class="form-actions">
      <Button type="button" variant="outline" @click="resetValidation">
        重置
      </Button>
      <Button type="submit" :disabled="hasErrors">
        注册
      </Button>
    </div>
  </form>
</template>

可访问性设计

ARIA 属性支持

<!-- components/form/AccessibleFormField.vue -->
<script setup lang="ts">
interface Props {
  id: string
  label: string
  error?: string
  description?: string
  required?: boolean
}

const props = defineProps<Props>()

const descriptionId = computed(() => `${props.id}-description`)
const errorId = computed(() => `${props.id}-error`)

// 组合 aria-describedby
const ariaDescribedBy = computed(() => {
  const ids: string[] = []
  if (props.description) ids.push(descriptionId.value)
  if (props.error) ids.push(errorId.value)
  return ids.length ? ids.join(' ') : undefined
})
</script>

<template>
  <div class="form-field" role="group">
    <label :for="id" class="form-label">
      {{ label }}
      <span v-if="required" aria-label="必填">*</span>
    </label>
    
    <slot
      :aria-describedby="ariaDescribedBy"
      :aria-invalid="!!error"
      :aria-required="required"
    />
    
    <p 
      v-if="description && !error"
      :id="descriptionId"
      class="form-description"
    >
      {{ description }}
    </p>
    
    <p 
      v-if="error"
      :id="errorId"
      class="form-error"
      role="alert"
      aria-live="polite"
    >
      {{ error }}
    </p>
  </div>
</template>

键盘导航支持

// composables/useFormKeyboard.ts
export function useFormKeyboard() {
  const formRef = ref<HTMLFormElement>()
  
  // 获取可聚焦元素
  const getFocusableElements = () => {
    if (!formRef.value) return []
    
    return Array.from(
      formRef.value.querySelectorAll<HTMLElement>(
        'input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
      )
    )
  }
  
  // 聚焦下一个元素
  const focusNext = () => {
    const elements = getFocusableElements()
    const currentIndex = elements.indexOf(document.activeElement as HTMLElement)
    const nextIndex = (currentIndex + 1) % elements.length
    elements[nextIndex]?.focus()
  }
  
  // 聚焦上一个元素
  const focusPrev = () => {
    const elements = getFocusableElements()
    const currentIndex = elements.indexOf(document.activeElement as HTMLElement)
    const prevIndex = (currentIndex - 1 + elements.length) % elements.length
    elements[prevIndex]?.focus()
  }
  
  // 聚焦第一个错误字段
  const focusFirstError = () => {
    const errorField = formRef.value?.querySelector<HTMLElement>(
      '[aria-invalid="true"]'
    )
    errorField?.focus()
  }
  
  return {
    formRef,
    focusNext,
    focusPrev,
    focusFirstError,
  }
}

高级表单模式

动态表单字段

<!-- components/form/DynamicFieldList.vue -->
<script setup lang="ts">
interface Props {
  modelValue: any[]
  minItems?: number
  maxItems?: number
  addLabel?: string
}

const props = withDefaults(defineProps<Props>(), {
  minItems: 0,
  maxItems: Infinity,
  addLabel: '添加',
})

const emit = defineEmits<{
  'update:modelValue': [value: any[]]
}>()

const items = computed({
  get: () => props.modelValue,
  set: (val) => emit('update:modelValue', val),
})

const canAdd = computed(() => items.value.length < props.maxItems)
const canRemove = computed(() => items.value.length > props.minItems)

const addItem = () => {
  if (!canAdd.value) return
  items.value = [...items.value, {}]
}

const removeItem = (index: number) => {
  if (!canRemove.value) return
  items.value = items.value.filter((_, i) => i !== index)
}

const moveItem = (from: number, to: number) => {
  const newItems = [...items.value]
  const [item] = newItems.splice(from, 1)
  newItems.splice(to, 0, item)
  items.value = newItems
}
</script>

<template>
  <div class="dynamic-field-list">
    <TransitionGroup name="list" tag="div" class="dynamic-field-list__items">
      <div
        v-for="(item, index) in items"
        :key="item.id || index"
        class="dynamic-field-list__item"
      >
        <!-- 拖拽手柄 -->
        <div class="dynamic-field-list__handle">
          <Icon name="grip-vertical" />
        </div>
        
        <!-- 字段内容 -->
        <div class="dynamic-field-list__content">
          <slot :item="item" :index="index" />
        </div>
        
        <!-- 操作按钮 -->
        <div class="dynamic-field-list__actions">
          <Button
            v-if="index > 0"
            variant="ghost"
            size="sm"
            @click="moveItem(index, index - 1)"
          >
            <Icon name="arrow-up" />
          </Button>
          
          <Button
            v-if="index < items.length - 1"
            variant="ghost"
            size="sm"
            @click="moveItem(index, index + 1)"
          >
            <Icon name="arrow-down" />
          </Button>
          
          <Button
            variant="ghost"
            size="sm"
            :disabled="!canRemove"
            @click="removeItem(index)"
          >
            <Icon name="trash" />
          </Button>
        </div>
      </div>
    </TransitionGroup>
    
    <!-- 添加按钮 -->
    <Button
      v-if="canAdd"
      variant="dashed"
      class="dynamic-field-list__add"
      @click="addItem"
    >
      <Icon name="plus" />
      {{ addLabel }}
    </Button>
  </div>
</template>

<style scoped>
.dynamic-field-list__item {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 16px;
  margin-bottom: 12px;
  background: var(--color-bg-secondary);
  border-radius: var(--radius-md);
}

.dynamic-field-list__handle {
  cursor: grab;
  color: var(--color-text-tertiary);
}

.dynamic-field-list__content {
  flex: 1;
}

.dynamic-field-list__add {
  width: 100%;
}

/* 列表动画 */
.list-move,
.list-enter-active,
.list-leave-active {
  transition: all 0.3s ease;
}

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(-20px);
}

.list-leave-active {
  position: absolute;
}
</style>

条件字段显示

<!-- 示例:根据条件显示不同字段 -->
<script setup lang="ts">
const formData = ref({
  userType: 'personal' as 'personal' | 'business',
  // 个人用户字段
  idNumber: '',
  // 企业用户字段
  companyName: '',
  businessLicense: '',
})

const isPersonal = computed(() => formData.value.userType === 'personal')
const isBusiness = computed(() => formData.value.userType === 'business')
</script>

<template>
  <form>
    <FormField label="用户类型" required>
      <FormRadioGroup v-model="formData.userType">
        <FormRadio value="personal">个人用户</FormRadio>
        <FormRadio value="business">企业用户</FormRadio>
      </FormRadioGroup>
    </FormField>
    
    <!-- 个人用户字段 -->
    <Transition name="fade">
      <FormField
        v-if="isPersonal"
        label="身份证号"
        required
      >
        <FormInput v-model="formData.idNumber" placeholder="请输入身份证号" />
      </FormField>
    </Transition>
    
    <!-- 企业用户字段 -->
    <Transition name="fade">
      <FormGroup v-if="isBusiness" title="企业信息">
        <FormField label="公司名称" required>
          <FormInput v-model="formData.companyName" placeholder="请输入公司名称" />
        </FormField>
        
        <FormField label="营业执照号" required>
          <FormInput v-model="formData.businessLicense" placeholder="请输入营业执照号" />
        </FormField>
      </FormGroup>
    </Transition>
  </form>
</template>

最佳实践总结

设计清单

类别最佳实践
布局使用单列布局,复杂表单分组分步
标签标签位于输入框上方,必填字段标星
占位符仅作为格式提示,不替代标签
校验即时反馈,避免提交后才提示
错误错误信息具体明确,指导修正
按钮主按钮突出,禁用态明确
移动端输入框高度足够,键盘类型匹配

组件设计原则

  1. 单一职责 - 每个组件只做一件事
  2. 可组合性 - 支持灵活组合使用
  3. 可访问性 - 完整的 ARIA 支持
  4. 可定制性 - 通过 props 和插槽扩展
  5. 一致性 - 统一的 API 设计模式

总结

构建优秀的表单组件系统需要:

  • 分层设计 - 原子组件、基础组件、组合组件、应用组件
  • 校验系统 - 灵活的规则定义和触发时机
  • 可访问性 - 完整的键盘和屏幕阅读器支持
  • 用户体验 - 即时反馈、清晰引导、错误恢复

通过系统化的组件设计和统一的交互模式,可以大幅提升表单开发效率和用户体验。