昨天我們已把 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
,前後一致、擴充容易。明天我們把這套「幻術結界」往更廣的表單延伸,並教你怎麼把規則拆成模組,讓大型專案也能優雅地維護 ✨
我們可以偷偷改時間來驗證程式對不對