第 1~12 天,我們一步步把「飲料點單」施法到能跑、能管理、能共享。
但真正上線的系統,錯的輸入就像走錯陣法:會讓資料歪掉、流程卡住,甚至害你加班(啊!)。
今天我們召喚兩件強力法器:
 
這類驗證工具的價值:避免工程師每個表單都「重新造輪子」。
規則用 Schema 集中描述、錯誤由元件統一管理,一致、可測、可擴充,你就不會在不同頁面複製貼上同一段 if/else 了。
今天我們只改 OrderForm.vue,API 與其他元件維持不變。
主軸目標:
npm i vee-validate yup
思路:
useForm/useField 綁定欄位與錯誤狀態handleSubmit 成功才 emit,並清空表單<script setup>
import { computed } from 'vue'
import OptionGroup from './OptionGroup.vue'
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'
const emit = defineEmits(['submit'])
const props = defineProps({
  disabled: { type: Boolean, default: false },
  drinks: { type: Array, default: () => [] },
  sweetnessOptions: { type: Array, default: () => [] },
  iceOptions: { type: Array, default: () => [] },
  // 例:
  // menuRules = {
  //   '紅茶':   { allowedSweetness: ['正常甜','去糖'], allowedIce: ['正常冰','去冰'] },
  //   '綠茶':   { allowedSweetness: ['正常甜','去糖'], allowedIce: ['正常冰','去冰'] },
  //   '巧克力': { allowedSweetness: ['正常甜','少糖'], allowedIce: ['熱飲'] } // 只能熱
  // }
  menuRules: { type: Object, default: () => ({}) },
})
/** Yup:表單規則(可重用) */
const schema = computed(() =>
  yup.object({
    name: yup.string().required('姓名必填').min(2, '至少 2 個字').max(20, '最多 20 個字'),
    note: yup.string().max(50, '備註最多 50 個字').optional(),
    drink: yup.string().required('請選擇飲料'),
    sweetness: yup.string().required('請選擇甜度'),
    ice: yup.string().required('請選擇冰量'),
  }).test('menu-rule', '選項與菜單規則不符', (values) => {
    const rule = props.menuRules?.[values.drink]
    if (!rule) return true
    const okSweet = !rule.allowedSweetness?.length || rule.allowedSweetness.includes(values.sweetness)
    const okIce = !rule.allowedIce?.length || rule.allowedIce.includes(values.ice)
    return okSweet && okIce
  })
)
/** VeeValidate:表單/欄位 */
const { handleSubmit, errors, isSubmitting } = useForm({ validationSchema: schema })
const { value: name } = useField('name')
const { value: note } = useField('note')
const { value: drink } = useField('drink')
const { value: sweetness } = useField('sweetness')
const { value: ice } = useField('ice')
/** 依飲料動態產生可選甜度/冰量(顯示層面) */
const optSweetness = computed(() => {
  const rule = props.menuRules?.[drink.value]
  return rule?.allowedSweetness?.length ? rule.allowedSweetness : props.sweetnessOptions
})
const optIce = computed(() => {
  const rule = props.menuRules?.[drink.value]
  return rule?.allowedIce?.length ? rule.allowedIce : props.iceOptions
})
/** 送出 */
const onSubmit = handleSubmit((v) => {
  if (props.disabled) return
  emit('submit', v)      // 對外契約不變:仍然送出乾淨 payload
  name.value = ''
  note.value = ''
  drink.value = ''
  sweetness.value = ''
  ice.value = ''
})
</script>
<template>
  <!-- 姓名 -->
  <div :class="['block', name ? 'complete' : 'invalid']">
    <label>姓名(必填)
      <input type="text" v-model.trim="name" placeholder="請輸入你的名字" />
    </label>
    <p class="hint" v-if="errors.name">{{ errors.name }}</p>
  </div>
  <!-- 備註 -->
  <div class="block">
    <label>備註(選填)
      <textarea v-model.trim="note" placeholder="例如:三點拿、少冰"></textarea>
    </label>
    <p class="hint" v-if="errors.note">{{ errors.note }}</p>
  </div>
  <!-- 飲料 / 甜度 / 冰量 -->
  <OptionGroup label="步驟 1:選擇飲料" :options="props.drinks" v-model="drink" required />
  <p class="hint" v-if="errors.drink">{{ errors.drink }}</p>
  <OptionGroup v-if="drink"
               label="步驟 2:選擇甜度"
               :options="optSweetness"
               v-model="sweetness"
               required />
  <p class="hint" v-if="errors.sweetness">{{ errors.sweetness }}</p>
  <OptionGroup v-if="drink && sweetness"
               label="步驟 3:選擇冰量"
               :options="optIce"
               v-model="ice"
               required />
  <p class="hint" v-if="errors.ice">{{ errors.ice }}</p>
  <button :disabled="props.disabled || isSubmitting"
          class="submit enabled"
          @click="onSubmit">
    送出
  </button>
  <!-- 全表單級錯誤(如跨欄位規則) -->
  <p class="hint" v-if="errors['']">{{ errors[''] }}</p>
</template>
| 欄位 | 規則 | 範例說明 | 
|---|---|---|
| name | required、min(2)、max(20) | 姓名必填,2~20 字 | 
| note | max(50) | 備註最多 50 字 | 
| drink | required | 必選飲料 | 
| sweetness | required | 必選甜度 | 
| ice | required | 必選冰量 | 
| cross rule | test()跨欄位:依menuRules | 例:巧克力只能熱飲 | 
跨欄位驗證的關鍵:
yup.object({ /* fields... */ }).test('menu-rule', '選項與菜單規則不符', (values) => {
  const rule = props.menuRules?.[values.drink]
  if (!rule) return true
  const okSweet = !rule.allowedSweetness?.length || rule.allowedSweetness.includes(values.sweetness)
  const okIce = !rule.allowedIce?.length || rule.allowedIce.includes(values.ice)
  return okSweet && okIce
})
errors / isSubmitting 等通通幫你管
v-if、選單)只負責「引導」,真正的資料保證交給 Yup。test():像「巧克力只能熱飲」這種關聯規則,很適合集中在 schema。OrderForm 專心組裝 UI 與發出 submit,規則/錯誤交由工具處理。今天把「輸入正確性」交給成熟法器,
你只要專注在需求與體驗,法陣就會守住資料品質。
需求至上的 Vue 魔法之旅,就是用對工具、寫對地方,
讓你和使用者都安心:每一筆訂單,都是合乎規則的好咒語。
我們今天算是把一些常用到的技巧跟工具講解完畢了
接下來就要進入到chapter3了~~
我們在一起努力唄~!!!