Day 26 完成多主題管理後,Learning Card App 已經可以同時保存多個 Gemini 產生的主題。
例如:
太陽系
人體器官
海洋探險
森林陸地
使用者可以透過 ThemeSelector 自由切換,而且每個 Theme 都有自己的 LearningCard[]。
但這些資料目前只存在 React State。
只要重新整理網頁:
Generated Themes
↓
React State 重新初始化
↓
AI 產生的學習卡全部消失
所以 Day 27 的目標,就是使用瀏覽器的 LocalStorage 保存 Generated Themes,讓使用者重新整理後仍然可以繼續使用之前產生及編輯過的學習卡。
不過,在加入 LocalStorage 前,我先遇到了一個必須優先處理的 Human Edit 問題。
實際操作時,我發現 Human Edit 偶爾會出現奇怪的結果:
編輯第 1 張卡片
↓
按下 Save
↓
第 1 張沒有更新
↓
第 5 張反而出現剛才的修改
如果直接加入 LocalStorage,錯誤更新的資料也會被永久保存。
所以這次先暫停持久化功能,檢查編輯流程的實際程式碼。
原本進入 Edit Mode 時,只會將目前卡片內容複製到 editForm:
currentCard
↓
editForm
但 Save 時,是透過:
selectedThemeId + currentIndex
決定要更新哪一張卡片。
問題在於,currentIndex 只是目前畫面上的位置,不是卡片的固定身分。
更關鍵的是,App 還有一組全域鍵盤事件:
if (e.key === 'ArrowRight') {
handleNext();
} else if (e.key === 'ArrowLeft') {
handlePrev();
}
即使使用者正在輸入框中編輯文字,按左右方向鍵移動文字游標時,App 仍然會切換卡片。
如果正在編輯第 1 張卡片,又按下 ArrowLeft:
currentIndex = 0
↓
handlePrev()
↓
currentIndex = cards.length - 1
↓
第 5 張
表單中仍然是第 1 張的草稿,但 Save 已經改成更新第 5 張。
這正好解釋了為什麼問題不是每次發生,而是偶爾才出現。
此外,如果進入 Edit Mode 前正在使用自動朗讀,自動播放計時器也可能繼續呼叫 handleNext(),同樣造成索引變動。
這次 Gemini 花費 279 秒,修改兩個檔案:
src/components/LearningCardEditor.tsx
src/LearningCardApp.tsx
並完成 Build。
首先將編輯表單抽成獨立的 LearningCardEditor:
LearningCardEditor
├─ 顯示編輯表單
├─ 管理尚未儲存的 formData
├─ onSave(data)
└─ onCancel()
LearningCardEditor 不直接修改 themes,真正的 Theme State 仍由 LearningCardApp 管理。
進入編輯模式時,也不再只記錄目前的 Index,而是固定保存:
{
themeId: string;
cardId: string;
}
概念上變成:
開始編輯
↓
記錄 themeId + cardId
↓
使用者修改表單
↓
Save
↓
根據 themeId 找到 Theme
↓
根據 cardId 找到 Card
↓
只更新目標卡片
即使 currentIndex 因為其他操作改變,Save 的目標仍然是最初點擊編輯的那張 Card。
同時也加入兩層鍵盤守衛:
正在編輯
→ 不執行全域卡片快捷鍵
input / textarea / select / contentEditable 取得焦點
→ 不執行全域卡片快捷鍵
進入 Edit Mode 時會停止自動播放,自動播放的 Effect 也不會在編輯期間建立新的計時器。
這次一共完成六項測試:
| Test | 測試內容 | 結果 |
|---|---|---|
| A | 編輯第 1 張並在欄位內多次按左右鍵 | ✅ PASS |
| B | 第 1 張編輯時按 ArrowLeft | ✅ PASS |
| C | 自動朗讀中進入編輯並等待超過 7 秒 | ✅ PASS |
| D | 修改內容後按 Cancel | ✅ PASS |
| E | 編輯中切換 Theme 再切回 | ✅ PASS |
| F | 分別編輯第 1 張與第 5 張 | ✅ PASS |
確認 Human Edit 不再更新錯誤卡片後,才正式開始 LocalStorage。
這次要保存的是:
generationParams
這些都屬於非敏感、容量不大的前端資料。
目前 App 也不需要:
所以先使用 LocalStorage,就足以解決重新整理後資料消失的問題。
LocalStorage 的限制也很明確:
對目前的 Learning Card MVP 來說,這是範圍最小、最直接的方案。
第一次 LocalStorage 修改由 Gemini 執行 119 秒,修改:
src/utils/generatedThemesStorage.ts
src/LearningCardApp.tsx
並完成 Build。
這次沒有把所有儲存邏輯直接放進 LearningCardApp.tsx,而是新增:
src/utils/generatedThemesStorage.ts
統一處理:
使用的 Storage Key 是:
learning-card-generated-themes:v1
其中的 v1 是資料格式版本。
如果未來 AppTheme 結構改變,就可以改成新的 Key 或加入資料遷移,不必直接假設舊資料一定相容。
App 中同時存在兩種 Theme:
AppTheme[]
├─ Preset Themes
└─ Generated Themes
Preset Themes 已經寫在程式的 CATEGORIES 中,每次啟動 App 都會載入,因此不需要重複寫入 LocalStorage。
儲存時只保留:
theme.source === 'generated'
這次沒有保存:
所以重新整理後,App 仍會先顯示預設的海洋主題,但先前產生的太陽系與人體器官會重新出現在 ThemeSelector 中,可以再次選取。
為了避免 App 初始化時使用空陣列覆蓋原本的 LocalStorage,這次使用 Lazy State Initialization:
const [themes, setThemes] = useState<AppTheme[]>(() => {
const presetThemes = CATEGORIES.map((category) => ({
...category,
source: 'preset' as const,
}));
const loadedGeneratedThemes = loadGeneratedThemes(
presetThemes.map((theme) => theme.id)
);
return [...presetThemes, ...loadedGeneratedThemes];
});
流程是:
App 初始化
↓
建立 Preset Themes
↓
讀取 LocalStorage
↓
驗證 Generated Themes
↓
排除無效資料與 ID 衝突
↓
合併成 AppTheme[]
不是先建立空的 Generated Themes,再等待 Effect 還原。
這樣可以避免舊資料在初始化過程中被空資料覆蓋。
App 只需要監聽 themes:
useEffect(() => {
saveGeneratedThemes(themes);
}, [themes]);
因此以下操作都不需要各自呼叫 LocalStorage:
Generate 新主題
↓
setThemes()
↓
自動保存
Human Edit Save
↓
setThemes()
↓
自動保存
Regenerate 成功
↓
setThemes()
↓
自動保存
而 Cancel 不會修改 themes,所以尚未儲存的草稿也不會進入 LocalStorage。
Regenerate 失敗時同樣不會用錯誤結果覆蓋原本有效資料。
LocalStorage 中的內容可能因為下列原因變得無效:
cards 不是 Array。generationParams.cardCount 不是有效正整數。所以這次沒有直接寫:
JSON.parse(value) as AppTheme[]
而是逐層檢查資料。
LearningCard 必須包含:
id
emoji
title
content
question
answer
pinyin、parentTip、soundName 若存在,也必須是字串。
generationParams 則檢查:
ageGroup:非空字串
topic:非空字串
cardCount:大於 0 的整數
同時使用 Set 檢查:
發現無效資料時,不讓 App Crash,而是略過資料並在 Console 顯示 Warning。
第一次修改完成後,我又檢查了一次實際程式碼,發現還有幾個需要補強的地方:
window.localStorage 的屬性存取仍有一部分在 try...catch 外。cardCount 沒有限制為正整數。因此再請 Gemini 做一次最小補修。
這次執行 109 秒,修改:
src/utils/generatedThemesStorage.ts
src/LearningCardApp.tsx
並再次完成 Build。
補修後,window.localStorage 本身的取得也位於 try...catch 中。
如果瀏覽器因為權限、隱私模式、iframe 限制或容量問題拒絕存取,App 會:
捕捉錯誤
↓
Console Warning
↓
保留目前 React State
↓
App 繼續運作
這次也再次證明,AI 回報完成並不代表可以直接結束;實際檢查程式碼,仍然有機會找到需要補強的邊界條件。
完成測試後,Storage Key 中實際保存了兩個 Generated Themes:
learning-card-generated-themes:v1
│
├─ gen-太陽系
│ ├─ source: generated
│ ├─ 5 張 Learning Cards
│ └─ generationParams
│ ├─ ageGroup: 7~8 歲兒童
│ ├─ topic: 太陽系
│ └─ cardCount: 5
│
└─ gen-人體器官
├─ source: generated
├─ 3 張 Learning Cards
└─ generationParams
├─ ageGroup: 5~6 歲幼兒
├─ topic: 人體器官
└─ cardCount: 3
簡化後的資料結構如下:
[
{
"id": "gen-太陽系",
"name": "太陽系",
"emoji": "☀️",
"source": "generated",
"cards": [
{
"id": "gemini-card-...-1",
"emoji": "☀️",
"title": "太陽",
"content": "...",
"question": "...",
"answer": "..."
}
],
"generationParams": {
"ageGroup": "7~8 歲兒童",
"topic": "太陽系",
"cardCount": 5
}
},
{
"id": "gen-人體器官",
"name": "人體器官",
"emoji": "❤️",
"source": "generated",
"cards": [
{
"id": "gemini-card-...-1",
"emoji": "❤️",
"title": "我的小心臟",
"content": "...",
"question": "...",
"answer": "心臟"
}
],
"generationParams": {
"ageGroup": "5~6 歲幼兒",
"topic": "人體器官",
"cardCount": 3
}
}
]
實際資料符合預期:
generationParams 正常保存。toddler_stars 使用另一個 Key,沒有混入 Theme 資料。圖:Day27 LocalStorage save.jpg
DevTools 中可以看到learning-card-generated-themes:v1與toddler_stars分開保存,Generated Themes Array 中共有太陽系與人體器官兩筆資料。
為了確認 LocalStorage 損壞時不會讓 App 出現白畫面,我將 Storage Value 手動改成:
invalid-json
圖:Day27 test LocalStorage invaild-json.jpg
將learning-card-generated-themes:v1的 Value 改成無效 JSON。
重新整理後,JSON.parse() 發生 SyntaxError,但錯誤被 loadGeneratedThemes() 捕捉。
Console 顯示:
[Storage] Failed to load generated themes from LocalStorage:
SyntaxError: "invalid-json" is not valid JSON
結果:
圖:Day27 App console warning.jpg
App 回到預設海洋主題並維持正常操作,Console 顯示 LocalStorage 解析失敗警告。
這表示 LocalStorage 即使被手動改壞,也不會讓整個 Learning Card App 無法使用。
這次一共完成八項測試:
| Test | 驗證內容 | 結果 |
|---|---|---|
| L1 | Generate 太陽系後 Refresh | ✅ PASS |
| L2 | 太陽系、人體器官同時保存 | ✅ PASS |
| L3 | Human Edit Save 後 Refresh | ✅ PASS |
| L4 | Regenerate 後 Refresh | ✅ PASS |
| L5 | Cancel 不保存草稿 | ✅ PASS |
| L6 | 無效 JSON 不造成 App Crash | ✅ PASS |
| L7 | LocalStorage 寫入失敗時 App 仍可使用 | ✅ PASS |
| L8 | 實際資料只有 Generated Themes | ✅ PASS |
其中 L3 也再次確認 Human Edit 錯位問題沒有復發:
編輯第 1 張
↓
Save
↓
Refresh
↓
第 1 張保留修改
↓
第 5 張不受影響
L4 則證明 Regenerate 只會更新指定 Theme,並將新結果保存至 LocalStorage,不會覆蓋其他主題。
現在 Learning Card App 的資料流程已經變成:
Gemini Generate
↓
Structured Output
↓
LearningCard[]
↓
新增或更新 Generated Theme
↓
AppTheme[]
↓
React UI
↓
LocalStorage 自動保存
重新整理時則是:
App 啟動
↓
載入 Preset Themes
↓
讀取 LocalStorage
↓
解析與驗證 Generated Themes
↓
排除無效資料及 ID 衝突
↓
合併 AppTheme[]
↓
ThemeSelector
↓
使用者重新選取先前主題
Human Edit 的流程也更完整:
選擇 Card
↓
固定記錄 themeId + cardId
↓
LearningCardEditor 草稿
↓
Save
↓
更新正確 Theme 與 Card
↓
themes State 更新
↓
LocalStorage 自動保存
一開始我以為 Day 27 只需要加入:
localStorage.setItem()
localStorage.getItem()
但實際完成後才發現,持久化不只是把 State 轉成 JSON。
還需要考慮:
這次也再次體會到:
在保存資料之前,要先確保資料更新本身是正確的。
如果 Human Edit 仍然可能把第 1 張內容寫入第 5 張,那麼 LocalStorage 只會讓錯誤保存得更久。
所以 Day 27 最重要的改變,不只是「重新整理後資料還在」,而是把整個資料流程從暫時的 React State,延伸成可以安全保存、還原和驗證的前端狀態管理。
目前 Learning Card App 已經完成:
Prompt 條件
↓
Gemini Structured Output
↓
LearningCard[]
↓
AppTheme[]
↓
ThemeSelector
↓
Human Edit
↓
Regenerate
↓
LocalStorage
接下來可以回頭整理整個專題的完整資料流程,確認從使用者輸入條件、Gemini 回應、前端資料轉換,到畫面顯示與保存之間,是如何串接在一起的。
Day 28|從 Prompt 到畫面:整理智慧學習卡完整資料流程