File size: 5,638 Bytes
793e953
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
/**
 * useFormValidation Hook
 *
 * 通用的表单验证 Hook,提供:
 * - 字段级别的 touched / error 状态管理
 * - onBlur 触发单字段验证
 * - onChange 实时验证(仅在已触碰后)
 * - validateAll 全量验证
 * - 验证图标动画支持(success / error 状态)
 *
 * 用法:
 *   const { errors, touched, getFieldProps, validateAll, reset } = useFormValidation({
 *     rules: {
 *       phone: (v) => validatePhone(v),
 *       password: (v) => v.length >= 6 ? '' : t('auth.passwordMinLength'),
 *     },
 *   })
 *
 *   <input {...getFieldProps('phone', phone, setPhone)} />
 *
 * @template Fields - 表单字段名联合类型
 */

import { useState, useCallback, useRef } from 'react'

/** 单个字段的验证函数:返回错误消息(空字符串表示通过) */
export type ValidationRule<T = string> = (value: T) => string

/** 验证规则配置 */
export interface ValidationRules {
  [field: string]: ValidationRule
}

/** Hook 配置选项 */
export interface UseFormValidationOptions {
  /** 验证规则映射:字段名 → 验证函数 */
  rules: ValidationRules
  /** 初始触屏状态 */
  initialTouched?: Record<string, boolean>
}

/** Hook 返回值 */
export interface UseFormValidationReturn<T extends Record<string, string>> {
  /** 当前错误:field → error message,无错误时字段不存在 */
  errors: Partial<Record<keyof T & string, string>>
  /** 触屏状态:field → has been interacted with */
  touched: Record<string, boolean>
  /**
   * 获取某个字段的通用 props(onChange / onBlur / statusClass)
   * 适用于浮度标签 input
   */
  getFieldStatus: (field: keyof T & string) => {
    hasError: boolean
    isTouched: boolean
    error: string | undefined
    isValid: boolean
  }
  /** 处理 onChange:如果字段已触碰,实时验证 */
  handleChange: (field: keyof T & string, value: string) => void
  /** 处理 onBlur:标记触碰 + 验证 */
  handleBlur: (field: keyof T & string, value: string) => void
  /** 验证所有字段,返回是否通过 */
  validateAll: (values: T) => { valid: boolean; errors: Partial<Record<keyof T & string, string>> }
  /** 重置全部状态 */
  reset: () => void
}

export function useFormValidation<T extends Record<string, string>>(
  options: UseFormValidationOptions
): UseFormValidationReturn<T> {
  const { rules, initialTouched = {} } = options

  const [errors, setErrors] = useState<Partial<Record<string, string>>>({})
  const [touched, setTouched] = useState<Record<string, boolean>>(initialTouched)
  // 用于控制验证图标动画:空字符 = 未验证, 'error' = 错误, 'valid' = 通过
  const [fieldStatus, setFieldStatus] = useState<Record<string, '' | 'error' | 'valid'>>({})

  /** 验证单个字段 */
  const validateField = useCallback(
    (field: string, value: string): string => {
      const rule = rules[field]
      if (!rule) return ''
      return rule(value) || ''
    },
    [rules]
  )

  /** 获取字段状态 */
  const getFieldStatus = useCallback(
    (field: keyof T & string) => {
      const hasError = !!errors[field]
      const isTouched = !!touched[field]
      const error = errors[field]
      const isValid = isTouched && !hasError && fieldStatus[field] === 'valid'
      return { hasError, isTouched, error, isValid }
    },
    [errors, touched, fieldStatus]
  )

  /** 处理 onChange */
  const handleChange = useCallback(
    (field: keyof T & string, value: string) => {
      // 如果已触碰,实时验证
      if (touched[field]) {
        const err = validateField(field, value)
        setErrors((prev) => {
          const next = { ...prev }
          if (err) {
            next[field] = err
            setFieldStatus((s) => ({ ...s, [field]: 'error' }))
          } else {
            delete next[field]
            setFieldStatus((s) => ({ ...s, [field]: 'valid' }))
          }
          return next
        })
      }
    },
    [touched, validateField]
  )

  /** 处理 onBlur */
  const handleBlur = useCallback(
    (field: keyof T & string, value: string) => {
      setTouched((prev) => ({ ...prev, [field]: true }))
      const err = validateField(field, value)
      setErrors((prev) => {
        const next = { ...prev }
        if (err) {
          next[field] = err
          setFieldStatus((s) => ({ ...s, [field]: 'error' }))
        } else {
          delete next[field]
          setFieldStatus((s) => ({ ...s, [field]: 'valid' }))
        }
        return next
      })
    },
    [validateField]
  )

  /** 全量验证 */
  const validateAll = useCallback(
    (values: T) => {
      const newErrors: Record<string, string> = {}
      const newTouched: Record<string, boolean> = {}
      const newStatus: Record<string, 'error' | 'valid'> = {}

      Object.keys(rules).forEach((field) => {
        newTouched[field] = true
        const value = values[field] ?? ''
        const err = validateField(field, value)
        if (err) {
          newErrors[field] = err
          newStatus[field] = 'error'
        } else {
          newStatus[field] = 'valid'
        }
      })

      setTouched(newTouched)
      setErrors(newErrors)
      setFieldStatus(newStatus)

      return { valid: Object.keys(newErrors).length === 0, errors: newErrors as Partial<Record<keyof T & string, string>> }
    },
    [rules, validateField]
  )

  /** 重置 */
  const reset = useCallback(() => {
    setErrors({})
    setTouched({})
    setFieldStatus({})
  }, [])

  return {
    errors,
    touched,
    getFieldStatus,
    handleChange,
    handleBlur,
    validateAll,
    reset,
  }
}