跳到主要内容

Travel 相册与地图功能实现说明

这篇文档不是给“只想加一篇新游记”的人看的操作手册,而是给以后继续维护这套功能的人看的实现说明。

目标只有一个:

  • 把当前 Travel 板块为什么这样设计、现在是怎么跑起来的、以后应该沿着哪条线继续维护,讲清楚。

如果你只想新增内容,先看:

如果你遇到高德点位整体侧漂,先看:

这套功能最终解决了什么问题

当前 Travel 板块不是单纯的“图片排版”,而是一条完整链路,解决了下面几件事:

  1. 把一篇旅行内容稳定落成 docs/Travel/*.mdx 页面。
  2. 把图片引用统一规范成可维护的相对路径,而不是散落的完整 OSS URL。
  3. 自动从 JPEG 提取 EXIF、拍摄时间、设备、镜头、GPS。
  4. 对有 GPS 的图片补高德逆地理编码,得到更像“人话”的地点名。
  5. 在文章页展示“这篇文章的拍摄点地图”。
  6. 在 Travel 分类页展示“我去过的地方总览地图”。
  7. 让以后新增文章时,不需要重新手写一遍地图和元数据逻辑。

设计原则

这套实现背后有五条固定原则:

1. 文章文件负责表达内容结构

docs/Travel/*.mdx 决定:

  • 文章标题
  • 段落顺序
  • 图片分组
  • 每组图片出现的顺序

也就是说,文章文件是“叙事结构”的唯一真源。

2. 元数据和地图数据都走生成产物

前端不应该每次运行时重新去读 EXIF、重新请求逆地理编码,也不应该自己临时推断地图结构。

所以现在有两份生成产物:

  • src/data/travelPhotoMetadata.generated.json
  • src/data/travelMap.generated.json

前者解决“单张图片的信息”,后者解决“文章级与地点级地图信息”。

3. 原始 GPS 保留在数据层

照片 EXIF 里的坐标保留原始 GPS,不在生成阶段强行改成高德可直接渲染的坐标。

这样做的原因是:

  • 原始数据可追溯
  • 逆地理编码和别的地图 SDK 仍然可以复用原始 GPS
  • 坐标系转换集中在前端地图渲染层,职责更清晰

4. 地图默认不抢正文注意力

文章页地图默认折叠,用户点击后再展开;分类页地图同样折叠。

原因很简单:

  • 地图是辅助信息,不是正文主角
  • 高德 JSAPI 和 WebGL 资源不该在首屏无脑加载
  • 折叠加载更利于性能和页面稳定性

5. 生成流程优先可维护,不优先“一次性最快”

所以现在不是直接从“用户口头描述”一步到最终 MDX,而是更偏两阶段:

  1. 先整理成源稿
  2. 再由脚本生成最终文章

这比一次性直接手写最终 MDX 更稳,因为以后改文案、增删图片、重排分组时,可以复用同一条链路。

总体分层

当前实现可以拆成四层。

层级主要职责关键文件
内容层保存最终 Travel 文章docs/Travel/*.mdx
生成层生成文章、照片元数据、地图数据scripts/create-travel-article.mjsscripts/generate-travel-photo-metadata.mjsscripts/generate-travel-map-data.mjs
数据层给前端提供稳定 JSONsrc/data/travelPhotoMetadata.generated.jsonsrc/data/travelMap.generated.json
展示层渲染相册、灯箱、文章地图、总览地图src/components/TravelGallery/*src/components/TravelMap/*

端到端数据流

完整流程是这样跑的:

  1. 用户给出地点、OSS 图片链接、少量描述,或者直接给一份 Markdown 原稿。
  2. AI 先整理出源稿,或者你手动准备好源稿。
  3. scripts/create-travel-article.mjs 把源稿转成 docs/Travel/<slug>.mdx
  4. scripts/generate-travel-photo-metadata.mjs 扫描所有 Travel 文章里的图片引用,下载 JPEG,提取 EXIF 和 GPS,并在需要时调用高德逆地理编码。
  5. scripts/generate-travel-map-data.mjs 再根据文章里的 TravelGallery 顺序和照片元数据,生成文章页地图与分类页地图需要的结构。
  6. TravelGallery 从照片元数据 JSON 里补齐每张图的展示信息。
  7. TravelStoryMapTravelOverviewMap 从地图 JSON 里读点位,再在前端渲染高德地图。

内容层:为什么最终文章仍然是 MDX

最终落地在 docs/Travel/*.mdx,而不是只保留数据库或 JSON,原因有三个:

  1. Docusaurus 原生就以文档文件为核心。
  2. 文章正文和图片组的关系,本质上就是文档结构。
  3. MDX 可以直接复用 TravelGalleryTravelStoryMap 组件,不需要额外解释层。

当前一篇 Travel 文章至少包含:

  • front matter
  • TravelGallery 引入
  • TravelStoryMap 引入
  • 若干 createTravelPhotos([...])
  • 正文与图片块交替出现

文章生成层:为什么单独做 create-travel-article

关键脚本:

  • scripts/create-travel-article.mjs

它存在的目的不是“偷懒”,而是把格式规范固化下来。

这个脚本现在负责:

  1. 解析 front matter。
  2. 容忍 UTF-8 BOM。
  3. 识别 Markdown 图片行。
  4. 只接受 picture.nevergpdzy.cn 下的 JPEG。
  5. 把完整 OSS URL 转成仓库内统一使用的相对路径。
  6. 保留 ## 章节结构。
  7. 自动生成 createTravelPhotos([...])
  8. 自动插入 <TravelStoryMap />
  9. 在需要时继续联动 generate:travelbuild

这一层的核心思路是:

  • 把“格式规范”写进脚本,而不是写进人脑。

这样以后无论是你自己手工补文章,还是 AI 帮你补文章,最终落到仓库里的结构都会尽量一致。

照片元数据生成层:为什么不在前端读 EXIF

关键脚本:

  • scripts/generate-travel-photo-metadata.mjs

前端不适合直接处理 EXIF,有几个原因:

  1. 图片体积太大。
  2. 浏览器端解析不稳定且浪费性能。
  3. 逆地理编码需要请求节流和缓存。
  4. 构建后静态站点更适合消费现成 JSON。

所以现在的做法是:

  1. 扫描 docs/Travel/*.mdx 里出现的 JPEG。
  2. 统一把引用归一化为 canonical image key。
  3. 下载图片。
  4. 从 JPEG 中解析 DateTimeOriginalModelLensModel、GPS。
  5. 如果有 GPS,再调用高德 Web 服务逆地理编码。
  6. 生成 travelPhotoMetadata.generated.json

这一层还做了几件维护性很重要的小事:

  • 复用已有缓存,不重复请求
  • 对高德请求做节流,避免无限打 API
  • 对 iPhone 镜头名称做归一化
  • 当 GPS 不可用时,允许回退到文章标题继承的地点名

地图数据生成层:为什么还要多一层 travelMap.generated.json

关键脚本:

  • scripts/generate-travel-map-data.mjs

看上去前端已经有照片元数据了,似乎可以直接拿来画地图,但当前项目没有这么做。原因是前端还缺几类“文章级信息”:

  • 这篇文章对应哪个 permalink
  • 各个 TravelGallery 的顺序是什么
  • 哪张图属于哪个章节
  • 哪个点适合作为文章概览点
  • Travel 分类页应该用哪个点来代表“去过这个地方”

所以这层脚本单独把“适合地图渲染”的结构整理好,再交给前端消费。

这层的几个关键设计决定是:

1. 地图只展示点,不展示路线

当前 Travel 地图不再画路线,只展示:

  • 文章页中的拍摄点
  • 分类页中的地点代表点

原因是实际效果上,照片 GPS 更适合表达“拍到了哪里”,不适合表达“完整怎么走的路线”。

2. 分类页用代表点,而不是所有点硬铺开

分类页如果把一篇文章的全部点都展开,信息量会太大,也很难点击。

所以脚本会为每篇文章求一个较合适的代表点,再汇总成总览地图。

3. 章节顺序继续从文章里继承

地图数据不是独立定义章节,而是回读文章里的 TravelGallery 与标题顺序。

这能保证地图和正文不会各说各话。

展示层:为什么拆成 TravelGalleryTravelMap

TravelGallery

关键文件:

  • src/components/TravelGallery/index.js

这一层负责:

  • 归一化图片引用
  • 拼接远程图片地址
  • travelPhotoMetadata.generated.json 中补齐拍摄时间、设备、镜头、地点
  • 渲染网格相册和灯箱

它不负责:

  • 决定文章结构
  • 请求高德地图
  • 解析 EXIF

TravelMap

关键文件:

  • src/components/TravelMap/shared.js
  • src/components/TravelMap/TravelStoryMap.js
  • src/components/TravelMap/TravelOverviewMap.js
  • src/components/TravelMap/TravelMapDisclosure.js

这里又分成三部分:

  1. shared.js 负责高德配置读取、loader 单例、地图创建、控件接入、坐标转换、fit view、销毁与 resize。
  2. TravelStoryMap.js 负责文章页折叠地图、照片点聚合、marker、点击信息窗。
  3. TravelOverviewMap.js 负责分类页地点概览图和跳转文章。

为什么 shared.js 要做成公共层

因为地图真正稳定运行,依赖的是一套共用规则:

  • 统一读取 key / serviceHost / securityJsCode
  • 统一设置 window._AMapSecurityConfig
  • 统一懒加载 @amap/amap-jsapi-loader
  • 统一加 AMap.ToolBarAMap.Scale
  • 统一做 gps -> 高德坐标 转换
  • 统一 fitTravelMapToOverlays
  • 统一在组件卸载时 map.destroy()

只要这些逻辑散落到各个地图组件里,后面就一定会出现行为漂移。

高德接入层:为什么现在同时支持 serviceHostsecurityJsCode

关键文件:

  • docusaurus.config.js
  • src/components/TravelMap/shared.js

这部分的核心矛盾是:

  • 前端加载高德 JSAPI 需要 AMAP_JSAPI_KEY
  • AMAP_SECURITY_JS_CODE 不应该在生产环境里长期裸露给客户端

所以现在的策略是:

  1. 本地或无代理环境下,可以用 securityJsCode 兜底。
  2. 生产环境优先走 AMAP_SERVICE_HOST
  3. 一旦配置了 AMAP_SERVICE_HOST,构建时就不再把 AMAP_SECURITY_JS_CODE 注入前端 customFields.amap

这样做的好处是:

  • 本地调试仍然简单
  • 正式上线时可以把真正的安全密钥留在服务器代理层

坐标处理:为什么选择“渲染前转换”,而不是“生成时覆盖”

这件事单独重要到值得再写一遍。

当前数据里保存的是原始 EXIF GPS,这意味着:

  • travelPhotoMetadata.generated.json 里的坐标仍然是原始 GPS
  • travelMap.generated.json 里的点位也继续沿用这组原始 GPS

真正变成高德可直接渲染坐标,是在前端调用:

AMap.convertFrom(..., 'gps')

这个设计是故意的,不是疏忽。原因是:

  1. 原始 GPS 更通用。
  2. 生成脚本不应该偷偷写入“只适合某个地图厂商”的坐标。
  3. 地图厂商切换时,只需要换渲染层,不需要重洗历史数据。

相关排障记录见:

地图交互:为什么默认折叠、为什么只点开时加载

文章页地图和分类页地图都通过 TravelMapDisclosure 折叠。

原因有三条:

  1. 地图不应该压过正文。
  2. 高德 JSAPI 首屏加载成本不低。
  3. 折叠后才初始化地图,可以减少无意义的 WebGL 实例。

当前行为是:

  • 默认不展开
  • 用户点击后才真正创建地图
  • 地图创建后请求 resize,避免折叠动画导致画布尺寸不对
  • 页面销毁时销毁 map 实例

这套实现为什么没有继续保留“路线”

这是一个明确的产品取舍,不是功能没做完。

之前路线的主要问题是:

  • 视觉上像轨迹图,不像相册
  • 多数旅行图组的 GPS 点并不适合线性讲述
  • 有些照片没有 GPS,路线天然不完整
  • 地图越像“导航产品”,正文越容易被抢焦点

所以这次改成:

  • 文章页只标拍摄点
  • 分类页只标去过的地方
  • 点位说明仍然来源于照片元数据和文章结构

这条线更贴合这个站点的 Travel 板块定位。

为什么这套实现适合你现在的工作方式

你现在最常见的输入,不是直接提交一篇完整 MDX,而是:

  1. 一个地点
  2. 一组 OSS 图片链接
  3. 一点点你自己的感觉和风格要求

所以当前系统刻意兼容这种输入方式:

  • 人可以先只提供少量描述
  • AI 先补源稿
  • 再自动生成文章
  • 再自动生成元数据和地图

这比要求你先把每篇旅行都手写成完全规范的 MDX 更符合实际。

以后继续扩展时,优先遵守的规则

1. 不要手改生成 JSON

下面这两份文件都视为生成产物:

  • src/data/travelPhotoMetadata.generated.json
  • src/data/travelMap.generated.json

要改逻辑,就改脚本,不要直接修 JSON。

2. 新增地图组件时先复用 shared.js

不要在新组件里重新写:

  • 高德 loader 初始化
  • 安全配置
  • 坐标转换
  • fit view
  • 销毁逻辑

3. 新增内容优先走“源稿 -> 最终 MDX”链路

不要回退到每次都手工拼 createTravelPhotos([...])

4. 生产环境优先走代理

不要默认把 AMAP_SECURITY_JS_CODE 跟着静态构建一起公开发布。

5. 不要把路线逻辑重新塞回来

除非 Travel 板块的产品目标真的变了,否则不要让地图从“拍摄点概览”重新变回“导航路线图”。

当前最常用的命令

npm run create:travel-article -- --source drafts/travel/<slug>-source.md
npm run sync:travel-article -- --source drafts/travel/<slug>-source.md
npm run generate:travel
npm run verify:travel

这四条命令分别对应:

  1. 生成文章
  2. 生成文章并同步刷新元数据与地图
  3. 只刷新元数据与地图
  4. 刷新并构建验证

以后看这套实现,先从哪几个文件开始

如果以后你自己或别人要继续维护这套功能,建议阅读顺序是:

  1. docs/LabNotes/travel-photo-metadata-workflow.md
  2. scripts/create-travel-article.mjs
  3. scripts/generate-travel-photo-metadata.mjs
  4. scripts/generate-travel-map-data.mjs
  5. src/components/TravelGallery/index.js
  6. src/components/TravelMap/shared.js
  7. src/components/TravelMap/TravelStoryMap.js
  8. src/components/TravelMap/TravelOverviewMap.js

按这个顺序读,最容易先看懂数据是怎么流的,再看懂前端为什么这样渲染。

一句话总结

当前 Travel 功能的核心不是“接了一个高德地图”,而是:

用文章结构做真源,用脚本生成元数据和地图数据,用前端只负责展示,从而把“旅行图片 + 文案 + 地图”变成一条可以重复执行、可以长期维护的内容生产链路。