工程日记・第五天:FocusJet 小组件的四次重生 & 批量修复 70 张失效画廊图片
两场火,一天扑灭
今天是那种"以为只是个小修复,结果重构了整个架构"的日子。我们面对两个问题: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 | 移除主动问候气泡 |
有时候最简单的功能——"最小化一个计时器"——需要最深层的架构变更。四次迭代不是失败;每一次都揭示了一个我们不知道存在的约束。这就是与真实用户一起、在实时反馈中构建产品的本质。
订阅资讯
空腿航班优惠、新航线与航空洞察,直达您的邮箱。