表單使用 Vue 元件,this.business.editdata 是元件的資料物件。這篇記錄從踩到 iOS Safari 日期限制的坑,到把整個 validateForm 重構的過程。
初始問題:日期驗證邏輯 一開始表單只是單純使用 HTML date 的 max 跟 min 屬性來限制,結果使用者通報在蘋果手機的瀏覽器無法正常運作。iOS Safari 對 min/max 的支援不完整——選擇器本身不會將無效日期標灰,使用者可以選到範圍外的日期,且 Safari 不一定會在送出時執行 constraint validation 擋下來,相當於限制形同虛設。因此補上了 JavaScript 日期驗證:
整理以下驗證需求:
計算最小允許的開始日期(現在日期加7天)。
檢查使用者輸入的開始日期是否符合要求。
如果日期無效,自動將其修正為最早允許的日期。
程式碼如下:
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 const today = new Date ();const minStartDate = new Date (today.getTime () + 7 * 24 * 60 * 60 * 1000 );const toLocalDateString = (d ) => { const y = d.getFullYear (); const m = String (d.getMonth () + 1 ).padStart (2 , '0' ); const day = String (d.getDate ()).padStart (2 , '0' ); return `${y} -${m} -${day} ` ; }; if (this .business .editdata .startDate ) { const startDate = new Date (this .business .editdata .startDate ); if (startDate < minStartDate) { alert ('開始日期必須是現在日期加7天或之後' ); this .business .editdata .startDate = toLocalDateString (minStartDate); isValid = false ; } } else { alert ('拍攝開始日期為必填項目' ); this .business .editdata .startDate = toLocalDateString (minStartDate); isValid = false ; } if (this .business .editdata .endDate ) { const endDate = new Date (this .business .editdata .endDate ); const startDate = new Date (this .business .editdata .startDate ); if (endDate < startDate) { alert ('結束日期不得早於開始日期' ); this .business .editdata .endDate = this .business .editdata .startDate ; isValid = false ; } } else { alert ('拍攝結束日期為必填項目' ); this .business .editdata .endDate = this .business .editdata .startDate ; isValid = false ; }
全面優化表單驗證 解決日期問題後,順手把整個 validateForm 重構了一遍:
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 90 91 92 93 94 95 96 97 98 99 100 101 102 validateForm ( ) { let isValid = true ; const today = new Date (); const minStartDate = new Date (today.getTime () + 7 * 24 * 60 * 60 * 1000 ); const toLocalDateString = (d ) => { const y = d.getFullYear (); const m = String (d.getMonth () + 1 ).padStart (2 , '0' ); const day = String (d.getDate ()).padStart (2 , '0' ); return `${y} -${m} -${day} ` ; }; const requiredFields = [ { field : 'company' , message : '公司名稱為必填項目' }, { field : 'GUInumber' , message : '公司統編為必填項目' }, { field : 'representative' , message : '法定代表人為必填項目' }, { field : 'address' , message : '公司地址為必填項目' }, { field : 'applicant' , message : '申請人姓名為必填項目' }, { field : 'contact' , message : '現場聯絡人為必填項目' } ]; requiredFields.forEach (({ field, message } ) => { if (!this .business .editdata [field]) { alert (message); isValid = false ; } }); if (!this .business .editdata .aMail || !/\S+@\S+\.\S+/ .test (this .business .editdata .aMail )) { alert ('申請人Email為必填項目,並且需為有效的Email格式' ); isValid = false ; } const phoneFields = [ { field : 'aPhone' , message : '申請人手機為必填項目,並且需為有效的手機號碼格式' }, { field : 'cPhone' , message : '現場聯絡人手機為必填項目,並且需為有效的手機號碼格式' } ]; phoneFields.forEach (({ field, message } ) => { if (!this .business .editdata [field] || !/^\d{10}$/ .test (this .business .editdata [field])) { alert (message); isValid = false ; } }); if (this .business .editdata .startDate ) { const startDate = new Date (this .business .editdata .startDate ); if (startDate < minStartDate) { alert ('開始日期必須是現在日期加7天或之後' ); this .business .editdata .startDate = toLocalDateString (minStartDate); isValid = false ; } } else { alert ('拍攝開始日期為必填項目' ); this .business .editdata .startDate = toLocalDateString (minStartDate); isValid = false ; } if (this .business .editdata .endDate ) { const endDate = new Date (this .business .editdata .endDate ); const startDate = new Date (this .business .editdata .startDate ); if (endDate < startDate) { alert ('結束日期不得早於開始日期' ); this .business .editdata .endDate = this .business .editdata .startDate ; isValid = false ; } } else { alert ('拍攝結束日期為必填項目' ); this .business .editdata .endDate = this .business .editdata .startDate ; isValid = false ; } if (this .mapdata .length < 1 ) { alert ("請填寫「拍攝資訊」" ); isValid = false ; } const hasStaffMember = this .business .dbStaffMember .some (staff => !staff.remove ); if (this .business .editdata .staff .length < 1 && !hasStaffMember) { alert ("請填寫「拍攝工作人員名冊」" ); isValid = false ; } const hasPlanningFile = this .business .dbFileSet .some (file => file.category === 'planning' && !file.remove ); if (!hasPlanningFile && this .business .editdata .file_planning .length < 1 ) { alert ("請上傳「企劃案(腳本)或拍攝計畫書」" ); isValid = false ; } return isValid; }
優化重點
模組化驗證邏輯 :把必填欄位整理成陣列,統一跑 forEach,新增欄位只要加一行物件。
日期自動修正 :不只回報錯誤,同時把欄位值修正為最近可用日期,減少使用者重填次數。
全面性檢查 :除欄位驗證外,也覆蓋拍攝資訊(mapdata)與工作人員名冊這類複合條件。
程式碼可讀性 :用具描述性的變數名稱與行內註解,方便後續維護。
未來改進方向
錯誤訊息處理 :目前 requiredFields.forEach 每個空白欄位各彈一次 alert,N 個欄位同時未填就連跳 N 次,體驗很差。應改成收集所有錯誤後一次顯示,或在欄位旁顯示 inline 錯誤文字。
非同步驗證 :需要與後端確認的欄位(如公司統編格式)可以補上非同步驗證。
即時驗證 :考慮在使用者輸入時即時檢查,而不是等到送出才回報。
國際化 :若網站需要支援多語言,把錯誤訊息抽成獨立的語系物件,便於切換。
結論 這次改動的核心教訓:別信 HTML 原生屬性在所有環境都能正確執行,iOS Safari 的 constraint validation 行為至今仍不可靠,任何日期限制都需要 JavaScript 兜底。另外,alert 連發只是快速方案,上線前一定要換掉。