上一篇,我完成了修改寵物資料的功能。
使用者不只可以新增與查看寵物,也可以打開寵物基本資料,修改原本儲存的內容。
做到這裡之後,CRUD 還剩下最後一個基本操作:
Delete。
一開始,我覺得刪除功能應該是四個操作裡最簡單的一個。
不需要處理一大堆表單欄位,也不用考慮哪些資料被修改。
只要取得寵物 ID,呼叫 DELETE API,再把畫面上的卡片移除,應該就完成了。
但實際測試時,我卻遇到一個讓我很錯愕的情況:
寵物卡片明明已經消失了,重新整理頁面後,為什麼又跑回來?
那一刻我才發現,畫面上看起來刪除成功,不代表資料真的被刪除了。
處理新增功能時,需要準備表單資料。
處理修改功能時,需要帶入原始內容、整理欄位,再把更新後的資料送出去。
和前面兩個功能相比,刪除看起來單純很多。
在我的想像中,流程大概是:
點擊刪除
↓
取得寵物 ID
↓
呼叫 DELETE API
↓
從畫面移除寵物卡片
↓
完成
因為刪除不需要傳送一整張表單,我原本覺得這應該很快就可以完成。
但真正實作後才知道,即使程式碼看起來不多,仍然有很多事情需要確認。
例如:
刪除不是只有「讓畫面上的東西消失」,而是前端、API、後端和資料庫都必須保持一致。
刪除和新增、修改有一個很大的差別。
新增或修改內容後,通常還可以再調整。
但刪除資料後,使用者通常無法直接把內容恢復回來。
所以在真正送出 DELETE 請求前,畫面需要讓使用者再次確認。
PawPal 的流程是先從首頁的寵物卡片開啟 PetProfileModal,再按下「刪除資料」。這個彈窗會把選到的寵物透過 delete 事件交給 DashboardView,由 Dashboard 開啟共用的 DeleteConfirmModal。
確認視窗的標題是「確認刪除寵物資料?」;它會顯示「刪除後將無法復原」,並帶入寵物名稱。按鈕文字分別是「確定刪除」和「取消」。按下取消、右上角關閉按鈕或視窗外的遮罩,都只會關閉確認視窗,不會送出請求。
流程如下:
點擊刪除資料
↓
顯示確認視窗
↓
使用者取消
→ 關閉視窗,不執行刪除
使用者確認
→ 送出 DELETE 請求
當時的我原本只想趕快把功能做完,沒有立刻意識到確認步驟的重要性。
後來才理解,確認視窗不是多餘的畫面。
它是在避免使用者因為誤觸,就永久失去資料。
和修改功能一樣,刪除資料時,後端也必須知道目標是哪一隻寵物。
在 PawPal 裡,Dashboard 會把目前選取寵物的 id 和登入狀態保存的 Token 交給 store:
const result = await petStore.deletePet(
petToDelete.value.id,
authStore.token,
)
store 接著呼叫 src/api/pet.js 的 deletePet,以 Axios 送出:
DELETE /api/v1/pets/:id
Authorization: Bearer <token>
寵物 ID 放在路徑的 :id,這支 DELETE 請求沒有 Request body。
但只有 ID 還不夠。
後端 route 先經過 authenticateToken,從 Bearer Token 驗證出登入者,並把 JWT 中的使用者 ID 放到 req.userId。資料庫刪除時再同時使用 id 和 user_id:
DELETE FROM pets
WHERE id = $1 AND user_id = $2
因此,前端傳送 ID 只是告訴後端「想操作哪一筆資料」。真正決定能不能刪除,仍然需要後端驗證登入身份與資料擁有權。
第一次測試刪除功能時,我點擊寵物卡片上的刪除按鈕。
確認刪除後,那張寵物卡片從畫面上消失。
看到卡片消失時,我的第一個反應是:
成功了,刪除功能完成了。
因為從畫面上看,一切都符合預期。
原本存在的卡片不見了,也沒有出現明顯的錯誤。
對當時的我來說,這已經很像一個完成的功能。
但我後來重新整理頁面時,卻發現剛剛刪除的寵物又出現了。
當下我真的很錯愕。
我第一個懷疑的是:
剛才是不是根本沒有真的刪除成功?
這個情況讓我第一次很直接地看到,前端畫面和資料庫狀態會出現不一致。
當卡片從畫面消失時,至少代表當下畫面使用的寵物清單已經發生變化。
重新整理 Dashboard 後,onMounted 會再次呼叫 petStore.fetchPets(),透過 GET /api/v1/pets 重新取得登入會員的寵物資料。
如果重新取得資料後,剛才消失的寵物再次出現,就代表畫面上的刪除結果,和後端實際回傳的資料仍然不一致。
當時我看到的情況是:
卡片從畫面消失
↓
畫面看起來刪除成功
↓
重新整理頁面
↓
前端重新取得寵物列表
↓
剛才消失的卡片再次出現
這是我當時的真實測試經驗。現有的 Issue、PR、Commit 與程式碼歷史沒有保留足以重建當時根因的紀錄,所以我不把問題歸到 API 路徑、ID、Token、SQL 或其他單一環節。
另外,Issue #248 曾記錄過一段尚未串接完成的狀態:PetProfileModal 已有刪除按鈕和 delete 事件,但 Dashboard、store 與 Axios 的 DELETE 呼叫尚未接上。
這筆紀錄不能直接證明就是我當時遇到問題的原因,但它能說明,畫面上的刪除互動和真正刪除後端資料,是兩段都必須完成的流程。
這次經驗讓我理解到,前端的變化只代表目前畫面使用的資料狀態改變。
它不一定能證明後端與資料庫也已經完成相同操作。
刪除功能至少需要確認三件事:
前端是否有把正確的寵物 ID 放進路徑並帶上 Bearer Token。
PawPal 成功刪除時回傳 204 No Content,沒有 Response body。API 模組把這個成功結果整理成前端可使用的 success: true 與預設成功訊息。
寵物 ID 格式錯誤時,後端回傳 400;缺少 Token、Token 無效或 JWT 的使用者 ID 無效時回傳 401;找不到這筆寵物,或這筆資料不屬於登入會員時回傳 404;未預期的伺服器錯誤則回傳 500。
資料是否已經從 pets 資料表消失。
重新呼叫 GET API 時,是否還會取得這筆資料。
刪除成功後,寵物清單是否正確更新。
刪除失敗時,卡片是否仍然保留,並讓使用者知道發生錯誤。
只有三個部分都一致,刪除功能才算真正完成。
在實作刪除功能時,前端更新畫面大致有兩種做法。
第一種是先等待 DELETE API 成功,再移除畫面上的資料。
送出 DELETE
↓
等待後端成功回應
↓
從前端清單移除寵物
第二種則是先讓畫面移除,再等待後端結果。
先從畫面移除
↓
送出 DELETE
↓
失敗時恢復資料
第二種做法能讓畫面感覺更快,但也需要處理失敗後如何恢復。
PawPal 採用第一種做法。petStore.deletePet 會先等待 Axios 的結果,只有 result.success 為真時,才用 filter 從 petStore.pets 移除相同 ID 的資料:
const result = await deletePetApi(petId, token)
if (result.success) {
pets.value = pets.value.filter((pet) => pet.id !== petId)
}
它不會在刪除後重新呼叫 GET,也不是先移除卡片的樂觀更新。API 失敗時,store 不會修改寵物清單,Dashboard 則顯示錯誤提示。
無論使用哪一種方式,重點都是:
前端不能只改畫面,卻不確認後端是否真的成功。
後端成功刪除資料後,前端仍然要更新寵物清單。
PawPal 直接把已刪除的寵物從目前清單中移除;若原本選取的正是這隻寵物,store 也會改選清單中的第一隻,或在清單已空時設為 null。
因此刪除功能真正完成時,是:
使用者確認刪除
↓
DELETE API 成功
↓
資料庫移除資料
↓
前端更新寵物清單
↓
卡片從畫面消失
↓
重新整理後資料也不會回來
這段流程也讓我知道,畫面上的結果要和真正資料來源一起檢查,才不會只停在第一眼看起來成功。
刪除資料是不可逆或不容易恢復的操作,因此成功與失敗的提示非常重要。
PawPal 的 Dashboard 會用 isDeletingPet 管理刪除中的狀態。送出請求後,確認視窗的「確定刪除」會顯示「刪除中...」,確認、取消與關閉操作都會停用;Dashboard 和確認視窗本身也會阻止重複觸發。
這層防護是 PR #331 補上的。它要求第一次點擊確認刪除後只送出一次 DELETE,請求結束後才恢復按鈕狀態。
成功時,Dashboard 顯示「寵物資料刪除成功」的 success Toast;失敗時顯示 API 回傳的訊息,或「寵物資料刪除失敗,請稍後再試」的 error Toast。Toast 元件固定在畫面上方,預設顯示 2.5 秒。
這讓我理解到:
除了把資料刪掉,使用者是否清楚知道操作結果,也是刪除功能是否完整的一部分。
PawPal 的寵物資料放在 pets 資料表。除了使用者與寵物之間的 user_id 外,專案的 medical_records、growth_records 和 calendar_events 都以 pet_id 參照寵物,且 schema 設定了 ON DELETE CASCADE。
因此,刪除一隻寵物時,這三類和該寵物相連的資料會由資料庫連帶刪除,不需要後端逐列清理。
行事曆還有另一層處理。controller 會先依寵物 ID 和登入者的 user ID 收集已同步的 google_event_id,再刪除寵物;本地資料庫連帶刪除行事曆資料後,才逐筆嘗試刪除 Google Calendar 上的對應事件。外部同步失敗會在同步流程中處理,並不會把已完成的本地刪除還原。
寵物的照片網址存放在 avatar_url 欄位。現有刪除流程沒有呼叫 Cloudinary 的資源刪除方法,因此文章不把圖片檔已同步刪除寫成既有行為。
這次踩坑之後,我不再只看卡片是否從畫面上消失。
我會繼續確認:
以前測試刪除時,我只做了第一眼看得到的確認:
卡片不見了。
但現在我知道,重新整理頁面也是很重要的測試。
因為重新整理後,前端會再次從後端取得資料。
如果資料又出現,就代表畫面和真正的資料來源之間仍然不一致。
第一次把自己的寵物新增到 PawPal 時,我覺得很有成就感。
修改資料時,我則感覺這個功能變得更完整。
但測試刪除時,感受其實很不一樣。
即使我知道那只是測試資料,按下確認刪除時,還是會多停一下。
因為刪除不像修改。
它不是把內容換成另一個版本,而是讓整筆資料從系統中消失。
這也讓我更能理解,為什麼刪除功能需要確認、提示和更完整的驗證。
技術上是一支 DELETE API,對使用者來說,這是一個需要慎重處理的動作。
如果現在重新製作刪除功能,我會先確認:
以前我會把刪除功能的完成標準設成:
卡片從畫面消失。
現在我會把標準改成:
API、資料庫、前端狀態和重新整理後的結果都一致。
完成刪除功能後,我學到:
以前我以為刪除功能只是呼叫一支 API。
真正踩過坑後才理解,讓資料消失只是表面。
更重要的是確認它真的從資料來源中被移除,而且畫面不會因為狀態不同步而欺騙使用者。
完成新增、顯示、修改與刪除後,我第一次把寵物資料的基本操作完整串起來。
但當功能越來越多,我也開始遇到另一個問題:
使用者登入之後,網站到底要怎麼記得「我已經登入了」?
原本我以為登入成功,會員功能應該就差不多完成了。
但真正開始處理登入狀態後,我才發現事情沒有這麼簡單。
下一篇:
Day 11|登入功能上線!我以為會員功能完成了……