表單使用 Vue 元件,this.business.editdata 是元件的資料物件。這篇記錄從踩到 iOS Safari 日期限制的坑,到把整個 validateForm 重構的過程。

初始問題:日期驗證邏輯

一開始表單只是單純使用 HTML date 的 max 跟 min 屬性來限制,結果使用者通報在蘋果手機的瀏覽器無法正常運作。iOS Safari 對 min/max 的支援不完整——選擇器本身不會將無效日期標灰,使用者可以選到範圍外的日期,且 Safari 不一定會在送出時執行 constraint validation 擋下來,相當於限制形同虛設。因此補上了 JavaScript 日期驗證:

整理以下驗證需求:

  1. 計算最小允許的開始日期(現在日期加7天)。
  2. 檢查使用者輸入的開始日期是否符合要求。
  3. 如果日期無效,自動將其修正為最早允許的日期。

程式碼如下:

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);

// 注意:toISOString() 輸出的是 UTC 時間。台灣 UTC+8,
// 若在本地時間 16:00 之後執行,UTC 日期會比本地日期早一天,
// 導致「加 7 天」實際只填入第 6 天。
// 改用本地日期字串避開這個問題:
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;
}
});

// 檢查 Email 格式
// 注意:\S+@\S+\.\S+ 是寬鬆驗證,a@b.c 或 @@.x 均可通過。
// production 建議改用更嚴格的 RFC 5322 regex 或後端二次驗證。
if (!this.business.editdata.aMail || !/\S+@\S+\.\S+/.test(this.business.editdata.aMail)) {
alert('申請人Email為必填項目,並且需為有效的Email格式');
isValid = false;
}

// 檢查手機號碼格式
// 注意:^\d{10}$ 只驗證10位數字,不驗證台灣手機開頭(09xx)。
// 若需要嚴格驗證台灣手機,改用 /^09\d{8}$/。
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;
}

優化重點

  1. 模組化驗證邏輯:把必填欄位整理成陣列,統一跑 forEach,新增欄位只要加一行物件。
  2. 日期自動修正:不只回報錯誤,同時把欄位值修正為最近可用日期,減少使用者重填次數。
  3. 全面性檢查:除欄位驗證外,也覆蓋拍攝資訊(mapdata)與工作人員名冊這類複合條件。
  4. 程式碼可讀性:用具描述性的變數名稱與行內註解,方便後續維護。

未來改進方向

  1. 錯誤訊息處理:目前 requiredFields.forEach 每個空白欄位各彈一次 alert,N 個欄位同時未填就連跳 N 次,體驗很差。應改成收集所有錯誤後一次顯示,或在欄位旁顯示 inline 錯誤文字。
  2. 非同步驗證:需要與後端確認的欄位(如公司統編格式)可以補上非同步驗證。
  3. 即時驗證:考慮在使用者輸入時即時檢查,而不是等到送出才回報。
  4. 國際化:若網站需要支援多語言,把錯誤訊息抽成獨立的語系物件,便於切換。

結論

這次改動的核心教訓:別信 HTML 原生屬性在所有環境都能正確執行,iOS Safari 的 constraint validation 行為至今仍不可靠,任何日期限制都需要 JavaScript 兜底。另外,alert 連發只是快速方案,上線前一定要換掉。