# 05c 壞味道審查(第 4 關) **一句話:** 整體乾淨、分層清楚(plan 純邏輯/store 存取/ui 畫面),沒有嚴重的結構問題;最該處理的是「history 救援版本會被快速洗掉」,以及 store.js 開頁時狀態被蓋掉、合併時把對方當成「刪光了」這兩個疑似錯誤。 審查範圍:index.html、app.css、js/plan.js、js/store.js、js/ui.js、server.py、tools/extract_book.py、tests/*。book.js(產生的資料)與 guide.html(靜態文件)未審。判斷基準是 03-tech-spec.md 與 04-implementation.md 的既定設計(例如:不用框架、ui.js 單檔、三方合併是已記錄的決策 D1),不以個人偏好扣分。 | 條號 | 檔案:行號 | 白話說明 | 建議修法 | 嚴重度 | |---|---|---|---|---| | 疑似錯誤 | server.py:83、ui.js:476、ui.js:479、store.js:117 | history 只留最近 200 版,但存檔非常頻繁:學習中每 30 秒存一次(已練分鐘)、每次點擊存一次、每次切走分頁還會連送兩次(save 的 800ms 計時器+flush 各一次,內容一樣也照樣加一版)。估計練 1~1.5 小時就把 200 版用完。隔天才發現進度壞掉時,已經沒有可救的舊版,違背「資料不能不見」這條長期目標。 | ① history 改成「依時間抽樣」保留(例如最近 50 版+每天最後一版保留 90 天);② flush 前先 clearTimeout,避免切分頁時重複送;③ server 遇到 data 跟上一版完全一樣時不加 history。 | 高 | | 疑似錯誤 | store.js:74-75 | 開頁時如果有上次沒送出的進度(pending),resolvePending 會設定「conflict」或送出失敗時設定「offline」,但緊接著第 75 行一律 setStatus("synced"),把前面的狀態蓋掉。結果:送出失敗時,紅色「連不上 server」橫幅被清掉、面板顯示「✓ 已同步」,其實資料還在排隊重試。 | 第 75 行只在 resolvePending 沒有設定其他狀態時才設 synced(例如讓 resolvePending 回傳最後狀態,或把 setStatus("synced") 移到 pending 不存在的分支)。 | 中 | | 疑似錯誤 | plan.js:168-176、store.js:137-138、store.js:106 | 三方合併把「對方沒有這個欄位」當成「對方刪掉了」。當 server 資料庫被重建(搬到新電腦沒帶 data/)或從備份還原成較舊版本時,409 回來的 data 是 null 或舊資料,合併結果會把這台裝置「上次同步後沒動過」的 learned、marks、review 全部丟掉,再存回 server。 | 在 store.js 遇到 409 且 server 的 version 小於自己記得的 version(或 data 為 null)時,不走合併:改以本機資料為準重送,並先把本機那份下載成檔案備份。 | 中 | | 1 重複程式碼/7 霰彈式修改 | ui.js:10-11、ui.js:76-79、plan.js:11-12、plan.js:89-98 | 「把答案填進空格、再切成字」這套規則寫了兩份:plan.js 用來算 marks 的字索引,ui.js 用來畫可點的字。兩邊的 BLANK、WORD_RE、填空邏輯只要有一邊改了,點到的字和記下來的字就會錯位,而且不會報錯,只會讓耳朵缺口清單悄悄記錯字。 | 讓 Plan 多回傳一個 `sentenceParts(book, q)`(完整句+每個答案的起訖位置),ui.js 的 stemHTML 直接用它切字,刪掉 ui.js 自己的 BLANK、WORD_RE 和填空迴圈。 | 中 | | 疑似錯誤 | store.js:67-92 | 從 http 網址開啟時,只要 API 不是 200、不是 401(例如 server 例外沒回應、通道回 502),就會悄悄進入本機模式。這段時間做的進度只存在瀏覽器,之後 server 恢復也不會合併回去,進度會分家。規格寫的「網路錯誤→本機模式」原意是給 file:// 用的。 | http(s) 來源下,非 200/401 一律當成「暫時連不上」:沿用上次的資料(或顯示「server 暫時連不上,請稍後重新整理」),不要切到本機模式。本機模式只留給 file://。 | 中 | | 7 霰彈式修改 | plan.js:14-24、plan.js:182-185、store.js:39-43 | 進度格式每加一個欄位,要記得同時改 emptyState、merge 的欄位清單、withDefaults。merge 對清單外的欄位一律「取對方的」,忘了加的話,這台裝置對新欄位的修改會在衝突時默默消失。 | 在 merge 裡改成「預設對所有 object 型欄位做 mergeMap、只有列在例外清單的欄位特別處理」,或加一條測試:emptyState 的每個 key 都必須在 merge 的處理清單裡。 | 中 | | 疑似錯誤(不確定) | server.py:140-155 | 靜態檔不支援 Range(只送整個檔案、回 200)。規格說不必要,但 iPhone Safari 播 mp3 常要求 Range 回應,否則可能播不出來;這裡的程式會退回系統朗讀(TTS),聲音變成機器音,使用者不一定察覺。我不確定 iOS 現在是否一定要求,需實測。 | 在 iPhone 實測一次;若播不出來,對 audio/ 加最簡單的 Range(解析 `bytes=a-b`,回 206+Content-Range)。 | 中 | | 疑似錯誤 | ui.js:165-172、ui.js:189-203 | 連續聽播放中,按任何一張卡的 🔊,speak() 會把連續聽的 onended 拿掉,連續聽就停了,但 contRun 還在,畫面一直顯示「正在播第 k / n 句」。另外按「停止」後 0.7 秒內再按「開始」,舊的計時器會接著跑,兩條播放鏈互相搶。 | 每次 startCont 產生一個 run 物件,next() 檢查 `contRun === run`;speak() 被別的地方呼叫時順便 stopCont()。 | 低 | | 疑似錯誤 | server.py:93-95 | log_message 直接取 args[1]。Python 內建的 log_error 有時只傳一個參數(例如「Request timed out: %r」),這時會拋 IndexError;而 send_error 傳進來的 args[1] 是訊息文字,不是狀態碼,錯誤反而不會被印出來。 | 改寫 log_request(它的參數固定是 code、size),log_message 保持預設或只做簡單過濾。 | 低 | | 疑似錯誤 | server.py:127 | Content-Length 不是數字時 int() 拋例外,沒有回應就斷線;前端會當成網路錯誤一直重試。 | 把 int() 包進下面的 try,回 400。 | 低 | | 疑似錯誤 | ui.js:359、ui.js:104 | 耳朵缺口清單的字(gaps 的 key)和題號直接放進 innerHTML。內容通常可信,但匯入的檔案或另一台裝置的資料若含 `<`,就會被當成 HTML 執行。風險低(只有自己的檔案)。 | 這兩處用一個小的 escape() 包起來即可。 | 低 | | 疑似錯誤 | store.js:115、store.js:131-132 | pushNow 送出途中有新變動時,pending 記的是舊的 baseVersion;若此時關頁,下次開頁會被誤判成「另一台裝置有比較新的進度」並跳出衝突訊息(資料本身會正確合併,只是訊息嚇人)。 | resolvePending 判斷衝突前,先比較 server 資料是否等於 pending.base 往後的「自己送過的那份」;或單純把訊息改成中性的「已合併」。 | 低 | | 疑似錯誤 | ui.js:240、plan.js:131-136 | 「連續聽」按鈕上寫「這次學的 / 這次複習的 N 句」,ui.js 自己重算一次 contList 內部的判斷。規則改了,按鈕文字會跟實際播放的清單對不上。 | 讓 Plan.contList 回傳 `{list, kind: "new" | "review"}`,ui 只負責顯示。 | 低 | | 6 依戀情結 | ui.js:426 | 「在這次新學清單裡作答就算學完」這條排程規則寫在 ui.js 的點擊處理裡,而不是 plan.js。 | 改成 `Plan.answer(s, id, k)`,由 Plan 決定要不要 learn。 | 低 | | 2 過長函式 | ui.js:418-457 | 全域點擊處理 40 行、二十多個分支,混用兩種分派方式(看 data-xxx 屬性的 if 串,以及看 data-act 的 switch)。新增按鈕時要先想清楚放哪一種。 | 統一成 data-act,做一張 `actions = { act: fn }` 對照表,點擊處理只剩查表。 | 低 | | 3 過大檔案/8 發散式變化 | ui.js 全檔(500 行) | ui.js 同時管音訊播放、這一次畫面、全部題目畫面、方法說明文案、面板、對話框、開機流程。規格允許單檔,目前還能讀,但改文案、改音訊、改版面都要動同一個檔。 | 先抽出最獨立的兩塊:音訊(speak/tts/連續聽)成 js/audio.js;方法說明那段 HTML 直接寫進 index.html 的隱藏區塊。 | 低 | | 1 重複程式碼 | ui.js:249-251、ui.js:329-333、ui.js:351 | 「複習 n/總數、新學 n/總數、連續聽 ✓」同樣的進度摘要格式寫了三次(區塊徽章、面板、手機底部條)。 | 一個 `progressText(st)` 小函式共用。 | 低 | | 1 重複程式碼 | store.js:115、store.js:132、store.js:141 | `{ baseVersion: version, base, data: state }` 寫進 pending 的動作重複三次;`parse(ls.get(PENDING_KEY))` 只為了確認「有沒有 pending」重複解析整份進度七次。 | 抽 `savePending()`、`hasPending()` 兩個小函式。 | 低 | | 1 重複程式碼 | ui.js:30、ui.js:206、ui.js:433、plan.js:34、plan.js:90-91 | 「題號 "g-i" 拆開再去 B.Q 找題目」和 groupOf 在兩個檔各寫一份。 | Plan 匯出 item(book, id)、groupOf,ui 直接用。 | 低 | | 9 基本型別偏執 | ui.js:264、ui.js:281、index.html:20、ui.js:223 | 「10 組」「100 題」「第 1 冊」寫死在畫面裡;plan.js 卻是從資料算出組別。以後加第 2 冊(book.id 已經預留)就要逐處找。 | 迴圈改用 `Object.keys(B.Q)`,文字從 B.title 與題數計算。 | 低 | | 9 基本型別偏執/10 訊息鏈 | ui.js:433、ui.js:55-64、ui.js:113-115、plan.js:91-92 | 題目資料是位置陣列:q[0] 句子、q[1][0] 正解、q[2] 翻譯、q[3] 詳解、q[4] 考點、q[5] 拆解;最長的一串是 `B.Q[g][i-1][5][k-1][0]`。讀的人要背編號。 | extract_book.py 輸出時順便轉成具名欄位(stem、options、trans、ex、tag、parts),內容不變,只是加名字。 | 低 | | 1 重複程式碼 | store.js:13、ui.js:337、ui.js:486-487 | 同步狀態的文案散在兩個檔,而且不一致:衝突時橫幅說「已重新載入,並把這台剛做的部分合併進去」,面板卻說「已改用 server 上比較新的進度」(聽起來像這台的進度被丟掉)。 | 文案集中到 ui.js 的 TEXT,面板的 conflict 文字改成跟橫幅一致。 | 低 | | 7 霰彈式修改 | ui.js:12-21 對照 ui.js:210-212、298-321、336-337 | 規格要求文案照 02-preview 不可自改,但只有一部分放在 TEXT 常數,其餘散在各個 render 函式裡。日後對照或修改文案要全檔搜尋。 | 把畫面文案都收進 TEXT(方法說明那段可移到 index.html)。 | 低 | | 11 冗贅類別/淺模組 | server.py:54-56、store.js:25 | history_count 只有測試在用,卻放在正式程式裡;setStatus 只是原封轉呼叫 onStatus。 | history_count 移進 server_test.py;setStatus 直接用 onStatus。 | 低 | | 12 注釋當除臭劑 | plan.js:167-168 | pick 的三元運算 `same(m,b) ? t : same(t,b) ? m : m` 第二段兩邊都是 m,等於多餘;旁邊的註解在幫一段繞口的寫法解釋。 | 寫成 `same(m, b) ? t : m`,註解保留一行即可。 | 低 | | 疑似錯誤 | server.py:74-77 | 衝突時先 con.close(),finally 又 close 一次(無害但多餘);read_progress 在鎖釋放後才讀,回傳的版本可能比剛比對的還新(也無害)。 | 刪掉第 76 行的 close。 | 低 | | 規格落差 | plan.js:99、plan.js:109 對照 03-tech-spec.md:54、58 | 規格寫 gaps 是「字 → 次數」,實作是 `{n, q:[題號]}`(為了清單能點回原句,合理);規格說 key 要去掉 's 以外的標點,實作只轉小寫,所以 8-6 的「tenants'」會跟「tenants」算成兩個字。 | 03-tech-spec 補記 gaps 新格式;gapKey 去掉字尾單獨的撇號。 | 低 | | 2 過長函式 | tests/e2e.js:63-227 | 整份 e2e 是一個 160 行的匿名 async 函式,前面步驟失敗會讓後面所有檢查一起失效,看結果時不容易分辨是哪一段壞。 | 依區段(首次造訪、雙裝置、離線、匯出匯入、手機、本機模式)拆成具名函式依序呼叫。 | 低 | 本報告的每一項判斷都來自實際讀程式碼;book.js 的資料邊界(空格旁沒有直接接字母、答案數與空格數一致、資料內沒有 `<>&`)有用 node 實際跑過檢查。標「不確定」的 iPhone Range 一項是推測,需實機驗證。