Travel 相册与地图功能实现说明
这篇文档不是给“只想加一篇新游记”的人看的操作手册,而是给以后继续维护这套功能的人看的实现说明。
目标只有一个:
- 把当前 Travel 板块为什么这样设计、现在是怎么跑起来的、以后应该沿着哪条线继续维护,讲清楚。
如果你只想新增内容,先看:
如果你遇到高德点位整体侧漂,先看:
这套功能最终解决了什么问题
当前 Travel 板块不是单纯的“图片排版”,而是一条完整链路,解决了下面几件事:
- 把一篇旅行内容稳定落成
docs/Travel/*.mdx页面。 - 把图片引用统一规范成可维护的相对路径,而不是散落的完整 OSS URL。
- 自动从 JPEG 提取 EXIF、拍摄时间、设备、镜头、GPS。
- 对有 GPS 的图片补高德逆地理编码,得到更像“人话”的地点名。
- 在文章页展示“这篇文章的拍摄点地图”。
- 在 Travel 分类首页展示“我的足迹”总览、足迹地图和旅行文章卡片。
- 让以后新增文章时,不需要重新手写一遍地图、卡片和元数据逻辑。
设计原则
这套实现背后有五条固定原则:
1. 文章文件负责表达内容结构
docs/Travel/*.mdx 决定:
- 文章标题
- 段落顺序
- 图片分组
- 每组图片出现的顺序
也就是说,文章文件是“叙事结构”的唯一真源。
2. 元数据和地图数据都走生成产物
前端不应该每次运行时重新去读 EXIF、重新请求逆地理编码,也不应该自己临时推断地图结构。
所以现在有两份生成产物:
src/data/travelPhotoMetadata.generated.jsonsrc/data/travelMap.generated.json
前者解决“单张图片的信息”,后者解决“文章级与地点级地图信息”。
3. 原始 GPS 保留在数据层
照片 EXIF 里的坐标保留原始 GPS,不在生成阶段强行改成高德可直接渲染的坐标。
这样做的原因是:
- 原始数据可追溯
- 逆地理编码和别的地图 SDK 仍然可以复用原始 GPS
- 坐标系转换集中在前端地图渲染层,职责更清晰
4. 地图按页面角色决定加载方式
文章页地图默认折叠,用户点击后再展开;Travel 分类首页的地图是页面核心信息之一,所以以嵌入模式直接渲染。
这里不是互相矛盾,而是页面角色不同:
- 文章页里,地图是正文后的辅助信息,不能抢正文注意力。
- Travel 首页里,地图和“我的足迹”总览是首屏主内容,可以直接加载。
- 两种模式复用同一个
TravelOverviewMap/TravelMap底层能力,只是初始化时机不同。
5. 生成流程优先可维护,不优先“一次性最快”
所以现在不是直接从“用户口头描述”一步到最终 MDX,而是更偏两阶段:
- 先整理成源稿
- 再由脚本生成最终文章
这比一次性直接手写最终 MDX 更稳,因为以后改文案、增删图片、重排分组时,可以复用同一条链路。
总体分层
当前实现可以拆成四层。
| 层级 | 主要职责 | 关键文件 |
|---|---|---|
| 内容层 | 保存最终 Travel 文章 | docs/Travel/*.mdx |
| 生成层 | 生成文章、照片元数据、地图数据 | scripts/create-travel-article.mjs、scripts/generate-travel-photo-metadata.mjs、scripts/generate-travel-map-data.mjs |
| 数据层 | 给前端提供稳定 JSON | src/data/travelPhotoMetadata.generated.json、src/data/travelMap.generated.json |
| 展示层 | 渲染相册、灯箱、文章地图、Travel 首页、总览地图 | src/components/TravelGallery/*、src/components/TravelMap/*、src/components/TravelHome/* |
端到端数据流
完整流程是这样跑的:
- 用户给出地点、OSS 图片链接、少量描述,或者直接给一份 Markdown 原稿。
- AI 先整理出源稿,或者你手动准备好源稿。
scripts/create-travel-article.mjs把源稿转成docs/Travel/<slug>.mdx。scripts/generate-travel-photo-metadata.mjs扫描所有 Travel 文章里的图片引用,下载 JPEG,提取 EXIF 和 GPS,并在需要时调用高德逆地理编码。scripts/generate-travel-map-data.mjs再根据文章里的TravelGallery顺序和照片元数据,生成文章页地图与分类页地图需要的结构。TravelGallery从照片元数据 JSON 里补齐每张图的展示信息。TravelHome从地图 JSON 里读取文章、地点和照片数据,渲染 Travel 分类首页。TravelStoryMap和TravelOverviewMap从地图 JSON 里读点位,再在前端渲染高德地图。
内容层:为什么最终文章仍然是 MDX
最终落地在 docs/Travel/*.mdx,而不是只保留数据库或 JSON,原因有三个:
- Docusaurus 原生就以文档文件为核心。
- 文章正文和图片组的关系,本质上就是文档结构。
- MDX 可以直接复用
TravelGallery和TravelStoryMap组件,不需要额外解释层。
当前一篇 Travel 文章至少包含:
- front matter
TravelGallery引入TravelStoryMap引入- 若干
createTravelPhotos([...]) - 正文与图片块交替出现
Travel 文章的 front matter 还承担分类页排序职责。当前约定是用旅行开始日期写 sidebar_position: -YYYYMMDD,例如 sidebar_position: -20260207。Docusaurus 的 autogenerated sidebar 会按 sidebar_position 升序排列,因此日期越新,负数越小,文章越靠前。这样 Travel 侧栏、上一篇/下一篇导航和 Travel 首页卡片可以保持同一套“最新旅行在前”的顺序。
不要用 1, 2, 3... 表示新旧顺序。那种写法每新增一篇更新的旅行文章都要整体改号,维护成本会越来越高。也不要为了排序重命名 Travel 文件;当前地图数据和首页展示仍把文件名 basename 作为文章 id,重命名会影响卡片封面、卡片描述和生成数据的稳定关联。
文章生成层:为什么单独做 create-travel-article
关键脚本:
scripts/create-travel-article.mjs
它存在的目的不是“偷懒”,而是把格式规范固化下来。
这个脚本现在负责:
- 解析 front matter。
- 容忍 UTF-8 BOM。
- 识别 Markdown 图片行。
- 只接受
oss.nevergpdzy.com下的 JPEG,或者已经归一化好的 JPEG 相对路径。 - 把完整 OSS URL 转成仓库内统一使用的相对路径;如果只写文件名,会按旧规则归到
img_for_Typora/。 - 保留
##章节结构。 - 自动生成
createTravelPhotos([...])。 - 自动插入
<TravelStoryMap />。 - 在需要时继续联动
generate:travel或build。
CLI 参数的当前行为是:
--source/-s指向源稿,必填。--sync在生成 MDX 后运行npm run generate:travel。--verify在生成 MDX 后运行npm run generate:travel和npm run build。--force允许覆盖已存在的目标文件;没有这个参数时,脚本会拒绝覆盖。- 源稿 front matter 的
output只用于指定输出路径,不会写进最终 MDX。
这一层的核心思路是:
- 把“格式规范”写进脚本,而不是写进人脑。
这样以后无论是你自己手工补文章,还是 AI 帮你补文章,最终落到仓库里的结构都会尽量一致。
照片元数据生成层:为什么不在前端读 EXIF
关键脚本:
scripts/generate-travel-photo-metadata.mjs
前端不适合直接处理 EXIF,有几个原因:
- 图片体积太大。
- 浏览器端解析不稳定且浪费性能。
- 逆地理编码需要请求节流和缓存。
- 构建后静态站点更适合消费现成 JSON。
所以现在的做法是:
- 扫描
docs/Travel/*.mdx里出现的 JPEG。 - 统一把引用归一化为 canonical image key。
- 下载图片。
- 从 JPEG 中解析
DateTimeOriginal、Model、LensModel、GPS。 - 如果有 GPS,再调用高德 Web 服务逆地理编码。
- 生成
travelPhotoMetadata.generated.json。
这一层还做了几件维护性很重要的小事:
- 复用已有缓存,不重复请求
- 对高德请求做节流,避免无限打 API
- 对 iPhone 镜头名称做归一化
- 当 GPS 不可用时,允许回退到文章标题继承的地点名
地图数据生成层:为什么还要多一层 travelMap.generated.json
关键脚本:
scripts/generate-travel-map-data.mjs
看上去前端已经有照片元数据了,似乎可以直接拿来画地图,但当前项目没有这么做。原因是前端还缺几类“文章级信息”:
- 这篇文章对应哪个 permalink
- 各个
TravelGallery的顺序是什么 - 哪张图属于哪个章节
- 哪个点适合作为文章概览点
- Travel 分类页应该用哪个点来代表“去过这个地方”
所以这层脚本单独把“适合地图渲染”的结构整理好,再交给前端消费。
这层的几个关键设计决定是:
1. 地图只展示点,不展示路线
当前 Travel 地图不再画路线,只展示:
- 文章页中的拍摄点
- 分类页中的地点代表点
原因是实际效果上,照片 GPS 更适合表达“拍到了哪里”,不适合表达“完整怎么走的路线”。
2. 分类页用代表点,而不是所有点硬铺开
分类页如果把一篇文章的全部点都展开,信息量会太大,也很难点击。
所以脚本会为每篇文章求一个较合适的代表点,再汇总成总览地图。
3. 章节顺序继续从文章里继承
地图数据不是独立定义章节,而是回读文章里的 TravelGallery 与标题顺序。
这能保证地图和正文不会各说各话。
展示层:为什么拆成 TravelGallery 和 TravelMap
TravelHome
关键文件:
src/components/TravelHome/index.jssrc/components/TravelHome/styles.module.css
这一层负责 Travel 分类首页,而不是文章详情页。
它现在做四件事:
- 渲染顶部“我的足迹”总览卡。
- 渲染嵌入式足迹地图。
- 渲染旅行文章卡片列表。
- 在桌面端渲染底部引用句,移动端隐藏这句,避免页尾显得拥挤。
TravelHome 的内容数据来源仍然是 src/data/travelMap.generated.json,不单独维护另一份旅行文章清单。卡片展示顺序则来自 Docusaurus 已经按 sidebar_position 排好的 Travel sidebar items。
具体规则是:
- 地点数、文章数来自
travelMapData.placeCount / articleCount。 - 旅行文章卡片内容来自
travelMapData.articles。 - 旅行文章卡片顺序来自 Travel category 的
sidebarItems,保持和左侧侧栏、上一篇/下一篇导航一致。 - 如果某篇文章暂时没有出现在
sidebarItems里,TravelHome会把它追加在已排序文章之后,避免因为排序数据缺失导致卡片消失。 - 卡片封面优先读取
CARD_COVER_IMAGES中按文章id配置的展示图;未配置时回退到文章里的第一张照片,并按oss.nevergpdzy.com的远程图片规则拼接。 - 卡片描述使用
CARD_DESCRIPTIONS中的展示文案,而不是直接复用 front matter 里的长描述。 - 飞行、火车、自驾里程是当前 Travel 首页的人工展示数据,不从照片元数据推导。
CARD_COVER_IMAGES 只影响 Travel 首页卡片封面,不会修改 docs/Travel/*.mdx 的正文相册、文章描述或 src/data/travelMap.generated.json 的生成数据。
CARD_DESCRIPTIONS 只影响 Travel 首页卡片,不会修改 docs/Travel/*.mdx 的文章描述。
这里有一个容易误解的边界:travelMap.generated.json 的文章列表按文件名读取后生成,真正展示顺序由 TravelHome 再根据 Docusaurus 传入的 sidebarItems 重排。也就是说,新增文章时要先保证 sidebar_position 正确,再通过构建验证首页卡片、侧栏和上一篇/下一篇导航是否一致。
新增 Travel 文章时,必须在 front matter 写入旅行开始日期对应的负数排序值:
sidebar_position: -20260430
同一天多篇文章时,不要追加整数位,例如不要写 -2026043001,因为它会比普通日期小很多,长期压到更前面。需要同日微调时使用小数后缀:
sidebar_position: -20260430.02
sidebar_position: -20260430.01
小数后缀只在同一天附近调整顺序,不会越过相邻日期。
Travel 首页视觉结构
Travel 首页不是 Docusaurus 默认 generated index 的简单换皮,而是一个专门的首页视图。
当前结构是:
- 左侧“我的足迹”总览卡。
- 右侧嵌入式地图卡。
- 下方旅行故事卡片网格。
- 桌面端底部中文引号引用句。
维护时要注意几个边界:
- 左侧总览卡背景固定使用
https://oss.nevergpdzy.com/AI-images/tranquil-valley-path.png。 - 统计区固定为两行:第一行是“探索地点 / 旅行故事”,第二行是“飞行 / 火车 / 自驾”。
- 统计项要左对齐,文字在图标右侧左对齐,图标在自己的浅色方块内垂直居中。
- 右侧地图卡不再显示额外标题栏和“7 个地点”,这样地图显示面积更大。
- 旅行文章卡片不显示日期和阅读量,只显示地点胶囊、封面、标题和短描述。
- 底部引用句桌面端用中文全角引号装饰;手机端隐藏。
这个页面的视觉目标是“旅行总览”,不是营销 landing page,也不是普通文档目录页。因此不要把默认 DocCardList 的列表样式重新塞回来。
TravelGallery
关键文件:
src/components/TravelGallery/index.js
这一层负责:
- 归一化图片引用
- 拼接远程图片地址
- 从
travelPhotoMetadata.generated.json中补齐拍摄时间、设备、镜头、地点 - 渲染网格相册和灯箱
它不负责:
- 决定文章结构
- 请求高德地图
- 解析 EXIF
TravelMap
关键文件:
src/components/TravelMap/shared.jssrc/components/TravelMap/TravelStoryMap.jssrc/components/TravelMap/TravelOverviewMap.jssrc/components/TravelMap/TravelMapDisclosure.js
这里又分成三部分:
shared.js负责高德配置读取、loader 单例、地图创建、控件接入、坐标转换、fit view、销毁与 resize。TravelStoryMap.js负责文章页折叠地图、照片点聚合、marker、点击信息窗。TravelOverviewMap.js负责地点概览图和跳转文章;默认折叠,也支持 Travel 首页使用的embedded嵌入模式。
TravelOverviewMap 的两个模式要区分清楚:
- 默认模式:走
TravelMapDisclosure,点击后才创建 inline map,适合非首屏辅助地图。 embedded模式:组件挂载后直接创建地图,适合 Travel 首页右侧的首屏足迹地图。
两个模式共享同一套地点数据、坐标转换、marker 创建、全屏弹窗和失败 fallback。
为什么 shared.js 要做成公共层
因为地图真正稳定运行,依赖的是一套共用规则:
- 统一读取
key / serviceHost / securityJsCode - 统一设置
window._AMapSecurityConfig - 统一懒加载
@amap/amap-jsapi-loader - 统一根据设备能力决定是否加载
AMap.ToolBar和AMap.Scale - 统一收紧地图渲染参数,例如默认走
2D、关闭旋转 / 俯仰 / 3D 楼块,只保留必要底图要素 - 统一做
gps -> 高德坐标转换 - 统一
fitTravelMapToOverlays - 统一在组件折叠或卸载时
map.destroy()
只要这些逻辑散落到各个地图组件里,后面就一定会出现行为漂移。
高德接入层:为什么现在同时支持 serviceHost 和 securityJsCode
关键文件:
docusaurus.config.jssrc/components/TravelMap/shared.js
这部分的核心矛盾是:
- 前端加载高德 JSAPI 需要
AMAP_JSAPI_KEY - 但
AMAP_SECURITY_JS_CODE不应该在生产环境里长期裸露给客户端
所以现在的策略是:
- 本地或无代理环境下,可以用
securityJsCode兜底。 - 生产环境优先走
AMAP_SERVICE_HOST。 - 一旦配置了
AMAP_SERVICE_HOST,构建时就不再把AMAP_SECURITY_JS_CODE注入前端customFields.amap。
具体逻辑在 docusaurus.config.js:AMAP_SERVICE_HOST 有值时,customFields.amap.securityJsCode 会被写成空字符串;否则才读取 AMAP_SECURITY_JS_CODE 或 fallback 值。前端统一通过 src/utils/amap.js 设置 window._AMapSecurityConfig。
这样做的好处是:
- 本地调试仍然简单
- 正式上线时可以把真正的安全密钥留在服务器代理层