起因
在别人的博客上看到一张赞赏页(liushen.fun),做得很干净:上面两个收款码并排,下面一份鸣谢名单,整页没有一句”求打赏”的话术,就是安安静静放个入口。
本站一直没有这个页面。倒不是排斥,是觉得很多赞赏页做得让人不太舒服——金额明晃晃列一排、名字后面挂着「¥50」,读着像排行榜。所以我给自己定了三条:
- 金额一律不公开,名单只记昵称和留言;
- 数据手动维护,不接后台、不搞数据库,改一个文件就够;
- 收款码先占位,图后面再补,但页面现在就得能正常打开。
这三条看着是需求,其实每一条都对应一个实现上的选择。这篇文章就是把这几个选择、以及路上踩的两个坑记下来。
一、先拆原站:代码 0% 可搬,思路 100% 可复用
第一件事是看对方怎么写的。结论很干脆:搬不了。
| 维度 | 原站 | 本站 |
|---|---|---|
| 主题 | Hexo + Pug 模板 | Fuwari(Astro) |
| 模板语言 | Pug(缩进即语法) | Astro 组件(JSX-like) |
| 布局外壳 | 主题自带 page 模板 | MainGridLayout.astro |
| 样式方案 | 独立 CSS | Tailwind + 主题 CSS 变量 |
Pug 和 Astro 的语法差异大到没法逐行翻译,硬套只会写出一堆不三不四的东西。但拆完之后有个收获:它真正值钱的不是布局,而是那几条克制——不写金额、只留留言、名单不排序。这些是设计决定,跟用什么主题无关。
所以接下来的工作不是”复刻”,而是”用 Astro 重新表达同一套决定”。想清楚这点之后,后面就顺了。
二、三个要求,各自落在哪
1. 金额不公开 —— 让它根本不存在,而不是藏起来
这是全篇我最想强调的一点。
「不公开金额」有三种常见做法,安全性差着量级:
| 做法 | 实现 | 风险 |
|---|---|---|
| 前端不渲染 | 数据里有金额,模板里不显示 | 改版、导出数据、看源码时随时漏出 |
| CSS 遮起来 | 金额渲染了,用 filter: blur 蒙住 | 右键检查元素即破 |
| 结构里就没有 | 数据类型压根没这个字段 | 想漏也没得漏 |
我选第三种。数据结构长这样:
export interface Sponsor { /** 昵称(必填,公开显示) */ name: string; /** 头像地址;留空则显示昵称首字符 */ avatar?: string; /** 留言 / 一句话简介 */ message?: string; /** 支持日期,格式 YYYY-MM-DD;留空则不显示 */ date?: string; /** 对方的主页或站点,填了会在卡片右上角显示外链按钮 */ url?: string;}没有 amount。这不是”暂时没用到”,而是刻意不给它留位置——将来就算我自己想加,也得先改类型定义、再改模板,多一道心理摩擦。约束做进结构里,比写进注释里可靠。
顺带一个细节:名单头部那个 共 N 位 的计数,也是唯一出现的数字。整页没有第二处金额相关的量。
2. 数据手动改 —— 收成一个文件,且不用手动排位置
所有内容集中在 src/sponsor_data.ts,文件顶部写满了注释,包括图片该放哪、名单怎么写、不想署名怎么办。
真正省事的是排序。人的直觉是”往下追加最新的一条”,但页面上要的是”最新的在最上面”。如果每次加人都得手动挪位置,早晚会忘。所以把排序交给页面:
const sortedSponsors = [...sponsors] .map((item, index) => ({ item, index })) .sort((a, b) => { const da = a.item.date ?? ""; const db = b.item.date ?? ""; if (da && db) return db.localeCompare(da); // 都有日期 → 倒序 if (da) return -1; // 只有 a 有 → a 在前 if (db) return 1; // 只有 b 有 → b 在前 return b.index - a.index; // 都没日期 → 按书写顺序,后写的在前 }) .map(({ item }) => item);规则就三条:有日期的按日期倒序、没日期的排在后面、日期空缺时按写入顺序倒推。于是维护动作退化成”打开文件、往数组末尾粘一段、保存”,不需要读任何其他代码。
3. 收款码占位 —— 靠 onerror 回退,而不是靠我记得补图
第三个要求最实际:正文和结构现在就能上线,二维码图我手上还没有。
如果直接 <img src="/sponsor/wechat.png">,图不存在时浏览器会渲染一个破图图标加 alt 文字,很难看。常见的笨办法是先放一张占位图,补真图时再替换——但那样就多了一步”记得改代码”。
我让缺图这件事自己暴露:
<img src={way.qr} alt={`${way.name}收款码`} width="180" height="180" loading="lazy" onerror="this.closest('.sponsor-qr-frame').classList.add('is-empty')"/>加载失败时给外层容器加一个 is-empty 类,CSS 里由它切状态:
.sponsor-qr-frame.is-empty img { display: none; }.sponsor-qr-frame.is-empty .sponsor-qr-placeholder { display: flex; }.sponsor-qr-placeholder 是一块虚线框 + 二维码图标 + “待补充”,平时 display: none 待命。
这么写的好处是:补图这个动作不需要改任何代码。把 wechat.png 丢进 public/sponsor/,刷新即生效。同款的还有名单头像——onerror="this.remove()" 直接摘掉失败的 <img>,露出底下用昵称首字符做的字母头像,永远不会看到破图。
三、入口:插进导航栏
页面做好了得能进去。本站导航在 src/config.ts 的 navBarConfig 里,加一项即可:
{ name: "赞赏", url: "/sponsor/", external: false,},位置我放在「友链」和「开往」之间——同类站内页聚在一起,不是随手丢末尾。现在是:
首页 / 归档 / Now / 关于 / 友链 / 赞赏 / 开往 / 其他注意 url 不写 base path(本站部署在子路径时由主题自动拼),external: false 表示站内链接、不显示外链图标。
四、两个坑,都是”不报错”的那种
真正花时间的不是写页面,是这两处。
坑 1:编辑时缩进模糊匹配,把相邻项吃掉了
加导航项时我用了编辑器做字符串替换,结果回读发现「开往」那一项被截断并重复了——本来一项变成了两项半。
原因是我给的匹配文本和文件里的真实缩进不完全一致(这文件用的是 Tab,我按习惯写成了空格)。编辑器做了容错匹配,把边界算歪了。这件事的教训不是”这个编辑器不行”,而是:
改配置文件后一定要回读校验,不能信”已保存”。
我的做法是改完立刻用脚本把改动区域的每一行按 repr() 打出来,逐行核对缩进字符:
lines = open(path, 'rb').read().decode('utf-8').split('\n')for n, l in enumerate(lines[43:95], 44): print(n, repr(l)) # repr 会把 Tab 打成 \t,一眼看出真假在那之后,凡是涉及此类改动,我都走”写脚本改 + 回读核对”,而不是依赖编辑器的模糊匹配。批量替换一律用 Python 按字节处理,因为一旦涉及行尾(\r\n)和编码,文本模式的读写会静默做转换,污染整个文件。
坑 2:Tailwind 静默丢弃了那个类
坑 1 是手滑,坑 2 是真不知道。
本站友链页有两个「申请友链」「申请标准」的小圆底图标,写法是:
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-[var(--primary)]/10">看起来完全合理——10% 透明度的主题色。但它一直是失效的,圆底其实没有颜色,只是不太明显所以没人发现。
原因:Tailwind v3 没法给 var() 任意值注入 alpha。bg-[var(--primary)] 是合法的,末尾加 /10 之后整个类会被直接丢弃——不报错、不警告,类名还老老实实挂在 DOM 上,所以只能靠肉眼”感觉这里应该有圈底色”来发现。
我用 Tailwind CLI 单独跑了一遍验证,不是猜的:
# in.css 里只有一行 @tailwind utilities;# 测试用的 HTML 里放 bg-[var(--primary)]/10 和对照的 bg-white/10node node_modules/tailwindcss/lib/cli.js -i in.css -o out.css --content test.html结果:产物里 bg-white/10 正常生成,bg-[var(--primary)]/10 一行都没有。
修的办法很简单,用 color-mix() 手写:
.icon-badge { @apply flex items-center justify-center shrink-0 w-10 h-10 rounded-full transition; color: var(--primary); background: color-mix(in oklab, var(--primary), transparent 90%);}顺手做了一件事:这既然是「主题色圆底 + 图标」这种通用视觉原子,就别让它在每个页面里重复一遍。提炼成全局类 .icon-badge 之后,赞赏页和友链页共用同一份定义,以后再出现同类用法直接套类名就行。
color-mix() 还有个额外好处:深色模式不用单独写覆盖。因为 --primary 本身在两个主题下取值不同,混出来的底色自动跟着变。
五、没起 dev server,怎么看效果
本机内存比较紧,我不太愿意为看一眼样式就把 dev server 拉起来。所以这次的做法是手工拼一份静态预览:
- 用 stylus 编译主题真实的变量文件,拿到
--primary、--card-bg、--line-color这些真值; - 用 Tailwind CLI 对页面源码做一次按需编译,产出它真正用到的那批工具类;
- 把两者内联进一个自包含 HTML,附一份和源码一致的标记。
产出是一个能直接双击打开的文件,还能切浅色/深色,观感和线上基本一致。代价是这份预览不会自动跟随源码更新,改完要重跑一次生成脚本——对”看效果”这个用途足够了。
这一轮验证用了三样东西,都不起服务:
| 手段 | 查什么 |
|---|---|
@astrojs/compiler 直接编译 | .astro 语法是否通过 |
| Tailwind CLI 输出产物 | 某个类到底有没有生成 CSS |
| Python 字节回读 | 改动是否真的落盘、行尾是否被翻转 |
小结
这一页的代码量不大——一个页面文件、一个数据文件、导航里加一项、全局 CSS 加一个类。真正值得记的是那几个刻意的选择:
- 「不公开金额」不是把金额藏起来,而是让它在数据结构里不存在;
- 「手动维护」不是把编辑成本留给人,而是让人只需要”往末尾粘一段”;
- 「图片占位」不是先垫一张假图,而是让缺图自己触发一个状态。
以及那两个不报错的坑——模糊匹配会吃掉相邻内容,Tailwind 会静默丢弃不合法的类。它们的共同点是:出错时什么都不说,代码看着还是对的。对付这类问题只有一个办法,就是改完必须回读、必须拿产物验证,不能靠”看起来没问题”。
赞赏页已经上线,入口在导航栏「赞赏」。金额不公开这条会一直保持——那是我和每一位朋友之间的小默契。