【山竹记账前端-vue】12.表单验证


大纲链接 §

[toc]


1. 重构拆分组件,v-model 绑定多个数据 ⇧

  • 拆分为两个组件:EmojiMainTopicNav.tsx、EmojiUnicodeList.tsx
    • EmojiMainTopicNav.tsx
    • 渲染展示主题列表和当前选中项
    • 切换选择主题,更新本地选中项,发布emoji、emojiHint更新事件;
    • EmojiUnicodeList.tsx
    • 渲染展示当前主题下的颜文字编码列表和当前选中项
    • 入参 selectedMainTopic: 选中的主题
    • 监听入参主题变化,清空本地selectedEmojiUnicode,发布emoji、emojiHint更新事件;
    • 切换选择颜文字编码,更新本地selectedEmojiUnicode,发布emoji、emojiHint更新事件;

@/modules/tags/emoji/EmojiMainTopicNav.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 {
  ref,
  computed,
  defineComponent,
  type ComputedRef,
  type PropType,
} from 'vue'
import { cn } from 'cn'
import { type EmojiMainTopic, emojiTopicList } from '@/const/emojiTopicList.ts'

export const EmojiMainTopicNav = defineComponent({
  name: 'EmojiMainTopicNav',
  props: {
    mainTopic: { type: String as PropType<EmojiMainTopic>, default: '表情', },
  },
  emits: ['update:mainTopic',],
  setup( _props, _ctx ) {
    const navList: ComputedRef<EmojiMainTopic[]> = computed(() => emojiTopicList
      .map(topicItem => topicItem.mainTopic))
    const selectedMainTopic =
      ref<EmojiMainTopic>(navList.value?.[0] ?? _props.mainTopic)
    const selectMainTopic = (topic: EmojiMainTopic) => {
      _ctx.emit('update:mainTopic', topic)
      selectedMainTopic.value = topic
    }
    return () => (
      <nav>
        <menu
          class={cn(
            'flex flex-nowrap overflow-auto mx-1em py-0 scrollbar-hide',
            '[&>li.selected]:(text-[var(--emoji-nav-text-selected)] font-bold)',
          )}>
          {
            navList.value
            .map((item) => (
              <li
                onClick={() => selectMainTopic(item)}
                class={cn(
                  'shrink-0 inline-block p-.5em text-[var(--emoji-nav-text)] whitespace-nowrap',
                  item === selectedMainTopic.value ? 'selected' : '',
                )}>
                {item}
              </li>
            ))
          }
        </menu>
      </nav>
    )
  },
})

@/modules/tags/emoji/EmojiUnicodeList.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
import { computed, type ComputedRef, defineComponent, type PropType, ref, watch } from 'vue'
import { cn } from 'cn'
import { type EmojiItem, safeEmojiList } from '@/const/safeEmojiList.ts'
import { type EmojiMainTopic, type EmojiSubTopic, emojiTopicList } from '@/const/emojiTopicList.ts'

export const EmojiUnicodeList = defineComponent({
  name: 'EmojiUnicodeList',
  props: {
    selectedMainTopic: {
      type: String as PropType<EmojiMainTopic>,
      default: '表情',
    },
  },
  emits: ['update:emoji', 'update:emojiHint'],
  setup(_props, _ctx) {
    const selectedTopicList: ComputedRef<readonly EmojiSubTopic[]> = computed(
      () =>
        emojiTopicList.find((topicItem) => topicItem.mainTopic === _props.selectedMainTopic)
          ?.topics ?? [],
    )
    const selectedFlattedEmojiList: ComputedRef<EmojiItem[]> = computed(() =>
      safeEmojiList
        .filter((item) => selectedTopicList.value.includes(item.topic))
        .flatMap((item) => item.emoji_list),
    )
    const selectedMainTopicEmojiUnicodeList: ComputedRef<string[]> = computed(() =>
      selectedFlattedEmojiList.value.map((item) => item.unicode),
    )
    const selectedEmojiUnicode = ref('')
    const selectedEmojiHint: ComputedRef<string> = computed(
      () => selectedFlattedEmojiList.value
        ?.find((emojiItem) =>
          emojiItem.unicode === selectedEmojiUnicode.value,)
        ?.hint ?? '',
    )
    const switchEmojiUnicode = (unicode: string) => {
      selectedEmojiUnicode.value = unicode
      _ctx.emit('update:emoji', unicode)
      _ctx.emit('update:emojiHint', selectedEmojiHint.value)
    }
    watch(() => _props.selectedMainTopic, () => {
      selectedEmojiUnicode.value = ''
      _ctx.emit('update:emoji', '')
      _ctx.emit('update:emojiHint', '')
    },)
    return () => (
      <ul
        class={cn(
          'scrollbar-hide overflow-auto h-[calc(1.65em_*_12)]',
          'flex flex-wrap content-start px-.5em py-2.5px',
          'text-22px line-height-1.5em',
        )}>
        {selectedMainTopicEmojiUnicodeList.value.map((unicode) => (
          <li
            onClick={() => switchEmojiUnicode(unicode)}
            class={cn(
              'text-center grow-0 shrink-0 w-10% b-1 b-transparent',
              unicode === selectedEmojiUnicode.value &&
              'bg-blue-300 outline-1px outline-solid outline-[var(--button-bg)] rounded-[var(--input-radius)]',
            )}>
            {unicode}
          </li>
        ))}
      </ul>
    )
  },
})

@/modules/tags/EmojiSelector.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
import { defineComponent, ref, watch } from 'vue'
import { cn } from 'cn'
import { type EmojiMainTopic } from '@/const/emojiTopicList.ts'
import { EmojiMainTopicNav } from '@/modules/tags/emoji/EmojiMainTopicNav.tsx'
import { EmojiUnicodeList } from '@/modules/tags/emoji/EmojiUnicodeList.tsx'

export const EmojiSelector = defineComponent({
  name: 'EmojiSelector',
  props: {
    emoji: { type: String, default: '' },
    emojiHint: { type: String, default: '' },
  },
  emits: ['update:emoji', 'update:emojiHint'],
  setup(_props, _ctx) {
    const selectedMainTopic = ref<EmojiMainTopic>('表情')
    const selectedEmoji = ref('')
    const selectedEmojiHint = ref('')
    watch([selectedEmoji, selectedEmojiHint], () => {
      _ctx.emit('update:emoji', selectedEmoji.value)
      _ctx.emit('update:emojiHint', selectedEmojiHint.value)
    })
    const clearEmoji = () => {
      selectedEmoji.value = ''
      selectedEmojiHint.value = ''
      _ctx.emit('update:emoji', '')
      _ctx.emit('update:emojiHint', '')
    }
    watch(selectedMainTopic, () => {
      clearEmoji()
    })
    return () => (
      <div
        class={cn(
          'min-h-[var(--input-min-height)] max-w-full flex-grow-1',
          'b-1 rounded-[var(--input-radius)] text-14px',
          // 动态切换边框颜色 hasError
          Math.random() > 0.5 ? 'b-[var(--input-border-color)]' : 'b-[var(--error-color)]',
        )}>
        <EmojiMainTopicNav v-model:mainTopic={selectedMainTopic.value} />
        <EmojiUnicodeList
          selectedMainTopic={selectedMainTopic.value}
          v-model:emoji={selectedEmoji.value}
          v-model:emojiHint={selectedEmojiHint.value}
        />
      </div>
    )
  },
})

2. 设计表单验证器 ⇧

TagForm.tsx 提交前需要验证数据

  • 不推荐直接在确定按钮点击事件中写提交逻辑
    • 类型为提交 <BarButton type="submit" onClick={() => {}}>确定</BarButton>
    • 默认点击按钮提交会触发页面刷新,需要使用 e.preventDefault() 阻止默认事件
  • 推荐在 form 表单中监听 onSubmit 事件,可以通过其他方式提交:回车、键盘提交按钮和点击确认等都可触发
    • 提交时先打印出表单数据 formData 为一个 reactive 代理对象
    • 需要转化为原生对象 toRaw(formData),即把代理对象的原始值返回,这个才是需要校验和提交给服务器的数据
  • 需要将校验逻辑和 form 组件解耦
    • 计算错误 const errors = validate(formData, rules),其中
    • rules 校验规则按顺序一个个独立校验数据,所以是一个数组
    • 在这个数组中再具体描述需要校验的规则的对象,例如 { key: 'name', required: true, pattern: /.{1,4}/, message: 'xxx',},
    • 但是需要注意的是,为了清晰地表述错误提示信息,必填、验证其他例如字符长度等;需要分开写到不同的校验规则中,并且按照顺序校验
    • { key: 'name', required: true, message: '必填', },
    • { key: 'name', pattern: /^.{1,4}$/, message: '必须1~4字符', },
    • 每一个校验只能描述一种规则类型
    • 将错误信息展示在底部留好的节点中 form_errorHint
    • 错误的数据结构 const errors = { name: ['错误1', '错误2'], sign: ['错误3', '错误4'],}
    • 以校验规则中的 key 作为属性字段,所有错误信息放在数组中

@/modules/tags/TagForm.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
import { defineComponent, reactive, ref, toRaw } from 'vue'
import { BarButton } from '@/components/BarButton.tsx'
import { cn } from 'cn'
import { EmojiSelector } from '@/modules/tags/EmojiSelector.tsx'

export const TagForm = defineComponent({
  name: 'TagForm',
  setup(/* _props, _ctx */) {
    const formData = reactive({
      name: '',
      sign: '',
    })
    const emojiHint = ref('')
    const onSubmit = (e: SubmitEvent) => {
      e.preventDefault()
      console.log('_______________________')
      console.log(toRaw(formData))
      console.log('_______________________')
      // const errors = validate(formData, rules)
      const rules = [
        { key: 'name', required: true, message: '', },
        { key: 'name', pattern: /^.{0,4}$/, message: '', },
        { key: 'sign', required: true,},
      ]
      const errors = {
        name: ['错误1', '错误2'],
        sign: ['错误3', '错误4'],
      }
    }
    return () => (
      <form
        onSubmit={onSubmit}
        class="p-1em min-h-720px h-full">
        <div class="mt-0.5em flex-grow-0">
          <label for="tag-name">
            <span class="">标签名称</span>

            <div class="flex mt-0.25em">
              <input
                id="tag-name"
                v-model={formData.name}
                class={cn(/* ... */)}
              />
            </div>

            <div class="m-t-.25em text-12px color-[var(--error-color)]">
              <span>{errors['name'].join(',')}</span>
            </div>
          </label>
        </div>

        <div class="mt-0.5em">
          <label class="flex flex-col">
            <span class="">符号 {formData.sign} {emojiHint.value}</span>

            <div class="flex mt-0.25em">
              <EmojiSelector
                v-model:emoji={formData.sign}
                v-model:emojiHint={emojiHint.value}/>
            </div>

            <div class="m-t-.25em text-12px color-[var(--error-color)]">
              <span>必填</span>
            </div>
          </label>
        </div>

        <p class="tips flex-grow-0 text-center py-1em px-0 min-h-[var(--input-min-height)]">
          记账时长按即可进行编辑
        </p>

        <div class="flex-grow-0 mt-0.5em min-h-[var(--input-min-height)]">
          <div class="mt-0.25em">
            <BarButton
              type="submit"
              onClick={() => {}}
              class="formItem button formItem_value">确定</BarButton>
          </div>
        </div>
      </form>
    )
  },
})

3. 实现表单验证器 ⇧

类型的循环引用问题 ⇧

  • 数据类型 type FormDataType = Record<string, string | number | null | undefined | FormDataType>
    • 使用别名类型,递归引用报错 TS2456: Type alias FormDataType circularly references itself. 类型别名不可循环引用自身
  • 改用 interface 就行了 interface FormDataType { [key: string]: string | number | null | undefined | FormDataType }

类型别名的并集互斥属性 ⇧

Rule 的类型

 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
/*
type Rule = {
  key: string
  message: string
} & (A | B)
// 这个行不通,有坑点
*/

type Rule = {
  key: string
  message: string
} & (
  { required: boolean } |
  { pattern: RegExp }
  )
type Rules = Rule[]
// 预想的是 required 和 pattern 属性互斥
const rules: Rules = [
  // 但是 pattern 还可以选,问题在于二选一的里面没有重合的部分
  {key: 'name', message: '', required: true, pattern: /hi/,},
  {key: 'name', message: '', pattern: /^.{1,4}&/},
]
// 需要改为
type Rule = {
  key: string
  message: string
} & (
  { type: 'required', required: boolean } |
  { type: 'pattern', pattern: RegExp }
  )
  • 注意类型别名并集互斥,需要添加一个额外描述作为重合的属性,来区分
  • 即 A | B 中需要一个子属性,为互斥的字段,就可以做到互斥
  • 添加一个区分属性 type
    • 可实现 {key: 'name', message: '', type: 'required', required: true, pattern: /123/},
    • 提示类型报错 TS2353: Object literal may only specify known properties, and pattern does not exist in type

使用泛型实现 FormDataType 与 RuleType 的关联

  • FormDataType 的 [k: string] 必须属于 Rule 的 key 属性
  • 注意 tsx 中,在箭头函数参数前声明泛型 <T> 可能报错
    • 需要额外添加 <T extends Xxx>() => ...

@/shared/validator/formValidate.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
interface FormDataType {
  [key: string]: string | number | null | undefined | FormDataType
}

type Rule<T> = {
  key: keyof T
  message: string
} & (
  { type: 'required', required: boolean } |
  { type: 'pattern', regexp: RegExp }
  )
type Rules<T> = Rule<T>[]

export const formValidate = <T extends FormDataType>(
  formData: T,
  rules: Rules<T>,
) => {
  console.log('_______________________')
  console.log("%c 3 --> formData: ","color:#ff0;", formData);
  console.log("%c 3 --> rules: ","color:#ff0;", rules);
  console.log('_______________________')
}
  • 使用泛型实现了函数两个参数之间的关联
    • type Rule<T> = { key: keyof T; message: string; } & {...}
  • T 表示 FormData 的类型,那么 Rule 的属性 key 就必须是 keyof T

声明 ErrorsType ⇧

  • 简单地声明 type Errors = { [k: string]: string[] },但不够精确
  • 其中 k 也是属于 FormDataType 的 key,必须是其子集
    • type Errors<T extends FormDataType> = { [k in keyof T]?: string[] }

@/shared/validator/formValidate.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
26
27
28
29
30
31
32
interface FormDataType {
  [key: string]: string | number | null | undefined | FormDataType
}

type Rule<T> = {
  key: keyof T
  message: string
} & (
  { type: 'required', required: boolean } |
  { type: 'pattern', regexp: RegExp }
  )
type Rules<T> = Rule<T>[]
type ErrorsType<T extends FormDataType> = { [k in keyof T]?: string[] }

export const formValidate = <T extends FormDataType>(
  formData: FormDataType,
  rules: Rules<T>,
): ErrorsType<T> => {
  const errors: ErrorsType<T> = {}

  rules.forEach(rule => {
    const { key, message, type, } = rule
    const value = formData[key as keyof FormDataType]
    if(type === 'required') {
      errors[key] = [message]
    }else if(type === 'pattern' && value && !rule.regexp.test(value.toString())) {
      errors[key] = [message]
    }
  })

  return errors
}
  • 遍历 rules,解构出各个属性,获取属性值 formData[key]
  • 通过 type 字段判断规则类型,最不同分支逻辑
  • 通过各个分支验证条件,将规则报错信息放入 errors[key] = [message],条件都是互斥的
    • 是否必要,是否为空
    • 是否满足正则

改成 switch 语法 ⇧

@/shared/validator/formValidate.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
interface FormDataType {
  [key: string]: string | number | null | undefined | FormDataType
}

type Rule<T> = {
  key: keyof T
  message: string
} & (
  { type: 'required', required: boolean } |
  { type: 'pattern', regexp: RegExp }
  )
type Rules<T> = Rule<T>[]
type ErrorsType<T extends FormDataType> = { [k in keyof T]?: string[] }

export const formValidate = <T extends FormDataType>(
  formData: T,
  rules: Rules<T>,
): ErrorsType<T> => {
  const errors: ErrorsType<T> = {}

  rules.forEach(rule => {
    const { key, message, type, } = rule
    const value = formData[key]
    switch(type) {
      case 'required':
        if([null, undefined, ''].includes(value?.toString())) {
          errors[key] ??= []
          errors[key].push(message)
        }
        break
      case 'pattern':
        if(value && !rule.regexp.test(value.toString())) {
          errors[key] ??= []
          errors[key].push(message)
        }
        break
      default: return
    }

  })

  return errors
}
  • 初始化 errors[key] ||= [] 等价于 errors[key] = errors[key] || [],errors[key]为空值则将其赋值为[]
  • 可以改为 errors[key] ??= [] 等价于 errors[key] = errors[key] ?? []

参考


两个不足 ⇧

  • 不支持自定义校验器 {type: 'custom', validatorFn: ...,} |
    • 后续实现
  • 不支持异步验证:例如判断用户名、邮箱是否全网唯一,需要想后端询问是否有重复
    • 前端暂不支持
    • 后端可以返回一个类型是 ErrorsType 的数据就行了
    • 统一错误的数据结构就可以满足所有需求

@/modules/tags/TagForm.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
import { defineComponent, reactive, ref, toRaw } from 'vue'
import { BarButton } from '@/components/BarButton.tsx'
import { cn } from 'cn'
import { EmojiSelector } from '@/modules/tags/EmojiSelector.tsx'
import {
  type ErrorsType,
  type FormDataType,
  formValidate,
  type Rules,
} from '@/shared/validator/formValidate.ts'

export const TagForm = defineComponent({
  name: 'TagForm',
  setup(/* _props, _ctx */) {
    const emojiHint = ref('')
    const formData = reactive<FormDataType>({
      name: '',
      sign: '',
    })
    const errors = ref<ErrorsType<FormDataType>>({})
    const onSubmit = (e: SubmitEvent) => {
      e.preventDefault()
      const rules = [
        { key: 'name', type: 'required', required: true, message: '必填', },
        { key: 'name', type: 'pattern', regexp: /^.{1,4}$/, message: '只能填1到4个字符', },
        { key: 'sign', type: 'required', required: true, message: '必填', },
      ] satisfies Rules<FormDataType>
      errors.value = formValidate(toRaw(formData), rules)
    }
    return () => (
      <form
        onSubmit={onSubmit}
        class="p-1em min-h-720px h-full">
        <div class="mt-0.5em flex-grow-0">
          <label for="tag-name">
            <span>标签名称</span>

            <div class="flex mt-0.25em">
              <input
                id="tag-name"
                v-model={formData.name}
                class={cn(
                  'flex-grow-1 min-h-[var(--input-min-height)] max-w-100%',
                  'b-1 rounded-[var(--input-radius)]',
                  'text-18px p-x-1em shadow-[inset_0_0_3px_var(--input-shadow)]',
                  // 动态切换边框颜色 hasError
                  Math.random() > 0.5 ? 'b-[var(--input-border-color)]' : 'b-[var(--error-color)]',
                )}
              />
            </div>

            <div class="m-t-.25em text-12px color-[var(--error-color)]">
              <span>{errors.value.name?.join(', ')}</span>
            </div>
          </label>
        </div>

        <div class="mt-0.5em">
          <label class="flex flex-col">
            <span>符号 {formData.sign} {emojiHint.value}</span>

            <div class="flex mt-0.25em">
              <EmojiSelector
                v-model:emoji={formData.sign}
                v-model:emojiHint={emojiHint.value}/>
            </div>

            <div class="m-t-.25em text-12px color-[var(--error-color)]">
              <span>{errors.value.sign?.join(', ')}</span>
            </div>
          </label>
        </div>

        <p class="tips flex-grow-0 text-center py-1em px-0 min-h-[var(--input-min-height)]">
          记账时长按即可进行编辑
        </p>

        <div class="flex-grow-0 mt-0.5em min-h-[var(--input-min-height)]">
          <div class="mt-0.25em">
            <BarButton
              type="submit"
              onClick={() => {}}
              class="formItem button formItem_value">确定</BarButton>
          </div>
        </div>
      </form>
    )
  },
})
  • ts 如果声明中间变量,会预先推测变量类型,要么将类型推测得过于宽泛,要么会降类型推测得狭隘了
    • const rules = [{...}, {...}, {...}] 宽泛;const rules = [{...}, {...}, {...}] 狭隘
    • 推测后的类型无法判断其中两个属性是互斥的:regexp 与 required
    • 也无法判断其中两个属性必须都存在:type: 'pattern' 与 regexp: /hi/
    • 所以传参给 formValidate 会报类型错误
  • 或不声明中间变量,直接将字面量放到参数中,不报类型错误,因为ts就好累推测类型这部,直接判断数据是否满足类型
    • 即只做类型对比,不做类型推测
  • 推荐的做法是明确地声明其类型 const rules: Rules<FormDataType> = [...]
    • 也可以类型断言 const rules = [...] as Rules<FormDataType>
    • 也可以类型满足 const rules = [...] satisfies Rules<FormDataType> 既能做类型安全检查,又能保留最具体的字面量类型

参考


完善错误提示功能,并修正样式 ⇧

从 @/modules/tags/TagForm.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
import { defineComponent, reactive, ref, toRaw } from 'vue'
import { BarButton } from '@/components/BarButton.tsx'
import { cn } from 'cn'
import { EmojiSelector } from '@/modules/tags/EmojiSelector.tsx'
import {
  type FormErrorsType,
  type FormDataType,
  type FormRules,
  formValidate,
} from '@/shared/validator/formValidate.ts'

export const TagForm = defineComponent({
  name: 'TagForm',
  setup(/* _props, _ctx */) {
    const emojiHint = ref('')
    const formData = reactive<FormDataType>({
      name: '',
      sign: '',
    })
    const errors = ref<FormErrorsType<FormDataType>>({})
    const onSubmit = (e: SubmitEvent) => {
      e.preventDefault()
      const rules = [
        { key: 'name', type: 'required', required: true, message: '必填', },
        { key: 'name', type: 'pattern', regexp: /^.{1,4}$/, message: '只能填1到4个字符', },
        { key: 'sign', type: 'required', required: true, message: '必填', },
      ] satisfies FormRules<FormDataType>
      errors.value = formValidate(toRaw(formData), rules)
    }
    return () => (
      <form
        onSubmit={onSubmit}
        class="p-1em min-h-720px h-full">
        <div class="mt-0.5em flex-grow-0">
          <label for="tag-name">
            <span>标签名称</span>

            <div class="flex mt-0.25em">
              <input
                id="tag-name"
                v-model={formData.name}
                class={cn(
                  'flex-grow-1 min-h-[var(--input-min-height)] max-w-100%',
                  'b-1 rounded-[var(--input-radius)]',
                  'text-18px p-x-1em shadow-[inset_0_0_3px_var(--input-shadow)]',
                  errors.value.name ? 'b-[var(--error-color)]' : 'b-[var(--input-border-color)]',
                )}
              />
            </div>

            <div class="m-t-.25em text-12px color-[var(--error-color)]">
              <span>{errors.value.name?.join(', ')}</span>
            </div>
          </label>
        </div>

        <div class="mt-0.5em">
          <label class="flex flex-col">
            <span>符号 {formData.sign} {emojiHint.value}</span>

            <div class="flex mt-0.25em">
              <EmojiSelector
                errorHint={errors.value.sign?.join(', ')}
                v-model:emoji={formData.sign}
                v-model:emojiHint={emojiHint.value}/>
            </div>

            <div class="m-t-.25em text-12px color-[var(--error-color)]">
              <span>{errors.value.sign?.join(', ')}</span>
            </div>
          </label>
        </div>

        <p class="tips flex-grow-0 text-center py-1em px-0 min-h-[var(--input-min-height)]">
          记账时长按即可进行编辑
        </p>

        <div class="flex-grow-0 mt-0.5em min-h-[var(--input-min-height)]">
          <div class="mt-0.25em">
            <BarButton
              type="submit"
              onClick={() => {}}
              class="formItem button formItem_value">确定</BarButton>
          </div>
        </div>
      </form>
    )
  },
})
  • 获取错误验证结果 errors.value = formValidate(toRaw(formData), rules)
  • 显示错误提示 <span>{errors.value.name?.join(', ')}</span>
  • 错误判断样式 errors.value.name ? 'b-[var(--error-color)]' : 'b-[var(--input-border-color)]',

传入子组件 @/modules/tags/EmojiSelector.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
import { defineComponent, ref, watch } from 'vue'
import { cn } from 'cn'
import { type EmojiMainTopic } from '@/const/emojiTopicList.ts'
import { EmojiMainTopicNav } from '@/modules/tags/emoji/EmojiMainTopicNav.tsx'
import { EmojiUnicodeList } from '@/modules/tags/emoji/EmojiUnicodeList.tsx'

export const EmojiSelector = defineComponent({
  name: 'EmojiSelector',
  props: {
    errorHint: { type: String, default: '' },
    emoji: { type: String, default: '' },
    emojiHint: { type: String, default: '' },
  },
  emits: ['update:emoji', 'update:emojiHint'],
  setup(_props, _ctx) {
    const selectedMainTopic = ref<EmojiMainTopic>('表情')
    const selectedEmoji = ref('')
    const selectedEmojiHint = ref('')
    watch([selectedEmoji, selectedEmojiHint], () => {
      _ctx.emit('update:emoji', selectedEmoji.value)
      _ctx.emit('update:emojiHint', selectedEmojiHint.value)
    })
    const clearEmoji = () => {
      selectedEmoji.value = ''
      selectedEmojiHint.value = ''
      _ctx.emit('update:emoji', '')
      _ctx.emit('update:emojiHint', '')
    }
    watch(selectedMainTopic, () => {
      clearEmoji()
    })
    return () => (
      <div
        class={cn(
          'min-h-[var(--input-min-height)] max-w-full flex-grow-1',
          'b-1 rounded-[var(--input-radius)] text-14px',
          // 动态切换边框颜色 hasError
          _props.errorHint ? 'b-[var(--error-color)]' : 'b-[var(--input-border-color)]',
        )}>
        <EmojiMainTopicNav v-model:mainTopic={selectedMainTopic.value} />
        <EmojiUnicodeList
          selectedMainTopic={selectedMainTopic.value}
          v-model:emoji={selectedEmoji.value}
          v-model:emojiHint={selectedEmojiHint.value}
        />
      </div>
    )
  },
})
  • 如果从ref改为 const errors = reactive<{[k in keyof FormDataType]?: string[]}>({})
  • 无法重复赋值,只能覆盖key Object.assign(errors, formValidate(toRaw(formData), rules))
  • 修改所有 errors.value 的地方
  • 添加清空上一次错误的逻辑 Object.assign(errors, {name: undefined, sign: undefined,} )
  • 防止验证错误后,错误样式抬高页面结构,可以给一个最小高度的样式
    • <div class="min-h-18px m-t-.25em text-12px color-[var(--error-color)]"><span>{errors.name?.join(', ')}</span></div>
    • <div class="min-h-18px m-t-.25em text-12px color-[var(--error-color)]"><span>{errors.sign?.join(', ')}</span></div>
    • 也可以使用空格的实体字符占位 <span>$nbsp;</span> 或者 <span>$emsp;</span>
    • 需要注意的是 中文字体空格 和 英文字体空格也可能会造成高度样式变动,微软雅黑 Arail 空格行高不一样
    • 将 <span>$nbsp;</span> 替换成中文全角空格   即可

4. 使用 pinia 管理tags表单数据,在各个拆分组件中通信 ⇧


·未完待续·

参考文章

相关文章

  • 无

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