昨天我們已把 VeeValidate + Yup 召喚入陣,讓表單具備基本的「護身結界」(必填、長度、型別)。
今天升級你的魔杖:把驗證做成會讀空氣的幻術——能看其他欄位、能看情境、能讀菜單規則,甚至能阻擋危險字。
你將學到如何用 when() / test() / oneOf() / notOneOf() / transform() 把規則寫得靈活又乾淨,讓表單 UX 變得「既貼心、又不放水」。
除了巧克力的情境,我們今天想要多一點要求
畢竟系統咩~
user都是不講理的!?
script、drop table …)| 規則 | 類型 | 說明 | 
|---|---|---|
| 巧克力僅熱飲 | 跨欄位 + 菜單規則 | drink = 巧克力 → ice = 熱飲 | 
| 抹茶拿鐵只能正常甜 | 跨欄位 + 菜單規則 | drink = 抹茶拿鐵 → sweetness = 正常甜 | 
| 單日最多 3 杯 | 跨資料(狀態) | 同名 + 今日 createdAt計數< 3 | 
| 備註黑名單 | 自訂字串規則 | 不得包含 script、drop table、<、>… | 
| 營業時間限制 | 情境規則 | 08:00–22:00 才能提交 | 

yup.object({...}).test(name, msg, (values) => ...):物件層級跨欄位檢查(同時看 drink、sweetness、ice)。yup.string().when('drink', (drink, s) => ...):依其他欄位動態調整規則(菜單說了算,別硬寫 if)。oneOf() / notOneOf():白名單/黑名單限制(允許或禁止某些值)。transform():驗證前先正規化(去空白、大小寫),避免髒資料混入。useForm({ validationSchema })、useField()、errors、isSubmitting:即時提示 + 提交狀態。我們一樣遵循原則
不要把規則綁死在前端~
不然以後系統大了之後~會無限上綱 做也做不完
coding不會停~~~
ordermenu.json(SSOT:Single Source of Truth)GET /api/ordermenu 提供完整菜單與規則{
  "drinks": ["紅茶", "綠茶", "巧克力", "抹茶拿鐵"],
  "sweetnessOptions": ["正常甜", "少糖", "去糖"],
  "iceOptions": ["正常冰", "去冰", "熱飲"],
  "rules": {
    "紅茶": { "allowedSweetness": ["正常甜", "去糖"], "allowedIce": ["正常冰", "去冰", "熱飲"] },
    "綠茶": { "allowedSweetness": ["正常甜", "去糖"], "allowedIce": ["正常冰", "去冰"] },
    "巧克力": { "allowedSweetness": ["正常甜", "少糖"], "allowedIce": ["熱飲"] },
    "抹茶拿鐵": { "allowedSweetness": ["正常甜"], "allowedIce": ["正常冰", "去冰", "熱飲"] }
  }
}
現有的
props.menuRules流程保留;下面是更完整、可直接替換的 schema 片段(含黑名單、單日上限、營業時間)。
<script setup>
import { computed } from 'vue'
import OptionGroup from './OptionGroup.vue'
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'
import { useOrderStore } from '../stores/orderStore'
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: { type: Object, default: () => ({}) },
})
const orderStore = useOrderStore()
// 小工具
const blacklist = ['script', 'drop table', '<', '>', 'select *', 'insert ', 'update ', 'delete ']
function isToday(iso){ const d=new Date(iso), n=new Date(); return d.getFullYear()===n.getFullYear()&&d.getMonth()===n.getMonth()&&d.getDate()===n.getDate() }
function countTodayByName(name){ return (orderStore.orders||[]).filter(o=>o.name===name && isToday(o.createdAt)).length }
function withinBusinessHours(){ const h=new Date().getHours(); return h>=8 && h<22 }
// 欄位級:依 drink 動態套用菜單白名單
const schema = computed(()=> {
  const sweetRule = yup
    .string()
    .transform(v=>v?.trim())
    .required('請選擇甜度')
    .when('drink', (drink, s) => {
      const allowed = props.menuRules?.[drink]?.allowedSweetness
      return Array.isArray(allowed) && allowed.length ? s.oneOf(allowed, '甜度不符規則') : s
    })
  const iceRule = yup
    .string()
    .transform(v=>v?.trim())
    .required('請選擇冰量')
    .when('drink', (drink, s) => {
      const allowed = props.menuRules?.[drink]?.allowedIce
      return Array.isArray(allowed) && allowed.length ? s.oneOf(allowed, '冰量不符規則') : s
    })
  return yup
    .object({
      name: yup
        .string()
        .transform(v=>v?.trim())
        .required('姓名必填')
        .min(2, '至少 2 個字')
        .max(20, '最多 20 個字')
        .matches(/^[\u4e00-\u9fa5A-Za-z0-9]+$/u, '僅允許中英文與數字'),
      note: yup
        .string()
        .transform(v=>v?.trim())
        .max(50, '備註最多 50 個字')
        .test('blacklist','備註含禁用詞',(val)=>{
          if(!val) return true
          const l = val.toLowerCase()
          return !blacklist.some(b => l.includes(b))
        })
        .optional(),
      drink: yup.string().required('請選擇飲料'),
      sweetness: sweetRule,
      ice: iceRule,
    })
    // 物件級:再次以菜單規則總檢(與 UI 收斂一致)
    .test('menu-rule-check','選項與菜單規則不符',(v)=>{
      const rule = props.menuRules?.[v.drink]; if(!rule) return true
      const okS = !rule.allowedSweetness?.length || rule.allowedSweetness.includes(v.sweetness)
      const okI = !rule.allowedIce?.length || rule.allowedIce.includes(v.ice)
      return okS && okI
    })
    // 物件級:單日上限
    .test('daily-limit','同一位使用者今日最多 3 杯',(v)=> countTodayByName(v.name) < 3)
    // 物件級:營業時間
    .test('business-hours','非營業時間(08:00–22:00)不可送單',()=> withinBusinessHours())
})
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')
// UI 收斂:依 drink 顯示可選清單(視覺就先幫使用者過濾掉不合法值)
const optSweetness = computed(()=> {
  const r = props.menuRules?.[drink.value]
  return r?.allowedSweetness?.length ? r.allowedSweetness : props.sweetnessOptions
})
const optIce = computed(()=> {
  const r = props.menuRules?.[drink.value]
  return r?.allowedIce?.length ? r.allowedIce : props.iceOptions
})
const onSubmit = handleSubmit((v) => {
  if (props.disabled) return
  emit('submit', v)
})
</script>
生產建議:每日上限、黑名單這類規則,前端驗證是為了 UX;後端也要再驗一次,才是完整防線。
舊(硬編碼,規則一改就動程式)
const sweet = yup.string().required().when('drink', (d, s) =>
  d === '抹茶拿鐵' ? s.oneOf(['正常甜'],'抹茶拿鐵只能正常甜') : s
)
const ice = yup.string().required().when('drink', (d, s) =>
  d === '巧克力' ? s.oneOf(['熱飲'],'巧克力僅能熱飲') : s
)
新(動態讀 menuRules,無需改程式)
const allowed = props.menuRules?.[drink]?.allowedSweetness
return Array.isArray(allowed) && allowed.length ? s.oneOf(allowed, '甜度不符規則') : s
為什麼要動態?
ordermenu.json,前端自動套用。when('drink', (drink, schema) => schema.oneOf(allowed))
object().test('name','msg',(values)=>{...})
string().transform(v => v?.trim())
string().matches(/regex/, 'msg')
oneOf() / notOneOf()
ordermenu.json,前後一致、擴充容易。明天我們把這套「幻術結界」往更廣的表單延伸,並教你怎麼把規則拆成模組,讓大型專案也能優雅地維護 ✨
我們可以偷偷改時間來驗證程式對不對
