iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
Vue.js

需求至上的 Vue 魔法之旅系列 第 19

# Day 14 : 動態幻術 - 跨欄位驗證與自訂規則(VeeValidate + Yup)

  • 分享至 

  • xImage
  •  

前言:開啟更高等的「檢驗系」魔法

昨天我們已把 VeeValidate + Yup 召喚入陣,讓表單具備基本的「護身結界」(必填、長度、型別)。
今天升級你的魔杖:把驗證做成會讀空氣的幻術——能看其他欄位、能看情境、能讀菜單規則,甚至能阻擋危險字。
你將學到如何用 when() / test() / oneOf() / notOneOf() / transform() 把規則寫得靈活又乾淨,讓表單 UX 變得「既貼心、又不放水」。


需求與 User Story

需求條列(今天新增/加強)

除了巧克力的情境,我們今天想要多一點要求

畢竟系統咩~

user都是不講理的!?

  • 巧克力僅能熱飲(延續 Day10)
  • 抹茶拿鐵甜度不可調,只能正常甜(菜單規則延伸)
  • 同名使用者單日最多 3 杯(以今日日期計數)
  • 備註欄位加入黑名單字串過濾(scriptdrop table …)
  • 非營業時間(08:00–22:00)禁止送單

User Story(需求為王)

  • 祕書:系統應依飲品動態限制甜度/冰量,在輸入時就即時提示不合法組合,避免送出後才退件。
  • 管理者:要能限制單日上限擋危險字遵守營業時間,避免事故與濫用。
規則 類型 說明
巧克力僅熱飲 跨欄位 + 菜單規則 drink = 巧克力 → ice = 熱飲
抹茶拿鐵只能正常甜 跨欄位 + 菜單規則 drink = 抹茶拿鐵 → sweetness = 正常甜
單日最多 3 杯 跨資料(狀態) 同名 + 今日 createdAt 計數 < 3
備註黑名單 自訂字串規則 不得包含 scriptdrop table<>
營業時間限制 情境規則 08:00–22:00 才能提交

法術流程圖(從輸入到送出)

https://ithelp.ithome.com.tw/upload/images/20251003/20121052J0arG8qLMA.png


技術要點(今天的魔法指令)

  • yup.object({...}).test(name, msg, (values) => ...)物件層級跨欄位檢查(同時看 drink、sweetness、ice)。
  • yup.string().when('drink', (drink, s) => ...):依其他欄位動態調整規則(菜單說了算,別硬寫 if)。
  • oneOf() / notOneOf():白名單/黑名單限制(允許或禁止某些值)。
  • transform():驗證前先正規化(去空白、大小寫),避免髒資料混入。
  • 表單層 useForm({ validationSchema })useField()errorsisSubmitting:即時提示 + 提交狀態。

後端:集中管理規則(別寫死在前端)

我們一樣遵循原則

不要把規則綁死在前端~

不然以後系統大了之後~會無限上綱 做也做不完

coding不會停~~~

  • 檔:ordermenu.json(SSOT:Single Source of Truth)
  • API:GET /api/ordermenu 提供完整菜單與規則
{
  "drinks": ["紅茶", "綠茶", "巧克力", "抹茶拿鐵"],
  "sweetnessOptions": ["正常甜", "少糖", "去糖"],
  "iceOptions": ["正常冰", "去冰", "熱飲"],
  "rules": {
    "紅茶": { "allowedSweetness": ["正常甜", "去糖"], "allowedIce": ["正常冰", "去冰", "熱飲"] },
    "綠茶": { "allowedSweetness": ["正常甜", "去糖"], "allowedIce": ["正常冰", "去冰"] },
    "巧克力": { "allowedSweetness": ["正常甜", "少糖"], "allowedIce": ["熱飲"] },
    "抹茶拿鐵": { "allowedSweetness": ["正常甜"], "allowedIce": ["正常冰", "去冰", "熱飲"] }
  }
}

前端:OrderForm.vue(強化版 Schema)

現有的 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;後端也要再驗一次,才是完整防線。


寫法對照:硬寫條件 vs. 動態規則(推薦)

舊(硬編碼,規則一改就動程式)

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

為什麼要動態?

  • SSOT:規則只改 ordermenu.json,前端自動套用。
  • 可擴充:新增飲品/規則,不動程式碼。
  • 一致性:UI 收斂與驗證共用同一份規則,避免「看得到但送不過」。
  • 維護省心:規則集中、測試容易。

小抄:今天用到的法術

  • when('drink', (drink, schema) => schema.oneOf(allowed))
  • object().test('name','msg',(values)=>{...})
  • string().transform(v => v?.trim())
  • string().matches(/regex/, 'msg')
  • oneOf() / notOneOf()

收尾:讓驗證變成會讀空氣的魔法

  • 我們把「菜單規則 + 狀態 + 時間」一口氣帶進驗證流程,即時阻擋不合法輸入,使用者更順、系統更穩。
  • Yup + VeeValidate 把規則寫成清楚的結界;配合集中化 ordermenu.json,前後一致、擴充容易。
  • 要害規則(限流、黑名單…)務必前後端雙驗證。前端顧 UX,後端顧安全。

明天我們把這套「幻術結界」往更廣的表單延伸,並教你怎麼把規則拆成模組,讓大型專案也能優雅地維護 ✨

我們可以偷偷改時間來驗證程式對不對

https://ithelp.ithome.com.tw/upload/images/20251003/201210520lCrDCoVTy.png

day14 github


上一篇
Day 13 : 用咒語守護表單:VeeValidate + Yup 的即時驗證魔法
下一篇
Day 15 : 傳送門法陣 Vue Router 的冒險之門
系列文
需求至上的 Vue 魔法之旅20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言