【山竹记账前端-react】22.封装表单组件


大纲链接 §

[toc]


1. 封装表单组件 ⇧

封装表单项,规划表单结构 value + onChange ⇧

  • form 表单主体
    • form-item 表单项
    • form-label 表单标题
    • form-item-content 表单项内容
    • form-item-error 表单项错误提示

封装表单项 InputText.tsx ⇧

@/components/form/item/InputText.tsx

  • inputType 类型支持 'text' | 'password' | 'email' | 'number'
  • 外部传参值 value、errorHint
  • 通过 onChange、onBlur、onFocus 在父组件中修改 value
  • 在 label 标签中设置属性 htmlFor,联结输入框 id 属性

     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
    
    import { cn } from 'cn'
    
    interface Props {
    className?: string
    ifUseLabelTag?: boolean
    // for 属性,用于内部 input 标签 id 属性联动
    labelFor?: string
    label?: string
    hasLabelColon?: boolean
    errorHint?: string
    hasErrorPlaceholder?: boolean
    placeholder?: string
    inputType?: 'text' | 'password' | 'email' | 'number'
    value?: string
    onChange?: (value: string) => void
    onBlur?: () => void
    onFocus?: () => void
    }
    
    export const InputText: React.FC<Props> = ({
    className,
    label,
    labelFor,
    hasLabelColon = true,
    ifUseLabelTag,
    errorHint,
    hasErrorPlaceholder = true,
    placeholder,
    inputType = 'text',
    value,
    onChange,
    onBlur,
    onFocus,
    }) => {
    const labelForId = labelFor && `form-item-${inputType}-${labelFor}`
    const labelChildren = (
    <>
      {label}
      {hasLabelColon ? ':' : ''}
    </>
    )
    const labelTag = ifUseLabelTag ? (
    <label {...(labelForId ? { htmlFor: labelForId } : {})} className="form-label-sc">
      {labelChildren}
    </label>
    ) : (
    <div className="form-label-sc">{labelChildren}</div>
    )
    
    return (
    <section className={cn('form-item-sc', className)}>
      {label && labelTag}
      <div className="form-item-content-sc">
        <input
          id={labelForId}
          type={inputType}
          value={value}
          onChange={(e) => onChange?.(e.target.value)}
          onBlur={onBlur}
          onFocus={onFocus}
          placeholder={placeholder}
          className={cn(
            'form-input-text-sc',
            errorHint ? 'b-[var(--error-color)]' : 'b-[var(--input-border-color)]'
          )}
        />
      </div>
      {(errorHint || hasErrorPlaceholder) && (
        <span className="form-item-error-sc w-fit buzz">{errorHint ?? <>&nbsp;</>}</span>
      )}
    </section>
    )
    }
    
  • 清晰地划分表单项结构:外层容器 <section className={cn('form-item-sc', className)}>

    • 标签标题 {label && labelTag}
    • 表单项主体内容 <div className="form-item-content-sc"><input .../></div>
    • 错误提示 <span className="form-item-error-sc w-fit buzz">{errorHint ?? <>&nbsp;</>}</span>

封装表单项 InputEmoji.tsx ⇧

@/components/form/item/InputEmoji.tsx

 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
import { EmojiSelector } from '@/components/form/item/emoji/EmojiSelector'
import { cn } from 'cn'
import { useState } from 'react'

interface Props {
  className?: string
  label?: string
  hasLabelColon?: boolean
  errorHint?: string
  hasErrorPlaceholder?: boolean
  value?: string
  onChange?: (value: string) => void
  onBlur?: () => void
  onFocus?: () => void
}

export const InputEmoji: React.FC<Props> = ({
  className,
  label = '符号',
  hasLabelColon,
  errorHint,
  hasErrorPlaceholder,
  value = '',
  onChange,
  onBlur,
  onFocus,
}) => {
  const [emoji, _setEmoji] = useState(value)
  const [emojiHint, setEmojiHint] = useState('')
  const setEmoji = (emoji: string) => {
    _setEmoji(emoji)
    onChange?.(emoji)
  }
  return (
    <section className={cn('form-item-sc relative', className)}>
      <div onBlur={onBlur} onFocus={onFocus} className="form-label-sc text-[22px]">
        <span className="text-[16px]">
          {label} {hasLabelColon ? ':' : ''}
        </span>
        <span className={cn('text-22px inline-block wag')}>{emoji}</span>
        <span className="text-[16px] animate-rainbow color-[var(--vp-c-brand-next)]">
          {emojiHint}
        </span>
      </div>
      <EmojiSelector
        onChangeEmojiUnicode={setEmoji}
        onChangeEmojiHint={setEmojiHint}
        hasError={!!errorHint}
        className="form-item-content-sc"
      />
      {(errorHint || hasErrorPlaceholder) && (
        <span className="form-item-error-sc w-fit buzz">{errorHint ?? <>&nbsp;</>}</span>
      )}
    </section>
  )
}
  • 使用 _setEmoji 拦截器 setEmoji,更新本地组件的状态的同时,使用 onChange 改变父组件数据状态
  • 清晰地划分表单项结构:外层容器 <section className={cn('form-item-sc', className)}>
    • 标签标题包含标题、图标和提示信息
    • 表单项主体内容 <EmojiSelector .../>
    • 错误提示 <span className="form-item-error-sc w-fit buzz">{errorHint ?? <>&nbsp;</>}</span>

注意避免 set-state-in-effect ⇧

  • useEffect 同步代码改变状态,会重渲染。避免导致性能下降的重新渲染现象
  • 使用初始值代替
  • 使用派生值代替:某些数值可以从现有的属性或状态中计算得出时,就不要将它们存储在状态中了。相反,应该在渲染过程中进行计算
  • useRef 的情况除外,可用

@/modules/tags/emoji/EmojiUnicodeList.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
export const EmojiUnicodeList: React.FC<Props> = ({
  className,
  mainTopic = '表情',
  onChangeEmojiUnicode,
  onChangeEmojiHint,
}) => {
  const [emojiUnicode, setEmojiUnicode] = useState('')
  // ❌ 会造成重渲染,直接在外层组件中改变数据状态即可
  useEffect(() => {
    setEmojiUnicode('')
  }, [mainTopic])

  useEffect(() => {
    onChangeEmojiUnicode(emojiUnicode)
    onChangeEmojiHint(emojiHint)
  }, [emojiHint, emojiUnicode])

  return (/*...*/)
}

改造 TagCreatePage.tsx ⇧

@/pages/TagCreatePage.tsx

 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
import { type FormEventHandler } from 'react'
import { cn } from 'cn'
import { GradientBanner } from '@/components/GradientBanner'
import { TopNav } from '@/components/TopNav'
import { SvgIcon } from '@/components/SvgIcon'
import { InputText } from '@/components/form/item/InputText'
import { InputEmoji } from '@/components/form/item/InputEmoji'

interface Props {
  className?: string
}

export const TagCreatePage: React.FC<Props> = ({ className }) => {
  const Icon = <SvgIcon name="back-arrow" className="size-[24px]" />
  const onSubmit: FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault()
  }

  return (
    <div className={cn('container w-screen h-screen relative', className)}>
      <GradientBanner>
        <TopNav title="新建标签" icon={Icon} />
      </GradientBanner>

      <form onSubmit={onSubmit} className="form-sc">
        <InputText
          label="标签名"
          labelFor="tag-name"
          placeholder="请输入标签名称"
          ifUseLabelTag={true}
          errorHint="标签名太长"
        />

        <InputEmoji label="符号" errorHint="不能为空" />

        <p className="form-item-sc text-center">记账时长按即可进行编辑</p>

        <div className="form-item-sc">
          <button type="submit" className="primary-btn">
            确定
          </button>
        </div>
      </form>
    </div>
  )
}
  • TODO: 继续将 InputText 和 InputEmoji 相同部分抽取逻辑,封装为 FormItem.tsx 组件
  • TODO: 封装为 TagForm.tsx 组件,统一收集表单数据,之后发送数据和服务器交互

改造 SignInPage.tsx ⇧

@/pages/SignInPage.tsx

  • 先使用 InputText.tsx 重构邮箱表单项部分

      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
    
    import { InputText } from '@/components/form/item/InputText'
    import { GradientBanner } from '@/components/GradientBanner'
    import { SvgIcon } from '@/components/SvgIcon'
    import { TopNav } from '@/components/TopNav'
    import { request } from '@/lib/request/request'
    import { type FormRules, hasError } from '@/lib/validator/formValidate'
    import { type SignInFormData, useSignInStore } from '@/stores/useSignInStore'
    import { cn } from 'cn'
    import { type FormEventHandler } from 'react'
    import { useNavigate } from 'react-router-dom'
    
    interface Props {
    className?: string
    }
    
    const signInRules: FormRules<SignInFormData> = [
    { key: 'email', type: 'required', required: true, message: '请输入邮箱地址' },
    { key: 'email', type: 'pattern', regexp: /^.+@.+$/, message: '邮箱地址格式不正确' },
    { key: 'code', type: 'required', required: true, message: '请输入验证码' },
    { key: 'code', type: 'length', min: 6, max: 6, message: '请输入六位证码' },
    ] as const
    
    export const SignInPage: React.FC<Props> = ({ className }) => {
    const Icon = <SvgIcon name="back-arrow" className="size-[24px]" />
    
    const {
    data: lastData,
    error: lastError,
    setData,
    clearError,
    revalidateFormItems,
    } = useSignInStore()
    
    const onChangeEmail = (value: string) => {
    setData({
      ...lastData,
      email: value,
    })
    }
    
    const onChangeCode = (e: React.ChangeEvent<HTMLInputElement>) => {
    setData({
      ...lastData,
      code: e.target.value,
    })
    }
    
    const nav = useNavigate()
    const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
    e.preventDefault()
    revalidateFormItems<SignInFormData>(lastData, signInRules, lastError)
    // 注意过期的闭包 lastData、lastError 都是闭包,不能直接使用,需要用 useSignInStore.getState() 获取最新状态
    const freshError = useSignInStore.getState().error
    const freshData = useSignInStore.getState().data
    if (!hasError(freshError)) {
      await request.post('/api/v1/session', freshData)
      // TODO 保存 JWT 作为登录凭证
      nav('/home')
    }
    }
    
    return (
    <div className={cn('container w-screen h-screen relative', className)}>
      <GradientBanner>
        <TopNav title="登录" icon={Icon} />
      </GradientBanner>
    
      {/* logo */}
      <section className="text-center pt-[40px] pb-[1em]">
        <SvgIcon name="mangosteen" className="inline w-[64px] h-[68px]" />
        <h1 className="text-[2em] color-[var(--app-name-text)] font-bold">山竹记账</h1>
      </section>
    
      {/* 表单 */}
      <form onSubmit={onSubmit} className="form-sc">
        {/* 邮箱 */}
        <InputText
          label="邮箱地址"
          labelFor="email-address"
          inputType="email"
          placeholder="请输入邮箱,然后点击发送验证码"
          ifUseLabelTag={true}
          errorHint={lastError.email && lastError.email.join(',')}
          value={lastData.email}
          onChange={onChangeEmail}
          onBlur={() =>
            revalidateFormItems<SignInFormData>({ email: lastData.email }, signInRules, lastError)
          }
          onFocus={() => clearError('email')}
        />
    
        {/* 验证码 */}
        <section className="form-item-sc">
          <label htmlFor="sms-code" className="form-label-sc">
            验证码:
          </label>
          <div className="form-item-content-sc">
            <input
              type="text"
              id="sms-code"
              value={lastData.code}
              onChange={onChangeCode}
              onBlur={() =>
                revalidateFormItems<SignInFormData>({ code: lastData.code }, signInRules, lastError)
              }
              onFocus={() => clearError('code')}
              placeholder="六位数字"
              className="form-input-text-sc w-[9em]"
            />
            <button type="button" className="grow-1 form-btn-sc">
              发送验证码
            </button>
          </div>
          {
            <span className="form-item-error-sc">
              {(lastError.code && lastError.code.join(',')) ?? <>&nbsp;</>}
            </span>
          }
        </section>
    
        {/* 登录 */}
        <section className="form-item-sc mt-[100px]">
          <button type="submit" className="primary-btn">
            登录
          </button>
        </section>
      </form>
    </div>
    )
    }
    

继续封装 @/components/form/item/InputValidateCodeSend.tsx 组件

 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
import { cn } from 'cn'

interface Props {
  className?: string
  value?: string
  errorHint?: string
  onChange?: (code: string) => void
  onBlur?: () => void
  onFocus?: () => void
  onClickSendCode?: () => void
}

export const InputValidateCodeSend: React.FC<Props> = ({
  className,
  value,
  errorHint,
  onChange,
  onBlur,
  onFocus,
  onClickSendCode,
}) => {
  const inputType = 'text'
  const labelFor = `form-item-${inputType}-sms-code`
  return (
    <section className={cn('form-item-sc', className)}>
      <label htmlFor={labelFor} className="form-label-sc">
        验证码:
      </label>
      <div className="form-item-content-sc">
        <input
          type={inputType}
          id={labelFor}
          value={value}
          onChange={(e) => onChange?.(e.target.value)}
          onBlur={onBlur}
          onFocus={onFocus}
          placeholder="六位数字"
          className={cn(
            'form-input-text-sc w-[9em]',
            errorHint ? 'b-[var(--error-color)]' : 'b-[var(--input-border-color)]'
          )}
        />
        <button type="button" onClick={onClickSendCode} className="grow-1 form-btn-sc">
          发送验证码
        </button>
      </div>
      {<span className="form-item-error-sc w-fit buzz">{errorHint ?? <>&nbsp;</>}</span>}
    </section>
  )
}

继续改造 @/pages/SignInPage.tsx

  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
import { InputText } from '@/components/form/item/InputText'
import { InputValidateCodeSend } from '@/components/form/item/InputValidateCodeSend'
import { GradientBanner } from '@/components/GradientBanner'
import { SvgIcon } from '@/components/SvgIcon'
import { TopNav } from '@/components/TopNav'
import { request } from '@/lib/request/request'
import { type FormRules, hasError } from '@/lib/validator/formValidate'
import { type SignInFormData, useSignInStore } from '@/stores/useSignInStore'
import { cn } from 'cn'
import { type FormEventHandler } from 'react'
import { useNavigate } from 'react-router-dom'

interface Props {
  className?: string
}

const signInRules: FormRules<SignInFormData> = [
  { key: 'email', type: 'required', required: true, message: '请输入邮箱地址' },
  { key: 'email', type: 'pattern', regexp: /^.+@.+$/, message: '邮箱地址格式不正确' },
  { key: 'code', type: 'required', required: true, message: '请输入验证码' },
  { key: 'code', type: 'length', min: 6, max: 6, message: '请输入六位证码' },
] as const

export const SignInPage: React.FC<Props> = ({ className }) => {
  const Icon = <SvgIcon name="back-arrow" className="size-[24px]" />

  const {
    data: lastData,
    error: lastError,
    setData,
    clearError,
    revalidateFormItems,
  } = useSignInStore()

  const onChangeEmail = (value: string) => {
    setData({
      ...lastData,
      email: value,
    })
  }
  const onBlurEmail = () => {
    revalidateFormItems<SignInFormData>({ email: lastData.email }, signInRules, lastError)
  }
  const onFocusEmail = () => {
    clearError('email')
  }

  const onChangeCode = (value: string) => {
    setData({
      ...lastData,
      code: value,
    })
  }
  const onBlurCode = () => {
    revalidateFormItems<SignInFormData>({ code: lastData.code }, signInRules, lastError)
  }
  const onFocusCode = () => {
    clearError('code')
  }

  const onClickSendCode = () => {}

  const nav = useNavigate()
  const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
    e.preventDefault()
    revalidateFormItems<SignInFormData>(lastData, signInRules, lastError)
    // 注意过期的闭包 lastData、lastError 都是闭包,不能直接使用,需要用 useSignInStore.getState() 获取最新状态
    const freshError = useSignInStore.getState().error
    const freshData = useSignInStore.getState().data
    if (!hasError(freshError)) {
      await request.post('/api/v1/session', freshData)
      // TODO 保存 JWT 作为登录凭证
      nav('/home')
    }
  }

  return (
    <div className={cn('container w-screen h-screen relative', className)}>
      <GradientBanner>
        <TopNav title="登录" icon={Icon} />
      </GradientBanner>

      {/* logo */}
      <section className="text-center pt-[40px] pb-[1em]">
        <SvgIcon name="mangosteen" className="inline w-[64px] h-[68px]" />
        <h1 className="text-[2em] color-[var(--app-name-text)] font-bold">山竹记账</h1>
      </section>

      {/* 表单 */}
      <form onSubmit={onSubmit} className="form-sc">
        {/* 邮箱 */}
        <InputText
          label="邮箱地址"
          labelFor="email-address"
          inputType="email"
          placeholder="请输入邮箱,然后点击发送验证码"
          ifUseLabelTag={true}
          errorHint={lastError.email && lastError.email.join(',')}
          value={lastData.email}
          onChange={onChangeEmail}
          onBlur={onBlurEmail}
          onFocus={onFocusEmail}
        />

        {/* 验证码 */}
        <InputValidateCodeSend
          value={lastData.code}
          errorHint={lastError.code && lastError.code.join(',')}
          onChange={onChangeCode}
          onBlur={onBlurCode}
          onFocus={onFocusCode}
          onClickSendCode={onClickSendCode}
        />

        {/* 登录 */}
        <section className="form-item-sc mt-[100px]">
          <button type="submit" className="primary-btn">
            登录
          </button>
        </section>
      </form>
    </div>
  )
}
  • TODO: 继续封装 SignInForm.tsx 组件

封装泛用组件 FormItem.tsx ⇧

提取表单相关公共类型 @/components/form/item/FormItemType.ts

 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
export type FormItemType = 'input' | 'emoji' | 'validateCodeSend'

export type InputType = 'text' | 'password' | 'email' | 'number'

export type CommonFormItemTypeProps = {
  label?: string
  labelFor?: string
  ifUseLabelTag?: boolean
  hasLabelColon?: boolean
  placeholder?: string
  errorHint?: string
  hasErrorPlaceholder?: boolean
  value?: string
  onChange?: (value: string) => void
  onBlur?: () => void
  onFocus?: () => void
}

export type FormItemTypeProps<T extends FormItemType> = T extends 'input'
  ? { type?: 'input'; inputType: InputType } & CommonFormItemTypeProps
  : T extends 'emoji'
    ? { type?: 'emoji' } & CommonFormItemTypeProps
    : T extends 'validateCodeSend'
      ? { type?: 'validateCodeSend'; onClickSendCode?: () => void } & CommonFormItemTypeProps
      : never
  • 每个表单项类型都集成公共类型 CommonFormItemTypeProps
  • 约束类型 T extends 'input' 推导类型 { type?: 'input'; inputType: InputType } & CommonFormItemTypeProps
  • 约束类型 T extends 'emoji' 推导类型 { type?: 'emoji' } & CommonFormItemTypeProps
  • 约束类型 T extends 'validateCodeSend' 推导类型 { type?: 'validateCodeSend'; onClickSendCode?: () => void } & CommonFormItemTypeProps
  • 方便后续添加不同表单项类型

已知 InputText、InputValidateCodeSend、InputEmoji 三个组件的公共部分可提取为组件

 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
import { InputEmoji } from '@/components/form/item/InputEmoji'
import { InputText } from '@/components/form/item/InputText'
import { InputValidateCodeSend } from '@/components/form/item/InputValidateCodeSend'
import type { FormItemType, FormItemTypeProps } from '@/components/form/item/FormItemType.ts'

interface Props<T extends FormItemType> {
  className?: string
  formItemType: T
  formItemTypeProps: FormItemTypeProps<T>
}

export const FormItem = <T extends FormItemType>({
  className,
  formItemType,
  formItemTypeProps,
}: Props<T>) => {
  const renderContent = () => {
    switch (formItemType) {
      case 'input':
        return <InputText {...formItemTypeProps} className={className} />
      case 'emoji':
        return <InputEmoji {...formItemTypeProps} className={className} />
      case 'validateCodeSend':
        return <InputValidateCodeSend {...formItemTypeProps} className={className} />
      default:
        return null
    }
  }
  return <>{renderContent()}</>
}
  • 声明泛用组件 export const FormItem = <T extends FormItemType>({...}: Props<T>) => {}
  • Props中的 formItemType: T 调用时给定具体类型
  • Props中的 formItemTypeProps: FormItemTypeProps<T> 推断表单项组件参数的具体类型

改造 @/pages/TagCreatePage.tsx

 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
import { type FormEventHandler } from 'react'
import { cn } from 'cn'
import { GradientBanner } from '@/components/GradientBanner'
import { TopNav } from '@/components/TopNav'
import { SvgIcon } from '@/components/SvgIcon'
import { FormItem } from '@/components/form/item/FormItem'

interface Props {
  className?: string
}

export const TagCreatePage: React.FC<Props> = ({ className }) => {
  const Icon = <SvgIcon name="back-arrow" className="size-[24px]" />
  const onSubmit: FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault()
  }

  return (
    <div className={cn('container w-screen h-screen relative', className)}>
      <GradientBanner>
        <TopNav title="新建标签" icon={Icon} />
      </GradientBanner>

      <form onSubmit={onSubmit} className="form-sc">
        <FormItem
          formItemType="input"
          formItemTypeProps={{
            inputType: 'text',
            label: '标签名',
            labelFor: 'tag-name',
            placeholder: '请输入标签名称',
            ifUseLabelTag: true,
            errorHint: '标签名太长',
          }}
        />

        <FormItem
          formItemType="emoji"
          formItemTypeProps={{
            errorHint: '不能为空',
          }}
        />

        <p className="form-item-sc text-center">记账时长按即可进行编辑</p>

        <div className="form-item-sc">
          <button type="submit" className="primary-btn">
            确定
          </button>
        </div>
      </form>
    </div>
  )
}

改造 @/pages/SignInPage.tsx

 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
// import ...

interface Props {
  className?: string
}

const signInRules: FormRules<SignInFormData> = [/* ... */] as const

export const SignInPage: React.FC<Props> = ({ className }) => {
  const Icon = <SvgIcon name="back-arrow" className="size-[24px]" />
  const {
    data: lastData,
    error: lastError,
    setData,
    clearError,
    revalidateFormItems,
  } = useSignInStore()
  const onChangeEmail = (value: string) => {/* ... */}
  const onBlurEmail = () => {/* ... */}
  const onFocusEmail = () => {/* ... */}
  const onChangeCode = (value: string) => {/* ... */}
  const onBlurCode = () => {/* ... */}
  const onFocusCode = () => {/* ... */}
  const onClickSendCode = () => {}
  const nav = useNavigate()
  const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {/* ... */}

  return (
    <div className={cn('container w-screen h-screen relative', className)}>
      <GradientBanner>
        <TopNav title="登录" icon={Icon} />
      </GradientBanner>

      {/* logo */}
      <section className="text-center pt-[40px] pb-[1em]">
        <SvgIcon name="mangosteen" className="inline w-[64px] h-[68px]" />
        <h1 className="text-[2em] color-[var(--app-name-text)] font-bold">山竹记账</h1>
      </section>

      {/* 表单 */}
      <form onSubmit={onSubmit} className="form-sc">
        {/* 邮箱 */}
        <FormItem
          formItemType="input"
          formItemTypeProps={{
            inputType: 'email',
            label: '邮箱地址',
            labelFor: 'email-address',
            placeholder: '请输入邮箱,然后点击发送验证码',
            ifUseLabelTag: true,
            errorHint: lastError.email && lastError.email.join(','),
            value: lastData.email,
            onChange: onChangeEmail,
            onBlur: onBlurEmail,
            onFocus: onFocusEmail,
          }}
        />

        {/* 验证码 */}
        <FormItem
          formItemType="validateCodeSend"
          formItemTypeProps={{
            value: lastData.code,
            errorHint: lastError.code && lastError.code.join(','),
            onChange: onChangeCode,
            onBlur: onBlurCode,
            onFocus: onFocusCode,
            onClickSendCode: onClickSendCode,
          }}
        />

        {/* 登录 */}
        <section className="form-item-sc mt-[100px]">
          <button type="submit" className="primary-btn">
            登录
          </button>
        </section>
      </form>
    </div>
  )
}

2. 封装表单组件2 ⇧


3. 表单验证 ⇧


4. 消除 key 警告 ⇧


5. 新页面:编辑标签 ⇧


6. $3 ⇧


·未完待续·

参考文章

相关文章

  • 无

  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名