跳到正文

CHANGELOG

更新历史

内容来自仓库根目录的 CHANGELOG.md,构建时渲染。

默认只展开最新一版,其余收起。

更新历史

版本号语义化:主.次.修订。每次改动在这里加一节。

修复:/admin/logs 里那句「只显示最近 $300 条」多了一个 $

正文里写 ${VISIT_LIMIT} 时,Astro 在这里把 $ 也原样输出了(渲染成 $300)。 页面上看起来就是「$300 条」,很怪。现在直接写字面量 300 并注明它来自 frontmatter (<script> 里本来也只能写字面量)。

顺带记一条网络实况:站主这台机器的 Watt Toolkit 直接占 80/443、不提供代理端口, Windows 系统代理是关着的 —— 所以 git push 直连就行, ~/.gitconfig 里那段指向 127.0.0.1:7890 的 per-URL 代理是残留、已删。

补齐:改名 / 归类 / 删除时,缩略图跟着一起搬、一起删

上一版只做到「上传时带缩略图」,其余三条路都漏了 —— 而站点是按「主图同名 .jpg」去找缩略图的(thumbPathFor),所以:

  • 改名后 → 站点找不到缩略图,退回显示原图(不会破图,但变慢),图库还会标「原图」角标
  • 删除后 → thumbs/ 里留下孤儿(仓库里当时就有两个:167155.jpg、0c912…jpg)

现在四条路都对齐了:上传带缩略图、改名搬缩略图、归类(老图挪回根目录时)搬缩略图、 删除(单张 + 批量)连同队列提交一起清缩略图。都放在同一次提交里, 要么都在、要么都不在;没有缩略图的图不会被凭空补一个空文件。

顺手清掉了仓库里那两个孤儿缩略图,并给 npm run verify 加了一条守卫: assets-src/ 里那两个留档原图必须还在(别顺手删了),也不许溜进 public/(那会 14.8MB 上线)。

测试(check-worker-admin 6.6 节)新增:带缩略图改名后缩略图在新名字下、旧名字下已删、 内容逐字节没变;没有缩略图时改名照样成功且不凭空造;删除时主图与缩略图一起没了。

新增:媒体页面点图片 → 直接全屏看原图

站主说媒体页里点图片要看大图。 (Decap 的卡片预览是 CSS 压到 160px 高的、object-fit:contain —— 图片文件是原图, 但看起来很小,这就是他要「点开看」的原因。)

  • 媒体页点图片 → 全屏灯箱(用项目里已有的 dcx-mask 灯箱,顶部显示文件名), Esc / 点背景 / 「关闭」都能退,另有「在新标签打开原图」。
  • 编辑器「选择图片」弹窗里 双击图片同样全屏 —— 那边单击=选中 (右侧才出现「插入」),绝不能吞掉,否则插不进图。
  • 判据(这一版改对了):看路由。#/media、#/medium/... 算媒体页, #/collections/... 一律放行给 Decap。

⚠️ 上一版判据是错的,所以功能等于没上线:我原本靠「工具栏里有没有『选择』文案」 来判断,线上判不中 → 单击被放过 → 站主在 PC 上点半天没反应。教训: 判据要拿真实页面量过再用;这次也确认了媒体页路由就是 #/media(本地实测), 并把 /medium/ 这条一并认了。

测试(check-decap-extras):媒体页点击 → 全屏且是点的那张、显示文件名、Esc 能关; #/collections/... 下单击不接管(插图流程不受影响)、双击才全屏;#/medium/... 也认; 按钮里的图不被接管、点空白不误开。

更正:若娜瓦那张图的「网页用图」恢复到你自己的原图

上一版我擅自把它按「图库上传的压缩规则」缩到了 1600×899 / 139KB, 站主指出那是把缩略图级的画质当成了原图。已改:

用途 尺寸 大小
文章里默认显示(缩略图) 720×405 28 KB
点开看的原图 4096×2302 0.93 MB
留档(assets-src/) 10228×5748 3.9 MB(JPEG)+ 14.8 MB(逐字节原版 PNG)

没有直接把 10228px / 14.8MB 设成网页用图:那一张会让点开的瞬间下载 15MB。 4096px 在任意屏幕上都够锐,1MB 也能接受。要更大的(6144px / 1.7MB)说一声就换。

修复:手机上点开正文图片是「一片空白」

站主报「点击图片显示原图错误,新页面是空白」。用真浏览器 + 手机视口复现后看清了: 灯箱直接把 src 换成原图,而原图现在有 1MB(慢网 50KB/s 要 19 秒)—— 等它的那几秒屏幕上就是纯黑,看起来就是「空白」。原图越大越像坏了。

现在改成三步(PostLayout.astro 的灯箱):

  1. 先铺缩略图(几十 KB,本地缓存里就有,立刻可见)
  2. 底下显示「正在加载原图…」
  3. 原图加载完淡入盖上去

顺带修掉一个只有缓存命中时才会出现的 bug:只写 pic.onload 是不够的 —— 原图命中缓存时,赋值那一刻就已经加载完了,load 事件不会再触发, 表现成「缩略图一直糊着、提示不收」。现在设完 src 再同步查一次 complete。

新增 tools/check-lightbox.mjs 并进 check:all(现在 11 项): 真浏览器 + 手机视口 + 50KB/s 限速,断言「点开后 0.5 秒内看到缩略图」「提示可见」 「原图没到时不显示它」「原图到了会自动盖上来」「关得掉」。 (这是个纯时序 bug,肉眼看代码看不出来,所以必须有测试盯着。)

2.18.3 · 2026-10-01

修复:后台改了代码,手机上还在跑旧 JS(缓存咬人)

站主报告「刚从手机传了 4 张 14MB 的 PNG,传上去还是 170KB 的 JPG」。 查证:服务端代码早就修好了 —— 线上 gallery.js 已无 shouldCompress, ui.js 已无 toJpegName / canvas.toDataURL。压缩发生在手机浏览器本地的旧 JS 里: /admin/g/ui.js 是无哈希文件名,默认 Cache-Control: max-age=14400, 所以手机最多能把旧代码用 4 小时 —— 在这期间上传,图会被本地压成 JPEG 再发上来, 服务端只是照单收下(它从来没收到过原始 PNG,所以也救不回来)。

根治(两招一起上):

  1. 三个后台页引用脚本都带上 ?v= 版本号(/admin/g|m|p/ui.js?v=2.18.3)—— URL 变了就绕开浏览器缓存
  2. public/_headers 给 /admin/* 加 Cache-Control: public, max-age=0, must-revalidate —— 强制每次回源校验(有 ETag,没变就是 304,代价很小)

npm run verify 新增两条守卫,防止以后有人把这层保护去掉或忘了加版本号: 「后台脚本都带 ?v= 版本号」「/admin/* 强制回源校验」。

⚠️ 教训:改后台脚本后,光看线上文件是新的还不够 —— 用户设备可能还在跑缓存里的旧版。以后改这些文件必须同时把 ?v= 版本号加一。

2.18.2 · 2026-09-30

站主提了 4 件事,全部处理。

1. 图库不再把 PNG 转成 JPG(原图原样保留)

以前上传时有一条「超过 400KB 就转成 JPEG(最长边 1600)」的规则, 后果是上传 PNG 会被悄悄改成 .jpg。站主明确要求:原始图片原样留在画廊里,不做任何压缩。

现在 prepare() 只做一件事:把文件原样读成 dataURL 上传 —— 格式、尺寸、字节都不动(PNG 就是 PNG)。shouldCompress / toJpegName / MAX_EDGE 那套压缩逻辑已删除。

2. 只有「超过 1MB」才生成缩略图

门槛从旧的 400KB 改成 1MB(站主定的规则):

  • ≤ 1MB:不生成缩略图,原图直出
  • 1MB:另外生成一张 720px/q68 的缩略图(只是额外产物,主图永远是原图)

  • 文章里默认显示缩略图,点开加载原图;gif/svg 不生成缩略图(会丢动画/本就更小)

3. 修:全屏图上盖着原位置的缩略图

灯箱是「缩略图占位 + 原图淡入」两层叠放。原图就绪后缩略图层没有退场, 于是它一直压在原图上面 —— 两张图尺寸不同时,露在外面的那一圈糊图特别明显。 现在原图就绪会同时给舞台加 is-ready,把缩略图层透明化并禁掉交互。 check-lightbox 加了两条断言盯住它(原图就绪 → ph 透明度归 0、不再接收点击)。

4. 修:/admin/p/ 的文章列表不跟着仓库更新(前几天的文章不出现)

根因:那份索引(存 KV 的 posts:index)只在第一次(KV 里没有)时建一次,之后再也不更新 —— 所以通过 Decap 后台或 GitHub 直接发的文章永远不出现在 /admin/p/ 里。

改法:抽出公用函数 buildPostIndex(),/admin/reindex 和 /admin/posts-index 共用; 读列表时顺手比一下文章数量(只读一次目录,不读文件内容,便宜),对不上就自动重建。 同一份索引最多每 60 秒查一次(INDEX_CHECK_SEC),避免频繁打 GitHub。 冷启动(KV 里没索引)也会现场建,不再要求用户手动点「重建」。

check-worker-admin 新增 6.93 一节真的跑一遍这条链路:仓库里多一篇 → 列表自动出现; 没变化时不重建(省配额);节流生效;冷启动能建出来。


2.18.1 · 2026-09-29

修复:大图被改名/归类时整张图变成 0 字节(真丢过一张图)

现象:站主在手机图库里把 167155.jpg(14.8MB)改名成「若娜瓦-低眉.jpg」, 结果仓库里那张图成了 0 字节 —— 文章里引用还在、线上却是 404, 图库一直显示「还没构建好」(其实永远等不到)。 当时那张图是 10228×5748、以 PNG 存成 .jpg 的,已从历史里恢复。

根因(这一条要记住): GitHub 的 Contents API 对超过 1MB 的文件不返回 content —— 它给 encoding: "none"、size 是真大小、content 是空串。 ghGetBlob 直接 (data.content || '') 当内容用,于是「读一张大图」拿到空串, 再原样写回 → 0 字节 blob 覆盖了原图。 小图全都正常,所以光看代码完全不觉得有问题。

改法:ghGetBlob 发现「内容是空、但 size > 0」时,改用 sha 走 Git Blobs 接口取真正的 base64(那个接口没有 1MB 限制); 取不到就抛错,绝不把空内容写回去。

再加一道防线:ghCommitMany 里凡是「内容为空」的文件一律拒绝提交 (响亮地失败,而不是把用户的图抹掉)。

测试的坑(同样重要): 原来的假 GitHub 一律返回 content,于是这个真丢图的 bug 在测试里完全隐形。 现在桩照真的来 —— >1MB 的文件只给 sha / size、不给内容, 并且支持 GET /git/blobs/<sha>。新增用例: 大图改名后逐字节比对、拿不到内容时明确报错且原文件不动、源文件不存在时报 404。


2.18.0 · 2026-09-29

HANDOFF「待办三件」里做了 ② 和 ③,① 按站主要求先不做(/admin/m 列表改成直读 GitHub)。

② 文章增删改日志(永久保留)

站主要知道「谁在什么时候动了哪篇文章」。范围明确:只记新增 / 修改 / 删除,不记浏览。

  • 记什么:动作、文章标题、可点击链接(/posts/<slug>/)、IP、时间(+08:00)。 删除的文章已经没有链接了,那条的链接留空,界面就不把标题做成可点的。
  • 存在哪:Cloudflare KV,两个键各管一件事 —— plog:<毫秒时间戳>-<随机> 是永久流水(?all=1 走它,键前缀天然按时间排序), plog:recent 是最近 200 条的缓存(界面打开只读这一个键,不用扫全量)。 ⚠️ 写日志时刻意不传 expirationTtl —— 访问日志那套 90 天 TTL 是给浏览记录的,别混。
  • 落点:/admin/save(新增 / 修改,改之前的老标题也一起记)与 /admin/delete(删除); IP 取 CF-Connecting-IP。日志写入包了一层 try/catch:记日志失败绝不能让文章保存报错。
  • 新接口 GET /admin/post-logs(Bearer LOGS_TOKEN):默认最近 30 天 / 最多 200 条,?all=1 给全部历史。
  • /admin/logs 重做:主表是文章日志(只渲染最新的一屏),访问日志挪到下面那张表; 两边都能导出 CSV。以前一打开就把所有记录全渲染出来,记录一多就卡。

③ 新上传的图自动带缩略图

npm run thumbs(tools/make-thumbs.mjs)是离线跑的,只覆盖当时已经在仓库里的图 —— 新上传的图要等谁手动跑一次才有缩略图,在那之前站点按原图显示。现在补上这一段:

  • 上传前在图库里用同一套参数(720px / q68)再画一张缩略图,跟着主图一起传。 参数抽成 tools 侧同一组常量(THUMB_WIDTH / THUMB_QUALITY),两边不许各写一份。
  • Worker 写进 public/uploads/thumbs/<同名>.jpg,和主图同一次提交 —— 不会出现「图传上了但缩略图没传」的中间态。重名时主图会加时间戳后缀,缩略图跟着一起改。
  • 缩略图是可选的:小图(< 400KB)、gif/svg、比 720 还小的图都不做, 没带或格式不对时只是忽略它,上传照样成功(和以前的行为一致)。
  • /admin/images 多回一个 hasThumb,图库里给还没有缩略图的老图加一个角标, 点开面板会说明「跑一次 npm run thumbs 就补上了」。

测试

  • tools/check-worker-admin.mjs 加两组(6.95 / 6.97): 三条动作各记一条、标题/链接/IP 都对、删除那条没有链接、plog 键没有 TTL、 默认窗口只回最近的 N 条、口令校验;以及缩略图写进 thumbs/、字节一致、 与主图同一次提交、列表里 hasThumb 正确、没带缩略图时也能传、重名时缩略图跟着改名。
  • 假 KV 的 list() 现在真的支持 prefix 了 —— 原来它一律返回空数组, 「全部历史」那条路根本测不到(和真的行为不一致的桩等于没测)。

2.17.0 · 2026-09-20

新增:日记页 /diary/(按年 → 月 → 列表翻看)

站主要一个只收录日记、需要口令、按年月翻看的入口。

  • 入口:yuuu.love/diary,和 /private/ 一样不列入导航(1a); 页脚另有一个不显眼的链接(1c)—— 不想要删掉 Footer.astro 里那一行即可。
  • 口令:复用私人角落那套(Worker 的 /hidden),同一个 sessionStorage 键 (yuuu-private),所以 /private/、/admin/* 任一登过,这边不用再输。
  • 三个视图都在这一页里,位置记在 hash 上(/diary/#2026/08): 年(从 2024 起,倒序)→ 月(固定 1–12 全部显示,带篇数)→ 该月列表(日期+标题,倒序)。 后退键能用、单级可收藏。
  • 不做任何自动生成:列表里只有真实写过的;空月份显示「还没有」。
  • 一天一篇:月份视图里点「✍ 写这一天」→ 出现该月日期格(已写过的标金色); 点已写过的日子 → 直接打开那一篇并提示「那天已经写过一篇」; 点空白日子 → 跳到手机写作页并预填分类=日记 + 该日期。 写作页那边也做了兜底:若同一天已有日记,直接打开已有的那篇。

为什么不做成三个静态页(重要)

dist/ 下的一切都是公开可下载的。 静态页会把日记标题写进公开 HTML, 口令就形同虚设 —— 跟之前那个泄漏是同一类问题。 所以公开的只有空壳 + 口令框,清单在验口令后由 Worker 返回。 为此加了 audit 8.7:拿全部文章的标题去 grep diary/ 与 private/ 的产物 HTML, 一个都不许命中(现在 18 个标题零泄漏)。

其它

  • 新增 tools/check-diary.mjs(真浏览器测口令 → 年 → 月 → 列表 + 一天一篇), 并进 check:all。现在一共 11 项。
  • 手机写作页支持 ?cat=<分类>&date=<日期> 预填(原只有 ?edit=<slug>)。

2.16.0 · 2026-09-19

站主提了 5 件事,做了 4 件(第 2 件「日记 年-月-日 层级」站主觉得工作量太大,先不做)。

1. 明暗随时间自动切换(+ 开关)

  • 主题多了 auto 状态:localStorage['yuuu-theme'] = auto | light | dark,默认 auto。
  • 白天区间默认 7:00–19:00 亮,可在设置面板里改(存 localStorage['yuuu-day'],形如 "7-19"), 并支持跨夜区间(如 "20-6")。
  • 设置面板「明暗」里一个开关 + 两个小时下拉,还显示「现在 21 点 → 暗色」; 顶栏那个按钮仍然能用,一点就变成手动钉死(开关会同步关掉)。
  • 页面一直开着也能到点自动切:每分钟对一次表 + 切回标签页时对一次。
  • 只影响站点前台,后台页面保持原来的浅色。

3. 图库操作队列(攒改动,一次提交)

  • 改名 / 改分类 / 删除先在本地攒着(卡片上出现「改名」「改分类」角标、描边高亮), 底部显示「N 项待提交」+ 全部撤销 + 提交;点提交才一次性推到 GitHub。
  • 新增 Worker 接口 /admin/images/commit:收到一批操作后用一次 Git 提交全部落地 (改名连文章引用一起改,仍然是原子的)。实测 3 项改动 = 1 条提交。
  • 上传递归立刻提交,不进队列 —— 图片必须马上落到仓库,否则关掉页面就丢了。
  • 队列非空时关页面/刷新会提醒;提交前先在本地查重名,不用等服务端拒绝。

4. 手机写作页的「+」按钮终于真的浮在视口上了

  • 根因是 CSS 陷阱,不是没写 position: fixed:.screen 的入场动画用了 animation-fill-mode: both,元素上残留 transform → 它成了后代的包含块, 里面的 fixed 按钮就改成相对整个页面定位 → 被推到最底部。 实测:按钮视口位置 top=4336(页面高 4412、视口只有 844); 把 fill-mode 改成 backwards 之后变成 768(正确贴在视口右下角)。
  • 图库和文章管理页有同样的问题(同一份 CSS 抄过去的),一起修了。
  • 加了自检盯住这个陷阱(.screen 的动画不允许出现 both)。

5. 文章页多了一对跳转按钮

  • 前台文章页左下角(手机上右下角)两个圆按钮:↑ 开头 / ↓ 末尾。
  • 位置取自 article 元素本身,所以是「文章的首尾」而不是整页首尾; 到了就自动置灰(避免点了没反应让人以为坏了)。
  • 平滑滚动交给 CSS 的 scroll-behavior,在 prefers-reduced-motion 下自动变回瞬时跳转。

其它

  • 首屏行内 JS 预算从 10KB 放宽到 12KB(站主拍板:「超了 10KB 没事,体验好就行了」)。 起因是 auto 主题必须在首屏绘制前算完,只能放 is:inline 脚本里。 检查保留,作用是防止以后失控膨胀。顺手把首屏脚本里的中文注释搬到 HTML 注释里 (一个汉字 3 字节,注释搬出来省了 1.2KB)—— 说明没丢,只是不计入 JS 预算。
  • 新增两个真浏览器回归测试(进 check:all): check-theme-jump.mjs(把时钟拨到各个小时验证明暗边界与跨夜区间、实测两个跳转按钮)、 check-decap-extras.mjs。现在 check:all 一共 10 项。

2.15.0 · 2026-09-19

新增

  • 全站页面索引:https://yuuu.love/admin/map/ 页面多了容易忘,这一页把所有后台页、站点页、公开数据文件、Worker 接口、 仓库里的位置、常用命令列全,还带三条硬规矩与「加分类要改 5 处」的提醒。
    • 入口放在 Decap 底部的工具条第一位(/admin/ 页最下面), 另外图库 / 文章管理 / 手机写作页的登录页也各加了一个链接。
    • 它是静态页(public/admin/map/index.html),本身就是个清单,不含任何口令或令牌; 没有口令保护是为了随手能打开,所以别往里放敏感信息(这一点写在文件注释里了)。
    • 「最后核对」那行写了当时的文章数与图片数,方便一眼看出有没有过期。
    • 有一个自检会遍历页内链接,确认都指向真实存在的产物(避免索引自己写错地址)。

2.14.0 · 2026-09-19

新增

  • 图片重命名(/admin/g/ 和 Decap 后台的媒体库都有)。
    • 图库里点图片 →「重命名…」;Decap 媒体库里每张图上有个 ✎ 按钮(见下面的说明)。
    • 只让改主名,后缀强制沿用原后缀(避免把 .jpg 改成 .png 后浏览器按错类型解析)。
    • 默认连同文章引用一起改:图常常已经被文章引用了,只改文件名会让 ![](/uploads/旧名.jpg) 立刻变破图。改名和改引用放在同一次提交里 —— 要么都成功,要么都没发生。界面上可以关掉(不勾「同时更新引用」)。
    • 分类索引跟着改名走(索引是以文件名为键的);目标重名会明确报错,不覆盖。
  • 看原图(大图)。
    • 图库里「看原图(大图)」打开全屏灯箱(原来只是新标签打开)。
    • Decap 媒体库里每张图上有个 🔍 按钮,同样是全屏灯箱 + 「在新标签打开原图」。

说明:Decap 那边的两个按钮是「运行时注入」

Decap 的媒体库没有给插件留扩展点:界面文案里只有上传/删除/下载/复制路径/搜索/选择, 没有重命名;DOM 里几乎没有 data-testid(只有 card-file-icon),类名都是 styled-components 的哈希;换成自定义媒体库要 CMS.registerMediaLibrary + React 组件, 而这个页面没有构建步骤。所以只能在运行时观察 DOM,往媒体卡片上挂两个小按钮 (public/admin/decap-extras.js)。两条自我约束:找不到元素就什么都不做、 只靠 img[src*="/uploads/"] 定位(不依赖任何哈希类名)。

新增 tools/check-decap-extras.mjs 做回归测试 —— 注入代码最容易出闭包问题 (「点第一张的按钮,弹出的却是最后一张」),这种错肉眼几乎看不出来。 这个测试就是用来钉住它的(确实当场抓到了一个 var 闭包 bug)。

内部整理

  • 后台路由只登记一处:原来 fetch 里的路由表和 handleAdmin 里的实现各写一遍, 加「重命名」时我只加了后者 → 线上一直 404(测试才发现)。 现在统一成 ADMIN_POST_PATHS / GALLERY_PATHS 两张表。

2.13.0 · 2026-09-19

修复

  • ★ 图库上传的图,Decap 后台的「媒体」里看不到。 根因是 Decap 的硬限制,不是我们的 bug:它加载媒体用的是 getMedia(folder) { return this.api.listFiles(folder) }, 而 listFiles 默认 depth = 1、且过滤掉路径里含 / 的条目 —— 只列 media_folder 根目录下的文件。 分类做成子目录之后(uploads/celeste/xxx.jpg),Decap 就永远看不到它们。
    • 改法:文件平铺在 public/uploads 根目录(Decap 看得到), 分类改记在 public/uploads/categories.json(图库照样按分类展示)。 两边都能看到全部图片。
    • 附带的两个好处:改分类 = 改一行 JSON(不用搬文件, 也就不会再有「二进制被搬坏」那类事故);前台 Markdown 路径变得扁平好读 (/uploads/0b91ecca….jpg,和 Decap 自己命名的风格一致)。
    • 兼容迁移期:文件还躺在子目录里的老图,列表时按子目录名当分类; 一旦通过图库改了它的分类,就会顺手挪回根目录(这样 Decap 也能看到)。 已有的那张 celeste/xxx.jpg 已经用 git 挪好并登记进索引。
  • 批量删除漏清分类索引:删了文件但 categories.json 里还留着那条记录 (指向空气的分类)。测试盯出来的,已修。

2.12.0 · 2026-09-19

变更(按用户要求)

  • 去掉「正文至少 20 字」的要求。 短句、一张图配一行字、一句话日记都是正当内容。 原来这条规则写在四个地方(tools/verify.mjs、Worker 的 /admin/save、 手机页的 validate()、以及字数提示),现在四处一起去掉。 测试也反过来盯着它:断言「19 字放行 / 一个字放行 / 空正文放行 / 只有图片放行」, 并且检查 verify 与 Worker 里都不再出现「正文长度 < N」的判断 —— 防止哪天被偷偷加回来。 标题仍然必须有(没有标题就生成不了 slug)。
  • 手机写作页新增「回车即换行」(默认开,可在「更多设置」里关)。 Markdown 里单个换行不算换行,会被合并成一段 —— 用户报的「我自己换了行,上线后没有了」就是这个(他写的俳句三行被渲染成一行)。
    • 为什么不做成全局的 breaks(GitHub 那种):站里 13 篇文章含多处软折行 (长句在源码里折成两三行),一开全局它们会在句子中间凭空多出换行。 所以改成写的时候才转换:保存时把单个换行写成 Markdown 硬换行(行尾两个空格), 只影响你保存的这篇,老文章一个字都不动。
    • 围栏代码块里的换行原样保留(那是代码,加空格会改坏它);已经有硬换行的行不重复处理。

修复

  • ★ /admin/p/ 登录循环(输完口令又回到登录页)。 根因:ui.js 是 type="module"(延迟执行),加载完成前登录表单还没有监听器。 用户在模块加载完之前输口令按回车,浏览器会原生提交(GET 带上 ?password=…)→ 页面刷新 → 又看到登录页。手机上网络慢一点就很容易撞上。 修法:在表单后加一小段内联脚本(立即执行)先拦掉默认提交;模块加载好后再接真正的逻辑。 已用无头浏览器验证过:把 ui.js 整个屏蔽掉(模拟模块未加载), 此时提交不会再刷新页面。 另外把「收到 401 就把人踢回登录页」改成留在当前页显示原因 + 一个「重新输口令」按钮 —— 万一服务端偶发拒绝,也不会表现成「刚验证完又回登录页」的循环。

2.11.0 · 2026-09-18

修复

  • ★ 图库「图片丢失」—— 其实是图片被内容损坏了。 用户报「给图片新增分类后图片就没了」。查仓库里的字节:

    字节数 开头
    本地原图 55965 ff d8 ff e0(正常 JPEG)
    仓库里那张 104912 ef bf bd ef(UTF-8 替换字符 U+FFFD)

    原因:/admin/image/move 用 ghGetFile(UTF-8 解码)+ toBase64(重新编码)搬图片。 二进制里的非法字节全变成 U+FFFD(3 字节),体积涨了快一倍,浏览器也渲染不出来 —— 看起来就像「图没了」。 修法:新增 ghGetBlob / ghPutBlob,图片全程走 base64,一步都不解码成文本。

  • 测试的盲区一起补了:假 GitHub 之前把 content 解码成文本再编码回去, 所以二进制损坏根本测不出来。现在桩和真 GitHub 行为一致(content 永远是 base64), 并加了一条真实 PNG(含 0x00/0xFF/0x89)走完整归类的断言、逐字节比对。 反向验证过:把坏实现放回去,测试立刻报 efbfbd… vs 89504e…,与线上现象一致。

  • ★ 泄漏:后台搜索索引被生成到了公开目录。 我一开始把含全文的 posts-index.json 生成到 dist/private/ —— 那是静态托管目录,任何人访问 /private/posts-index.json 都能拿到全部正文、 草稿和隐藏文章。「私人角落」那两份清单靠的是 Worker 的口令门槛,不是文件藏得深。 现在索引只存在 KV 里,由 POST /admin/reindex 从 GitHub 现场构建、读取得持 ticket。 另外悄悄话:/private/posts.json 里那段 2KB 正文(其实是摘要)也去掉了。 审计新增第 8.55 节:公开目录里出现正文类文件即失败。

新增

  • 图库批处理:多选(全选 / 清空)→ 批量改分类 / 批量删除。 Worker 走 Git tree 接口,N 张图 = 1 条 commit(不是 N 条或 2N 条), 用 base_tree 保证没选中的文件不被误删;单批上限 60 张。
  • 文章管理页 /admin/p/:全部文章(含草稿)+ 真搜索 + 分类筛选。 Decap 的 view_filters 只对单个字段做正则、且默认看不到草稿,所以另做一页。 搜索跨标题 / 分类 / 标签 / 摘要 / 正文并打分排序,列表里标出命中位置和正文片段。
  • Decap 也加了 8 个筛选按钮(4 个分类精确匹配 + 其它分类 + 草稿/置顶/隐藏)。 config.yml 里注明:pattern 是当正则用的(new RegExp(pattern).test(值)), 所以 ^安利$ 这种写法有效。
  • 手机写作页支持 /admin/m/?edit=<slug>:从文章管理页点一篇直接进编辑。 四个后台页面现在互相都有入口。

2.10.0 · 2026-09-18

新增

  • 新分类「安利」(看完电影 / 番剧 / 书之后想推荐或吐槽的)。 改动落在四处:Decap 后台的下拉、手机写作页的分类胶囊、归档页的分类色、 以及「没指定封面时按分类自动挑」的配色(安利用玫红 340 + opera 样式)。
    • 手机写作页现在能直接选分类(之前只能保留原分类,新建时固定随笔)。
    • check-mobile 新增一条对账:后台下拉和手机页的分类集合必须一致 —— 漂移的后果很隐蔽:手机上选了「安利」,电脑用 Decap 打开时分类显示空,一保存就被抹掉。
  • 图库 /admin/g/:图片分类管理(手机和电脑都能用)。
    • 分类 = public/uploads 下的子目录名。这样 Decap 自带的媒体库也能按文件夹浏览, Markdown 里的路径也自带分类(/uploads/表情包/xxx.jpg); 已经在根目录的老图显示成「未分类」,不用迁移。
    • 网格浏览 + 分类筛选(带数量)+ 搜文件名/分类;点开一张图可以 复制路径(写文章时直接粘)、换分类、看原图、删除。
    • 上传:相册多选 → 先选分类 → 逐张上传。上传前自动压缩: 最长边 1600、质量 0.85 转 JPEG(手机上随手拍的照片动辄 5MB,直接进 git 仓库又慢又臃肿); gif 和 svg 不压(压了动画就没了)。
    • Worker 新增 /admin/images、/admin/image/upload、/admin/image/move、/admin/image/delete, 一样是 token 只在服务端,浏览器只拿 ticket。

修复

  • 正文的换行不再被吞。手机写作页保存时,正文只做一件事:CRLF 统一成 LF; 其它一律原样保留 —— 结尾的空行、行尾两个空格(Markdown 硬换行)、列表缩进都不动。 check:worker 新增 6 种换行场景的断言,并且逐字符比对提交内容与落盘内容。
  • 图片列表永远是空的:IMAGE_EXT 我写成了 /^(jpe?g|png|…)$/(要求整串等于扩展名), 于是列图片时传完整路径全被判非法,而上传时传的是裸后缀所以碰巧正常 —— 表现成「能上传但列表没图」,很难往正则上想。改成匹配结尾。

2.9.0 · 2026-09-18

新增

  • 手机写作页 /admin/m/ —— 专为手机做的极简写作界面,取代拿 Decap 后台硬撑手机的做法。 为什么另起一页:Decap 的界面按桌面宽度写死(min-width:800px + flexDirection:column 的 SplitPane + 百分比高度),手机浏览器只能把整个界面缩到 49% 塞进屏幕 —— 字极小、说明文字被 压成竖条。我先后试了两版 CSS 覆盖,都把「能用的缩放版」改成了「真的坏」,见 2.8.2。 结论是:别人的桌面 UI 不要硬改成手机 UI,另做一个。
    • 界面:登录(和私人角落同一个口令)→ 文章列表(搜索 / 已发布 / 草稿 / 隐藏 四档筛选) → 写作(标题 + 正文 + 标签 + 可折叠的更多设置)→ 保存。
    • 移动端该有的都有:字号 >= 16px(否则 iOS 聚焦会放大整页)、dvh 高度、 scroll-padding-bottom(键盘弹起时输入框滚进可视区而不是整页被推走)、 安全区 env(safe-area-inset-*)、触控目标 >= 44px、 prefers-reduced-motion 时关掉全部动效。
    • 保存按钮只在有未保存改动时才浮出,平时界面保持安静;字数实时显示,不足 20 字会变金色提醒。
  • Worker 新增四个接口(/admin/posts /admin/file /admin/save /admin/delete):
    • GitHub token 只在 Worker 里,浏览器只拿 ticket —— 就是口令通过后发的随机串 (KV 里 ticket:<uuid>,6 小时过期)。前端文件里出现 token 会被 verify 拦下。
    • 保存时服务器自己写 updated;改已有文章只替换需要变的 frontmatter 行, 注释和字段顺序原样保留(不是整份重写);新建才拼一份完整 frontmatter。
    • 路径白名单:只允许 src/content/posts/ 下的 .md,且不许出现 ..。
    • 正文长度校验与 tools/verify.mjs 同一条规则(trim().length >= 20,标点也算)—— 一开始我多写了「去掉标点再数」,会导致手机说能存、构建时被 verify 拦。
  • 构建时多生成一份 dist/private/posts-all.json(含草稿的全部文章清单),供手机写作页列清单; 正文仍然只放元信息,点开哪篇现取哪篇。

验收(新增两个检查,都接进了 check:all)

  • npm run check:worker(tools/check-worker-admin.mjs):拦截 GitHub API, 直接调用 Worker 的处理函数,检查它实际提交了什么 —— frontmatter 是否保留注释与未改字段、 发布日期是否被误改、标题里的冒号井号是否被正确引号包住、新建路径是否合理、 路径穿越/空标题/太短正文是否被拦、没配 token 时提示是否点名了缺哪个 secret。 这个过程逮到两个真 bug:新建文章被文件名校验误拦(rel 为空时正则必然不匹配)、 以及上面那条长度规则不一致。
  • npm run check:mobile(tools/check-mobile-app.mjs):写作页的纯逻辑(校验、标签解析、 列表文案、相对时间)加上页面结构自查 —— 其中一条很实用: ui.js 里取的所有 id 必须在 index.html 里存在(对不上就是「点了没反应」)。
  • 另外用无头 Edge 把整页跑通了:登录 → 列表 4 篇 → 搜索/筛选 → 打开编辑 → 短正文被拦 → 保存成功 → 新建保存,并逐个界面截图看过排版(无横向溢出、字号达标、按钮不重叠)。

2.8.2 · 2026-09-18

修复

  • 手机后台进文章只剩一片白板(PC 正常)—— 是我上一版加的手机 CSS 干的。 复盘:我在 @media (max-width:759px) 里加了 [class*="SplitPane"] { display: block } 和 [class*="Pane"] { height: calc(100dvh - 66px) }。 但 Decap 的编辑器是 SplitPane:flexDirection: column + 绝对定位 + 百分比高度, 高度靠 100% 一层层算下来 —— 把栏位高度钉死、又把 flex 改成 block,分栏直接塌掉。 同一版还改了 overflow,把滚动链截断,键盘行为反而更糟。
    • 现在布局和高度一概不碰,只保留「拆掉硬编码的 min-width:800px」这种 约束性质的覆盖,以及颜色(html 底色)和 scroll-padding/scroll-margin (只影响聚焦时滚到哪里,不参与布局)。
    • 教训写进文件注释和 HANDOFF 9.9:别人的组件库,别替它算布局。
  • 预览里的封面是一块空白。原因不止一个,都补上了:
    • 加载失败会退回渐变 + 标题首字(原来图挂掉就只剩空白,还没提示);
    • 没选封面时,渐变上会写明「没选封面 · 按封面色相画渐变(hue 202)」, 不留一块说不清的白;
    • 色相为空 / 非法时退回站点默认的 202,不会拼出无效的 CSS。

2.8.1 · 2026-09-18

修复

  • 「最近修改」和「最新发布」又变成同一个顺序了(用户报「三种切换没区别」复发)。 根因是清单生成脚本的兜底:三篇隐藏文章 frontmatter 里都没有 updated, 于是「最近修改」退回用 git log -1 --format=%cs,而 %cs 只精确到「天」 —— 同一天被碰过的几篇日期全一样,排出来自然和按发布日期排一模一样。
    • 改成取 %cI(精确到秒的时刻),同一天的不同提交也能分开。
    • 再加一个不依赖 git 的次要依据 touches(改过该文件的提交数): 同一批提交碰过的文件时刻也会完全相同,这时候靠它分先后。 为什么不用「文件修改时间」:构建环境的 clone 会把所有文件的 mtime 刷成同一时刻,同样没用。
  • 上一轮我是用「日期互不相同」的数据验的,所以没发现这个洞 —— 这次把验收写进 npm run audit:第 8.5 节拿真实清单跑三种排序,两两相同就判失败, 顺带检查清单里每条都带 touches、以及排序只重排不丢条目不重复。
  • 排序规则抽到 src/lib/sort-posts.ts,让页面和审计共用同一份实现 —— 之前逻辑写在 .astro 的 is:inline 脚本里,检查脚本只能照抄, 抄完就开始各自漂移。(页面里仍有一份等价实现,因为 is:inline 不能 import; 文件里注明了改规则要两边一起改,审计会兜住不一致。)

2.8.0 · 2026-09-18

新增

  • 后台预览长得像线上文章页了(public/admin/preview.js + preview.css)。 原来点「打开/关闭预览」看到的是 Decap 默认的裸排版:只有正文,没有标题层级、 没有日期/分类/标签那一行、没有封面 —— 和线上差太远,等于没法定版式。 现在按 PostLayout.astro 的结构重画:衬线标题、日期/「改于」/分类/字数/阅读时长、 标签、封面(封面池 id 会解析成真实图片,没选封面就用封面色相画渐变)、正文排版 (标题、引用、代码块、列表、表格、行内代码)。草稿也能预览。 用 iframe 隔离(decoupled: true),这些样式碰不到后台自己的界面。
    • 样式没去引构建产物:dist 里的 CSS 文件名带哈希(BaseLayout.DpRaR-Rf.css), 每次构建都变,后台里写死必然某天 404。令牌是手抄的,改主题时要一起对 (文件头有说明)。
    • 它不是线上页面的镜像。刚保存的文章要等 Cloudflare 构建(约 1 分钟), 想看真实的就点编辑器右上角「查看发布」。这句话也写在预览面板顶部。
    • 兜底 Markdown 渲染器是手写的(不引任何第三方库):段落、1~4 级标题、引用、 代码块、列表、分隔线、粗斜体、行内代码、链接。验收标准是「一字不丢」 —— npm run check:preview 拿最长的一篇真文章比对纯文本, 抽查 13 个 8 字片段必须全命中,另测空正文 / 未闭合代码块 / 5000 字无换行等边界。

修复

  • 手机后台:整页被撑到 800px 宽、右侧一片空白、底部还有白条。 用无头 Edge + CDP 量出来的实据(390×844):visualViewport 是 390, 但 innerWidth 被撑到 800,documentElement.scrollWidth 也是 800。 原因:Decap 桌面优先,AppMainContainer / ToolbarContainer / EditorContainer 三处都硬编码了 min-width:800px;而且编辑器在宽屏是「写作 / 预览」左右分栏, 手机上两栏各 400px 根本塞不下。现在窄屏(<760px): 拆掉最小宽度、只留写作栏(预览用工具栏按钮随时切)、宽度锁 100%。
    • 底部的白条:Decap 把底色画在 body 上,html 是透明的, 露出来的是浏览器画布自己的颜色(通常白)。地址栏收放、键盘弹起都会露出来。 现在 html 一起刷成同色。
    • 高度原来靠 height:100% + 写死的 padding-top:66px 凑, 键盘弹起、地址栏收放都会错位。窄屏改用 100dvh(动态视口高度)并按 .Pane 补差量。
  • 手机后台:点输入框整页上移、指针跑到屏幕外,看不到在打什么。 这个是同一批原因的结果:界面比屏幕高、EditorContainer 又是 overflow:hidden, 浏览器的「把获得焦点的输入框滚进可视区」失败,于是整页被推走。 现在给滚动容器加 scroll-padding-bottom: 45dvh(键盘之上的可视区才算数)、 scroll-margin-bottom,并把 overflow 从 hidden 放开成可滚 —— 滚动链不通, 浏览器就没法把输入框滚进来。
  • viewport 补上 viewport-fit=cover,刘海屏/手势条上给工具栏留安全区内缩。

新增(工具)

  • npm run check:preview(tools/check-admin-preview.mjs):后台预览模板的回归检查。 已接进 check:all。
  • 后台的移动端问题是用无头 Edge + CDP 真跑一遍定位的: 用假 token(localStorage['decap-cms-user'],必须带 backendName: 'github')+ 拦截 api.github.com(/user、/repos/...、/git/trees/...、/contents/...、/graphql), 就能在本地进真实编辑器、量真实几何、截图,不用真的登录 GitHub。 探针要求 backend 里不能带 base_url(带了会走 OAuth 跳转,不走本地 token)。

2.7.0 · 2026-09-18

新增

  • 私人角落(/private/):口令在 Worker 里校验,口令不对连文章清单一根毛都拿不到 (不是前端比对字符串那种一看源码就穿的形式)。通过后展示被隐藏的文章, 支持全文搜索与三种排序;进入 / 离开都记日志(IP、设备、时间、停留秒数), /admin/logs/ 可看、可导出 CSV、?all=1 翻全部历史。 日志在 KV 里分两层:热数据 log:<id>(留 90 天)+ 永久归档 day:YYYY-MM-DD(不设 TTL), 所以 90 天到期只影响按条查看,历史统计不会跟着消失。 同一 IP 连错 5 次锁 15 分钟(fail:<ip> 计数)。

    说清楚边界:这是防君子。站点是纯静态的,文章本体没有加密, 知道真实 URL 的人仍然看得到,写秘密请另想办法。MAC 地址浏览器根本不给,做不了。

  • 排序下拉(src/components/SortSelect.astro):主页与私人角落共用。 没用原生 <select>(它的展开列表由浏览器画,圆角 / hover 都改不了), 按钮和列表自己画,真正的 <select> 留在 DOM 里只存值 —— 表单语义和可达性都保住, 页面代码照旧 getElementById + change 就行。三种排序:最新发布 / 最早发布 / 最近修改。
  • 卡片风格 × 皮肤:卡片 4 种(玻璃 / 描边 / 纸质 / 浮起),皮肤 7 套 (枫丹 / 歌剧院 / 深海 / 薄荷 / 琥珀 / 翡翠 / 靛蓝),设置面板里带缩略图。
  • 置顶:pinned: true 的文章排在最前并显示图钉与左侧金线。
  • 日记类(category: 日记)默认隐藏,被隐藏的文章也能带着 ?from=search 跳进私人角落。

修复

  • 「最近修改」排序看起来没反应 —— 其实是真的没生效。 排序的下拉箭头、选项高亮、写回隐藏 <select>、派发 change 都由 SortSelect 组件负责, 重排列表则由页面脚本负责,两半之间的那根线在人手里。私人角落只写了前一半: change 事件发出去没人接,于是选中项变了、列表纹丝不动。 (上一轮只验证了「组件会派发 change」,没验证「页面接住了」—— 验证做了一半。) 以及 const again = ... 那行被一次编辑压进了上一行的注释里,一直靠浏览器的 「带 id 的元素自动成为全局变量」兜着,属于埋着的雷,一并修掉。
  • 新增 npm run check:sort(tools/check-sort-link.mjs)盯死这类断线: 把产物里真实的两段脚本一起放进假 DOM,点每个选项并断言 「值写回 → 列表确实重排 → 三种顺序两两不同」。 已反向验证过:把 change 监听拆掉它立刻失败(exit 1),不是个只会点头的测试。 已接进 npm run check:all。
  • npm run check:all 不负责构建(它验的是已有的 dist), 改完源码要单独跑 npm run build —— 这次差点被它骗过去(源码改好了、dist 还是旧的, 于是测试「依然失败」,看着像修错了地方)。

2.6.0 · 2026-09-16

新增

  • 隐藏文章(日记不想被看见)。判定两条,满足其一即可: category: 日记(自动,日常用这个)或 private: true(手动,给非日记的文章)。 生效范围:首页 / 归档 / 标签云 / 标签页 / 分类筛选 / Pagefind 搜索 / RSS / sitemap / 上一篇下一篇导航 / 搜索引擎收录 —— 全都不出现; 但直接开链接仍然能看(纯静态站没有登录,这是能做到的极限,写秘密请另想办法)。
    • 规则只有一处实现:src/lib/hidden.ts。渲染期和构建配置期共用它 —— 后者不能 import src/lib/posts.ts(那会拖进 astro:content 这个渲染期才有的 虚拟模块,构建配置阶段直接报 “Cannot find module ‘astro:content’”)。
    • 分享卡片(og:description)对隐藏文章换成中性摘要,免得链接被贴出去时摘要外泄。
    • 后台加了「隐藏」开关,并说明「选日记分类会自动隐藏」。
    • npm run audit 新增第 8 节:逐项验收上述每个出口,泄漏即失败。

修复(这一节里有两个「本地能跑、线上必炸」的坑)

  • 幽灵依赖又咬了一次:astro.config.mjs 里 import 'yaml' 借用了 astro 的传递依赖 —— npm 会把它提升到顶层所以本地构建正常,Cloudflare 用 pnpm 的隔离模式时 直接报 Cannot find module 'yaml'、整个站点发不出去。 现在构建配置里只用极小的正则取需要的两个键; tools/verify.mjs 里的同类借用改成把 yaml 显式写进 devDependencies。

  • 同样是这一类:astro.config.mjs 一开始想直接 import { getPosts } from 'src/lib/posts', 而那个模块依赖 astro:content(渲染期的虚拟模块),构建配置阶段加载会失败 —— 所以判定规则抽到了独立的 src/lib/hidden.ts,两边共用。

  • 可选日期允许空值。网页后台清空「最后修改」时会写 updated: '', z.coerce.date() 把它转成 Invalid Date 对象,Astro 报 InvalidContentEntryDataError、整个站点构建失败。

  • tools/verify.mjs 两处对齐:schema 同样处理空值; frontmatter 改用真正的 YAML 解析器(原来手写的那版只认 tags: [a, b], 遇到后台保存出来的多行列表就误报 schema 错)。

  • audit-dist.mjs 的「加载更多」检查改为按公开文章总数判断 —— 公开文章不足一页时本来就没有下一页,缺 api/posts/*.json 是正确的。


2.5.1 · 2026-09-16

新增

  • 写作流程脚本化,三条命令:
    • npm run new —— 逐条问(标题 / 日期 / 分类 / 标签 / 封面样式 / 摘要), 直接回车用默认值;也支持 npm run new -- "标题" 技术 "前端, 笔记" 一条命令建好。 生成的草稿带 draft: true,构建时会跳过,先写草稿是安全的。
    • npm run posts —— 在资源管理器里打开文章目录。
    • npm run publish —— 自检 → 构建 → 提交 → 推送一条龙。 构建不过就什么都不提交(不会把错误推上去让 Cloudflare 报错); 有草稿会列出来提醒;提交说明写进临时文件再 git commit -F, 绕开 cmd.exe 下 git commit -m "中文" 乱码的问题。
  • README 补「写文章」完整章节:三种写法、改文章、本地预览、什么才算发布, 以及为什么必须 push 才会更新线上(线上是 Cloudflare 在构建 GitHub 仓库)。

修复

  • 主题切换的太阳 / 月亮图标没有居中。原来用的是 ☾ / ☀ 两个 Unicode 字符 —— 它们的墨迹在字体 em 框里的位置随系统字体变,靠 CSS 居中在有些机器上会偏上或偏下。 改成内联 SVG(viewBox 收紧到图形包围盒),居中由几何决定,不看字体脸色; 旋转切换动画保持不变。已用 Edge 无头截图逐个核对深色 / 浅色两种状态。

2.5.0 · 2026-09-16

新增

  • npm run smoke(tools/smoke-search.mjs):搜索的运行时冒烟测试。 verify / audit 只能证明「引擎没进首屏、chunk 切出来了」, 证明不了「用户聚焦之后它真能跑」。这个脚本用一个极小的 DOM 假件 把 dist 里真实的按需 chunk 跑一遍:init 幂等、补跑第一次输入、 渲染结果、↑↓ / Enter / Esc / Ctrl+K /「/」/ 清空、索引缺失时的诊断分支。 改搜索引擎之前先跑它。另加 npm run check = verify + smoke + audit。

性能

  • 搜索引擎改成按需加载(src/scripts/search-engine.ts)。 SearchBox.astro 里只留一个引导:聚焦输入框 / Ctrl+K /「/」 时才 import() 引擎本体。首屏要下的 JS 从 16.37 KB 降到 9.99 KB(gzip 后 实际传输),第一次达标 PRD 的 10 KB 红线 —— 其中行内 7.88 KB、外链 gzip 2.11 KB。 引擎自己的 chunk 是 7.80 KB(gzip 3.33 KB),用户一动手才下载。
  • 引导分两段:快捷键与「正在准备搜索…」占位现在就装好, 保证 Ctrl+K /「/」第一次按下就有反应;引擎初始化要一点时间, 那之前敲进去的字由引擎 init() 里的 240ms 补跑接住。
  • 引擎 chunk 下载失败(离线 / 404)会在面板里明说,不会静默留个「正在准备搜索…」。

修复

  • 补跑第一次输入的逻辑原来写成 if (input.value.trim()),而 init() 执行时 输入框必然是空的 —— 这个分支从来没生效过。现在无条件挂 240ms 兜底, 触发时再判断(已经有结果或已清空就跳过)。冒烟测试专门盯这条。
  • tools/verify.mjs 增加「按需模块」检查:搜索引擎 / 设置面板只允许被 import() 引用,一旦有人改回静态 import 会立刻报错。
  • tools/audit-dist.mjs 增加产物层回归:首屏脚本里出现 excerptLength / search-hit / pagefind.js 等引擎特征串即判失败, 并核对引导里的 import() 确实指向那个按需 chunk。

2.4.0 · 2026-09-16

新增

  • 搜索查询语法。支持 in:title,tag、tag:前端、category:技术、 date:2024-01..2024-12、words:500..2000、min:1..5,以及 "精确短语"。 文章页的 sr-only 区块里补齐了 data-pagefind-filter / data-pagefind-meta, 筛选走 Pagefind 原生筛选器,区间比较在客户端做。搜索时面板顶部会显示生效的条件。

    注:需求里提到的 size:1024..4089 是文件管理器的语义,博客文章没有「文件大小」, 已改用语义等价的 words:(字数)与 min:(阅读分钟)。

  • 皮肤系统。theme.css 改成「主色只存 RGB 分量 --a / --g,其余全部派生」, 皮肤只需覆盖这两行,线条 / 悬停 / 渐变 / 极光背景一起跟随。 三套皮肤:枫丹(水蓝)、歌剧院(玫瑰)、深海(幽紫),深浅各一份压暗版。 主题面板可同时切模式与皮肤。
  • --accent-solid / --gold-solid 用 @property 注册成 <color>, 这样渐变里的色标也能平滑过渡,而不是硬切。

修复 / 调整

  • 搜索聚焦聚光:整页模糊变暗,搜索框高亮,导航淡出。 遮罩是 pointer-events: none(PRD 硬性要求,旧版漏了会导致全站点不动)。
  • 搜索结果只展示一次:点别处关掉后会连结果一起清空,再次聚焦不会又冒出来。
  • 首屏禁止选中(.no-select),正文不受影响。
  • 顶部导航精简为 首页 / 关于 / 更新,归档与标签交给页脚(页脚本来就有)。
  • 搜索框光标与输入内容始终居中。

2.3.1 · 2026-09-16

新增

  • 封面池(src/lib/covers.ts)。文章 frontmatter 的 cover 现在支持三种写法:

    写法 含义
    cover: stand 用封面池里的(推荐)
    cover: /covers/my.jpg 用你自己的图(public 下的绝对路径)
    cover: https://… 外链

    不写则按 slug 从池子里稳定挑一张 —— 同一篇文章每次构建拿到的封面一致, 不会刷一次换一张。12 篇示例文章已全部改成池子 id。

调整

  • 主页首屏占满一屏(calc(100svh - 68px)),默认看不到文章列表。
  • 首屏只留徽记 + FONTAINE · FURINA + yuuu + 一行统计,砍掉了原来的长句和大标题。
  • 徽记放大到 clamp(148px, 22vw, 244px),站名放大到 clamp(54px, 12vw, 124px)。
  • 下滑时首屏整体缩小 20% 并淡出(只动 transform / opacity),文章版块顺势接上来。
  • 更新历史里内容本来就装得下的版本,不再显示「展开」按钮 —— 点了没新内容很迷惑。 改成按 scrollHeight 实测判断。

移除

  • 海浪背景。它原本对齐首屏底边,首屏改成满屏后会横在正文中间, 滚动时又赖着不走。已连同 Background 的 waves 属性和 CSS 一起删除。

修复

  • 搜索框光标默认停在中央(:placeholder-shown 时 text-align: center), 一开始打字就回到左对齐,避免边打边跳。

2.3.0 · 2026-09-16

新增

  • 真的 favicon。用 tools/make-icons.mjs 处理参考图:从四条边泛洪填充抠背景 (不是「白色变透明」—— 角色头发也是白的,那样会打出窟窿),裁边, 输出 favicon.ico(16/32/48 三尺寸)、icon-192/512.png、apple-touch-icon.png 和一份 site.webmanifest。
  • 7 张表情包处理成透明背景的 WebP,放在 public/mascot/,用作文章封面与空状态插画。
  • 主页重构成两屏:首屏只有徽记、站名、一句话和统计;文章列表整体下沉 + 模糊,滚进视口后一次性浮现。下滑提示带轻微浮动动画。
  • 搜索快捷键 /(Ctrl/Cmd+K 保留)。↑↓ 在结果间移动,Enter 打开选中项。
  • 从搜索结果点进文章会定位:链接带上 ?q=,文章页找到正文第一处匹配, 包上 <mark> 并 scrollIntoView({ block: 'center' }) 滚到屏幕中央, 配一个只动 opacity/transform 的脉冲圈。
  • 更新历史按版本折叠:默认只展开最新一版,其余收成 170px 并底部渐隐, 每段有「展开/收起」,顶部有「全部展开」。
  • tools/set-domain.mjs + pnpm run domain:一条命令改全站 6 处域名。

修复

  • 首页分类筛选点任何分类都是 0 篇。index.astro 读 el.dataset.category, 但 PostCard.astro 从来没输出过 data-category,永远是 undefined, 所有卡片都被 hidden。已补上属性。
  • 搜索框光标被 placeholder 遮住。快捷键提示原本写在 placeholder 里, 聚焦后文字紧贴光标。现在提示做成独立的 <kbd> 徽标,聚焦后淡出, placeholder 在聚焦时也变透明。
  • 卡片 hover 生硬。原来 300ms 线性位移 + 阴影重绘。现在统一用 cubic-bezier(0.22, 1, 0.36, 1) 长缓动,阴影改由伪元素淡入 (不逐帧重绘 box-shadow),封面缓慢放大,标题变色。
  • sharp 之前是幽灵依赖(和 zod 一样的坑),已显式声明。

与 PRD 的偏离

  • PRD 9.5 禁止用 filter: blur() 做动画,但需求明确要求「文章下沉模糊」。 采取折中:模糊只在进入视口时过渡一次,transitionend 后加 .is-done 把 filter 摘掉,不留长期合成层。 tools/verify.mjs 的对应规则也拆成两条:@keyframes 里动 blur 一律禁止; transition 里含 filter 则要求必须配 .is-done 摘除。

2.1.2 · 2026-09-16

修复(搜索质量)

  • 搜索结果被标签页污染。构建时 Pagefind 会提示 Did not find a data-pagefind-body element on the site. ↳ Indexing all <body> elements —— 意思是它把整页都索引了,包括导航栏、页脚和所有标签页。

    实测后果(用 pagefind.js 在 Node 里跑真实查询):

    查询 修复前 修复后
    芙宁娜 1 条,是标签页 /tags/芙宁娜/ 3 条,全是文章
    玻璃 6 条,第 1 条是 /tags/设计/ 1 条,正是那篇文章
    雨天 6 条(夹着无关标签页) 1 条
    Astro 34 条噪音 4 条,全是文章

    修法:给 PostLayout.astro 的 <article> 加 data-pagefind-body, 给底部按钮行加 data-pagefind-ignore,只索引正文。 索引页数从 43 降到 12(只剩文章),这才是博客搜索该有的样子。

  • 搜索结果能显示日期了。给日期元素加 data-pagefind-meta="date", SearchBox 里的 meta.date 从 undefined 变成了 2024 年 5 月 20 日。

增强

  • npm run audit 新增两项检查:文章是否都标了 data-pagefind-body、 是否都带 data-pagefind-meta="date"。这类问题不看产物是发现不了的。

2.1.1 · 2026-09-16

修复

  • 幽灵依赖。tools/verify.mjs 用了 zod 与 @astrojs/compiler,但没在 package.json 里声明。 npm 的扁平化把它们提升到顶层所以能跑;pnpm 的隔离式 node_modules 会正确拒绝, 报 ERR_MODULE_NOT_FOUND。现已显式声明为 devDependencies, 并把 verify.mjs 的导入改成动态 import —— 缺依赖时给一句人话而不是甩堆栈。

新增

  • npm run audit(tools/audit-dist.mjs):对真实构建产物做验收, 而不是像 verify 那样只看源码。检查首屏 JS 实测字节、首页卡片数、 加载更多的静态 JSON、Pagefind 索引完整性、RSS/Sitemap、 以及 [hidden] 兜底和 pointer-events 保护有没有出现在产物 CSS 里。
  • 首屏内联 JS 的构成明细(会打印每段脚本的体积与用途),让性能预算可执行。

补充说明

  • build 是 astro build && pagefind --site dist。astro build 若以非 0 退出码结束, pagefind 不会执行,搜索索引会静默缺失。构建后请跑 npm run audit 确认。
  • ⚠️ Windows 的 cmd.exe 里不要把 # 注释写在命令同一行。# 在 bash 是注释、 在 cmd 里不是,整行复制会让后面的命令收到多余参数 (例如 npm run build # 说明 会让 pagefind 报 unexpected argument '#' found)。 仓库里所有代码块都已去掉行内注释,可以整段复制。

实测数据(npm run audit,Astro 7.3.2 构建产物)

项目 结果
页面 44 个 HTML(12 篇正文 + 25 个标签页 + 归档/关于/更新/标签云/404)
首屏 JS 8.70 KB(行内 7.61 KB + 外部包 gzip 1.09 KB),红线 10 KB
第三方脚本 公开页面 0 个
首页首屏 正好 10 张卡片,追加走 api/posts/2.json
Pagefind 43 页 / 1694 词 / 57 文件 / 643 KB
RSS 12 篇
Sitemap 42 个 URL

2.1.0 · 2026-09-15

变更

  • Astro 5.18.2 → 7.3.2。这是与 PRD「锁死 Astro 5.x」的一处有意偏离: npm audit 对 Astro ≤ 7.2.7 报出 1 critical / 1 high / 1 low, 逐条核对后确认相关代码路径本项目都没用到,但修复成本只有一行,且已实测兼容。 源码一行未改,tools/verify.mjs 在 5 和 7 上都是全绿。
  • 包管理器配置补齐:pnpm-workspace.yaml 放行 esbuild / sharp 的构建脚本。 pnpm 10+ 默认拦截构建脚本并以 exit 1 结束,会导致 Cloudflare Pages 安装步骤失败。 注意 pnpm 11 已不再读取 package.json 里的 pnpm 字段,配置必须放这里。
  • 同时提供 package-lock.json 与 pnpm-lock.yaml,二选一保留。

⚠️ 破坏性要求

项目 旧 新
Node >= 18 >= 22.12.0
npm — >= 9.6.5
pnpm — >= 7.1.0

Cloudflare Pages 的 NODE_VERSION 必须从 20 改成 22,否则构建直接失败。


2.0.0 · 2026-09-15

用 Astro 从零重构。这是一次推倒重来,不是迭代。

技术栈(全部锁死)

层 选型
框架 Astro(静态输出,默认零 JS)
内容 Astro Content Collections(frontmatter 强校验)
样式 Tailwind CSS 4(主题变量走 CSS custom properties)
动画 View Transitions API + 原生 CSS
搜索 Pagefind(构建时生成静态索引)
RSS / Sitemap @astrojs/rss / @astrojs/sitemap
部署 Cloudflare Pages
写作 Sveltia CMS + GitHub OAuth Worker

新增

  • 组件化:Header / Footer / PostCard / TagCloud / SearchBox / ThemeToggle / Background / CoverStyle,改一处全站生效。
  • 内容层交给框架:不再手写 Markdown 解析器,表格、引用、代码块、任务列表全部由 Astro 处理,frontmatter 有 schema 校验。
  • 文件路由:首页、/posts/[slug]、/archive、/tags/[tag]、/about、/changelog、/rss.xml、/404。
  • Pagefind 全文搜索:索引分片、按需加载,聚焦输入框才开始下载,不占首屏。
  • 原生 View Transitions:水波淡入,零 JavaScript。
  • 深色 / 浅色双调色板:460ms 整体联动过渡。
  • 9 种封面样式:潮汐 / 星云 / 王冠 / 歌剧院 / 极光 / 星海 / 气泡 / 方格 / 自定义图片,全部是 CSS 渐变,零图片请求。
  • 首页分页:首屏只渲染 10 篇,其余由构建时生成的静态 JSON 按需追加。
  • Sveltia CMS:/admin 登录 GitHub 直接写文章,push 后 Cloudflare Pages 自动重建。
  • OAuth Worker:workers/oauth,处理 GitHub 授权的 /auth 与 /callback。

砍掉

  • ❌ 音乐播放器(与流畅度冲突)
  • ❌ 空闲提醒(鸡肋)
  • ❌ 6 种页面切换动画 → 只留 1 种「水波淡入」
  • ❌ 装饰性水母 SVG
  • ❌ 阅读次数 / 上次阅读时间(需要后端,第一版砍掉)
  • ❌ 拼音搜索(Pagefind 中文分词够用)
  • ❌ 评论系统(以后再说)

性能约定(硬红线)

  • 首屏 JS < 10KB —— 实测约 1KB(Astro 默认零 JS,交互脚本全部内联)
  • 只动 transform 和 opacity,不碰 filter / box-shadow / width
  • 第三方脚本默认 0 个(统计脚本需手动填 token 才加载)
  • 任何功能如果让首屏 JS 超标,砍掉重做

从旧版踩坑里继承的禁令

  • 禁止用 display 覆盖 [hidden] 的默认行为(全局已加 [hidden] { display: none !important; })
  • 禁止给全屏固定层不加 pointer-events: none
  • 禁止手写 Markdown 解析器
  • 禁止全量加载所有文章到一个 JS 文件
  • 禁止用 filter: blur() 做动画
  • 禁止用 localStorage 存文章正文

1.4.1 · 2024-09-15(旧版)

  • 修复全站按钮点不动:空闲遮罩用 display 覆盖了 [hidden]。

1.0.0 · 2024-09-12(旧版)

  • 手写静态站点首次上线。