
博客的评论区是个很奇怪的地方:你分不清谁是路过的,谁是每次都来的老朋友。老朋友留完言就走,不会特意告诉你他第几次来了;新访客留个「写得好」也没人接得住话。
Typecho 本身不带评论等级。插件市场有几个,但都是「积分 + 等级 + 排行榜」那一套,我一个都没用——因为我想要的不是激励读者发言,而是认出熟人。
这两件事看着像,其实相反:激励发言会让你把评论区变成打卡任务;认出熟人只是让老朋友知道「我记得你」。
所以这套东西从第一天就定了三条红线:不排名、不催更、不惩罚。徽章只有装饰作用,不动审核、不限流、不给任何权限。它唯一的工作是,让每次来的人知道自己被记住了。
设计理念
一、按邮箱聚合,不按 cookie
这是第一个也是最关键的决定。
Typecho 的 typecho_comments 表里,每条评论都带 mail 和 ip。直觉上用 cookie 最省事——写个标识,下次读出来算数。但 cookie 会被清、换设备就丢、隐私模式下根本不存在。老朋友换个手机,等级归零,这件事本身就很难解释。
邮箱不一样。mail 是评论者自己填的,他换设备、清缓存、用不同浏览器,只要填的是同一个邮箱,等级就连续。
代价是要按邮箱做一次全表聚合,不能每条评论单独算。这个代价后面会看到怎么兜住。
SELECT k, SUM(n) AS c, COUNT(*) AS a, MIN(f) AS f
FROM (
SELECT LOWER(TRIM(mail)) AS k,
LEAST(COUNT(*), 3) AS n,
MIN(created) AS f
FROM typecho_comments
WHERE status = 'approved' AND mail <> ''
GROUP BY LOWER(TRIM(mail)), FLOOR((created + 28800) / 86400)
) x
GROUP BY kLOWER(TRIM()) 是因为 Foo@Bar.com 和 foo@bar.com 是同一个人,不归一的话等级会裂成两条。
顺带一提,这个 SQL 有个容易被忽略的细节:日界必须用 PHP 的时区偏移先加再整除(上面的 28800 是东八区)。MySQL 会话时区和 PHP 不一定一致,直接用 DATE() 分组会把深夜的评论划到错误的自然日,活跃天数跟着算虚。
二、三维正交,而不是一个总分
如果只有一个数字,这个数字一定会被当成目标去刷。所以拆成三个互不相关的维度,而且必须同时达标才升级:
| 维度 | 含义 | 关键约束 |
|---|---|---|
| 计数 | 发过的评论条数 | 每个自然日最多只计 3 条 |
| 活跃 | 发过评论的不同天数 | 一天来十次也只算一天 |
| 资历 | 第一条评论至今的天数 | 只看最早那条,不看总量 |
三个维度各自堵死一种刷法:
- 只看条数 → 一天刷 300 条直接冲级(所以计数封顶 3/天)
- 只看活跃天 → 每天来点个「1」水天数(所以还要看条数)
- 只看资历 → 挂 400 天不说话也升级(所以三者取 AND)
封顶那句 LEAST(COUNT(*), 3) 是整个设计里最要紧的一行。它把「爆发刷级」这条路直接堵死了:想升到第五阶,你至少得在 45 个不同的日子里各出现一次。这不是能靠努力达成的,只能靠真的常来。
我早期版本把门槛设成了「7 天内 15 条」这种,被自己否掉了——太短了,认真刷两天就破。现在这套从两周拉到两年,跨度是刻意的。
三、名字先于数字
我不想在昵称旁边放「Lv.3」。数字会让人想去比较,而我想让人读到的是一段关系的描述。
七阶的名字是按中国人交情的说法排的,每一阶配一句寄语:
| 阶 | 名称 | 寄语 |
|---|---|---|
| 1 | 萍水相逢 | 一次擦肩,也是相逢 |
| 2 | 点头之交 | 见得多了,便点头致意 |
| 3 | 常来常往 | 门常开,人常来 |
| 4 | 渐渐相熟 | 名字记住了,声音也熟了 |
| 5 | 久处老友 | 不必寒暄,你来了就好 |
| 6 | 千里知己 | 千里之外,心意相通 |
| 7 | 莫逆之交 | 相视一笑,莫逆于心 |
徽章上只显示四字称谓,寄语放在悬浮面板第一行。这样评论列表保持干净,想看的人把鼠标放上去才展开。
门槛表本身就是一组可调参数,单位全部是「天」:
Lv2 14 天≈2周 Lv5 150 天≈5个月
Lv3 30 天≈1个月 Lv6 365 天=1年
Lv4 60 天≈2个月 Lv7 730 天=2年这里没有任何 /365 或 /30 的换算,365 和 730 就是天数本身。因为跨度要从两周拉到两年,中间得填档,我挑了「2周 / 1月 / 2月 / 5月 / 1年 / 2年」这组人类习惯的说法,而不是精确的 30/60/90/180。
四、博主不参与
博主自己的徽章由 CSS 用 ::after 生成一个「博主」,如果再叠一个等级徽章,读者会分不清哪个是身份、哪个是资历。所以判定 authorId === ownerId 时直接不渲染等级徽章。
实现步骤
第一步:聚合与缓存
全站聚合这条 SQL 是全表扫(typecho_comments 上没有 mail 索引)。个人博客量级——千行上下——可以接受,但不能每页每条评论都跑一遍。
所以做了文件缓存:.cache/level/map.json + .cache/level/map.stamp。
失效靠双条件,缺一不可:
if ($cur === (string) $approved) { // stamp:approved 总数
if ($data['expire'] > time()) { // TTL:600 秒
return $memo;
}
}为什么 stamp 存的是「approved 总数」而不是 MAX(coid)?这是个很隐蔽的坑:管理员在后台把一条待审评论审核通过时,coid 根本不变,MAX(coid) 感知不到这次变化。但 approved 总数会 +1,正好对应「有新评论进入统计」。
TTL 那一半则是必需的补充:资历天数和活跃天数只随时间流逝变化、完全不碰数据库,stamp 再准也拦不住它们过期。所以两个条件是并列的,一个管「数据变了」,一个管「时间走了」。
第二步:渲染位置——必须在 <cite> 内部
徽章最终长这样:
<span class="comment-level comment-level--3" tabindex="0"
aria-label="常来常往,门常开,人常来">
常来常往
<span class="level-pop"> … 进度面板 … </span>
</span>放在 <cite> 里面不是随手写的,是被手机端的布局逼出来的。
桌面端 .comment-meta-info 是一个正常 flex 容器,徽章放哪都行。但手机端这条规则会把它打散:
@media (max-width: 767px) {
.comment-meta-info { display: contents; }
}display: contents 让这个容器不再生成盒子,它的子元素直接变成 .comment-header 的 flex 项。如果徽章挂在 <cite> 外面,它就会被提升成一个独立的 flex 项,跑到昵称之外、跟时间戳并排。
放在 <cite> 里面就安全了——它是 <cite> 的子元素,<cite> 又是 display: contents 容器的子元素,重排时会整体跟着昵称流动、一起换行。
第三步:悬浮进度面板
面板挂在徽章上,position: relative 的锚点就是徽章本身,所以它精确落在徽章正下方。面板内容三块:寄语、头行(昵称 · Lv2「点头之交」 —— 下一阶「常来常往」)、三维进度条。
瓶颈判定用的是「三维里剩余绝对值最大那一维」。比如下一阶要求 15 条 / 8 天 / 30 天,你现在是 6 条 / 3 天 / 20 天,剩余 9 / 5 / 10,资历是瓶颈,面板就在资历那行标一句 ← 当前瓶颈:再等 10 天。
这个判据简单、可解释,也不会因为某一维比例低但绝对量小就误判。满级没有下一阶,改报一句「三条维度均已圆满」。
第四步:两个必须处理的细节
去掉原生 title。 一开始寄语是写在 title 上的,加上面板之后就冲突了:原生 tooltip 位置由浏览器决定、有几百毫秒延迟,会突然压在自绘面板上,两条信息叠在一起。所以寄语挪进面板首行,徽章改成 aria-label + tabindex="0"——键盘和触屏(点按聚焦)同样能呼出面板。
消除悬停缝隙闪烁。 徽章和面板之间留了 6px 空隙,鼠标从徽章移到面板的半路上,徽章:hover 已经失效、面板:hover 还没建立,面板会先隐藏,然后你就再也够不着它了。
解法是让徽章自己长一个透明的 ::after 盖住这 6px:
.level-pop { top: calc(100% + 6px); }
.comment-level::after {
content: ""; position: absolute;
left: 0; right: 0; top: 100%; height: 6px;
}因为这个 ::after 是徽章的后代,指针穿过缝隙时仍然落在徽章的命中区域里,:hover 不中断。
第五步:横向钳位
面板锚在徽章上,代价是横向起点跟着徽章走。一个长昵称会把徽章一路推到卡片右侧,面板再往右铺就戳出视口了。
而绝对定位元素的右侧溢出会算进文档的可滚动区域——页面会凭空多出一条横向滚动条。CSS 里没法知道「徽章离视口右缘还有多远」,只能上 JS:
var rect = pop.getBoundingClientRect();
var vw = document.documentElement.clientWidth;
var shift = 0;
if (rect.right > vw - 8) shift = -(rect.right - (vw - 8));
if (rect.left + shift < 8) shift += 8 - (rect.left + shift);
if (shift !== 0) pop.style.left = shift + 'px';实测一个 14 字长昵称,面板 left 从 357px 被拉到 21px。
踩过的坑
这一节比实现本身更有价值,因为都是「测试全绿但功能是坏的」那类。
1. 缓存命中了,但没回填。 聚合结果写进了缓存文件,却忘了写回函数内的 $memo。结果首次取数永远返回空,徽章要等第二次请求才出现。第一次测试全过——因为测试跑两遍。
2. 测试夹具漏了一层 <ol>。 嵌套评论的真实标记是 .comment-children 里再包一层 <ol class="comment-list">(由核心 Archive::threadedComments() 的 before 输出)。夹具里漏了这层,HTML 解析器遇到「<div> 里直接放 <li>」会先闭合外层 <li>,把子评论重挂成兄弟节点——于是 .comment-children 变空,嵌套楼层的断言「查不到元素」,静默跳过,假绿。
站点的标记本身没问题,是测试写错了。
3. chromium 的窗口宽度有 500px 下限。 视觉测试一开始用 --window-size=390,1400 测手机档,实测发现新旧两种 headless 模式下 innerWidth 恒为 500。也就是说 390 和 320 两档一直在用 500px 冒充,断言全绿却什么都没测。
改成走 CDP 的 Emulation.setDeviceMetricsOverride,并且加一条 real_viewport 断言,要求探测到的 innerWidth 必须等于期望值——否则测试本身就不可信。
4. 配色锁死十六进制,不用主题 CSS 变量。 主题变量在明暗模式下会翻转明度(--color-400 亮色是 #707070,暗色变成 #c0c0c0)。徽章如果用变量,亮色模式下的白字在暗色模式会压到浅灰上,直接不可读。所以七阶底色写死:
.comment-level--1 { background: #7a7a7a; color: #fff; }
.comment-level--4 { background: #eab308; color: #101010; } /* 黄底必须配深字 */
.comment-level--7 { background: linear-gradient(135deg, #b8555f, #7a4fa3); color: #fff; }5. 注释里写字面量的注释结束符。 把「为什么」写进代码时,注释块里出现了 /** */ 这几个字符,其中的 */ 提前闭合了注释,后面的内容全变成 PHP 代码,php -l 直接语法错误。
效果
徽章是一枚四字胶囊,七阶从灰到暖色再到彩色渐变:
| 阶 | 底色 | 字色 |
|---|---|---|
| 1 萍水相逢 | #7a7a7a | 白 |
| 2 点头之交 | #9c6b3f | 白 |
| 3 常来常往 | #64757f | 白 |
| 4 渐渐相熟 | #eab308 | 深 |
| 5 久处老友 | #e0873a | 深 |
| 6 千里知己 | #2f6b96 | 白 |
| 7 莫逆之交 | linear-gradient(135deg,#b8555f,#7a4fa3) | 白 |
前三阶是低饱和的中性色,存在感刻意压得很低;第四、五阶转暖(黄→橙),是「熟了」的视觉提示;第六阶回到冷色但更饱和;第七阶是唯一的渐变。

悬浮面板长这样(以 Lv2 为例,瓶颈是资历):
见得多了,便点头致意
小明 · Lv2「点头之交」 —— 下一阶「常来常往」
计数 6/15 ████████░░░░░░░░
活跃 3/8 ████░░░░░░░░░░░░
资历 20/30 █████████████░░░ ← 当前瓶颈:再等 10 天验证
这套东西写了两层测试,都在 CLI 里跑:
- 门槛判定:37 项,覆盖七阶 AND 关系、反刷(潜水 400 天只 3 条仍 Lv1、爆发 300 条但仅 10 天仍 Lv1)、大小写与空格归一、缓存 stamp 失效、满级文案、昵称转义
- 响应式渲染:5 档视口(1440 / 834 / 767 / 390 / 320)× 25 项 = 125 项,覆盖徽章不出界、不折行、始终在
<cite>内、面板不出视口、失焦收起
视觉测试那层不是普通的 DOM 断言,它会真的把每个徽章 focus() 一遍,读 getComputedStyle 和 getBoundingClientRect——因为 nowrap 是否生效、display: contents 重排后徽章还在不在昵称里、面板有没有戳出视口,这些只有真实的布局引擎说了算。
顺带把主题头部注释的解析规则也钉进了不变量测试。Typecho 用 Plugin::parseInfo() 读 index.php 的文档注释,@package/@author/@version 被删掉不会报错,只会让后台那几行静默变空、标题回退成目录名。现在 110 项不变量里有 5 项专门守着它。
想改的话
所有门槛都在 theme_comment_level_defs() 里,一行一阶:
2 => ['name' => '点头之交', 'motto' => '见得多了,便点头致意', 'c' => 5, 'a' => 3, 't' => 14],c / a / t 分别是计数、活跃天、资历天。改动只影响文案和门槛,不用碰任何 SQL 或 JS。
不过这组数字现在是盲调的。真上线之后建议拿实际评论分布校一遍——个人博客的评论密度差别很大,日更站和月更站的「45 个活跃日」完全是两个概念,定高了没人够得着,这套东西就白做了。


已有 16 条评论