工程日記・第五天:FocusJet 小組件的四次重生 & 批量修復 70 張失效畫廊圖片
四次迭代才做對一個最小化計時器,一個程序化 Unsplash 批量修復腳本,以及一個深刻教訓:當你的組件需要出現在每個頁面時,頁面級狀態就是個陷阱。
兩場火,一天撲滅
今天是那種"以爲只是個小修復,結果重構了整個架構"的日子。我們面對兩個問題:100 個目的地頁面中 70 張畫廊圖片失效,以及一個 FocusJet 最小化組件一直把用戶困在錯誤頁面上。兩個都解決了,兩個都讓我們學到了東西。
第一部分:批量復活 70 個失效的 Unsplash URL
我們發現目的地內容中 340 個 Unsplash 畫廊 URL 裏有 70 個返回 404 錯誤。這些是用戶瀏覽巴厘島、聖托里尼、馬爾代夫等目的地時看到的主圖。奢華平臺上出現裂圖,就像五星級酒店牆上缺了畫——瞬間摧毀信任感。
問題根源
Unsplash 會定期下架照片(攝影師撤回、政策違規、賬號刪除)。我們的目的地數據硬編碼了指向已刪除圖片的 photo ID。在一個 3000 行的 TypeScript 文件裏手動替換 70 個 URL 不是選項。
程序化修復方案
我們寫了一個三階段 Node.js 管道:
- 階段一:檢測。從
destinations.ts提取全部 340 個唯一 Unsplash URL,並行發送 HEAD 請求,識別出 70 個非 200 狀態碼的鏈接。 - 階段二:搜索替換。對每個失效 URL,用目的地名稱查詢 Unsplash 搜索 API(
/napi/search/photos),提取第一個高質量結果的 photo ID。第一輪找到 57/70 個替換;用替代搜索詞重試找到剩餘 13 個。 - 階段三:批量替換。最後一個腳本在
destinations.ts中執行字符串替換,把舊 photo ID 換成已驗證的新 ID。
驗證結果:全部 340 個 URL 現在返回 HTTP 200。零手動編輯。整個管道的編寫時間比手動替換 10 張圖片還短。
教訓:當內容依賴外部資源(CDN 圖片、API 端點、第三方嵌入)時,你需要自動化健康檢查。我們正在構建一個每週運行 URL 驗證的 CI 步驟。
第二部分:FocusJet 小組件——四次迭代才做對
FocusJet 是我們的航空主題番茄鍾。用戶選一架飛機、設定航線,然後在 3D 地球可視化中"飛行"一個專注會話。全屏模式完美運行。問題是:用戶最小化後會怎樣?
迭代一:右下角卡片
第一次嘗試:右下角的浮動卡片,類似音樂播放器迷你組件。問題:和 AI 禮賓聊天組件重疊了,兩個組件在同一個 200x200 像素區域搶位置。
迭代二:頂部全寬條
把組件移到視口頂部全寬條。問題:和固定導航欄(position: fixed; top: 0; z-index: 9999)打架了。截圖確認:看起來是壞的。
迭代三:導航欄下方浮動膠囊
定位在 top: 80px(剛好在 80px 導航欄下方)的緊湊膠囊。視覺乾淨,沒有重疊。但關鍵反饋來了:"用戶仍然點不了任何東西,被困在 FocusJet 頁面上。"
這就是我們一直忽略的根本問題。三個迭代共享同一個架構缺陷:組件渲染在 FocusJet 頁面組件內部。用戶"最小化"後仍然在 /tools/focus-jet 路由上——頁面的 <main> 元素仍然佔據視口,路由器仍然指向 FocusJet,用戶哪兒也去不了。
迭代四:全局 Context + 佈局級組件
正確的架構需要三個改變:
- 通過 React Context 實現全局狀態。我們把整個 FocusJet reducer 狀態提取到一個包裹布局的
FocusJetProvider中。計時器、暫停狀態、機型選擇、航線——全部在任何單獨頁面之上。 - 佈局級組件渲染。
FocusJetWidget組件現在和ChatWidget一起住在ModeAwareLayout.tsx裏——不在 FocusJet 頁面內部。會話激活時它出現在應用的每個頁面上。 - 最小化時頁面導航。用戶點擊"最小化"時,我們調用
router.push("/")導航到首頁。FocusJet 頁面在小組件模式下返回null。用戶可以自由瀏覽目的地、探索機隊、閱讀博客——同時專注計時器在導航欄下方的細條裏滴答作響。
localStorage 持久化
CEO 的下一個需求:"即使用戶刷新頁面,計時器也必須繼續。"
我們的方案:每次狀態變化時(以及 beforeunload 時),將 FocusJet 狀態序列化到 localStorage,附帶 _savedAt 時間戳。頁面加載時,provider 檢查持久化狀態。如果找到,計算頁面關閉期間實際過去的秒數:
- 如果計時器在運行(未暫停),加上
(Date.now() - _savedAt) / 1000到elapsedSeconds - 如果已超過總時長,直接過渡到"降落"階段
- 如果計時器已暫停,恢復完全相同的狀態(不加時間)
這意味着用戶可以開始一個 45 分鐘專注會話,合上筆記本電腦,30 分鐘後打開,看到計時器顯示還剩 15 分鐘。會話能跨瀏覽器標籤、頁面刷新和完整瀏覽器重啓。
技術洞察
useReducer 初始狀態陷阱
React 的 useReducer 只在首次渲染時讀取初始狀態參數。我們的 loadPersistedState() 在 provider 每次渲染時都調用,但只有第一次有意義。無害但浪費。正確優化是用惰性初始化形式:useReducer(reducer, undefined, loadPersistedState)。已記錄爲後續任務。
固定元素的 z-index 層級
三個 fixed 定位元素(導航欄、FocusJet 組件、聊天組件),需要清晰的堆疊順序:
z-9999:導航欄(始終最上層)z-9999:聊天組件(同級,不同角落)z-9998:FocusJet 組件條(稍低,導航欄下方全寬)
經過時間計算精度
用 Math.floor() 計算離開秒數意味着我們略微少算(最多 999ms)。這是有意爲之——對於專注計時器,給用戶多一點時間比提前截斷更好。多算可能導致計時器提前降落,用戶會覺得是 bug。
今日發貨
| 變更 | 文件 | 影響 |
|---|---|---|
| 畫廊 URL 修復 | destinations.ts(80 處替換) | 100 個目的地頁面中 70 張裂圖修復 |
| FocusJet 全局組件 | 5 個文件(2 新建,3 修改) | 計時器跨導航和刷新持久化 |
| 聊天組件清理 | ChatWidget.tsx | 移除主動問候氣泡 |
有時候最簡單的功能——"最小化一個計時器"——需要最深層的架構變更。四次迭代不是失敗;每一次都揭示了一個我們不知道存在的約束。這就是與真實用戶一起、在實時反饋中構建產品的本質。
準備好飛行了嗎?幾秒鐘獲取個性化包機報價。
訂閱資訊
空腿航班優惠、新航線與航空洞察,直達您的郵箱。