新增列表卡片:无封面排版 + AI 摘要动态拉取

新增列表卡片:无封面排版 + AI 摘要动态拉取
Eucalyptus前言
之前给博客做过一版「横向卡片」(配置项叫 home_card_style: horizontal),后来主题更新,那套模板和 CSS 一起没了,配置项也变成了孤儿。这次换个思路:不另写模板,直接在 post-ui.pug 里加分支,用一个整数开关切换「封面卡片」和「列表卡片」。
顺带解决了另一个更烦的问题:卡片里那句摘要。
以前它是「正文去掉 HTML 标签后截取 500 字」,也就是文章开头那句话。而文章页顶部明明有一块 AI-摘要,是真正的总结。为什么不直接把那段搬过来?
这篇文章就记录这两件事:怎么加一张卡片,以及怎么让卡片里的摘要真的”活”起来——不是抄一份快照,而是跟着服务端缓存自动更新。
示例:
正文
文件路径分布
1 | blog/ |
css/_extra/ 下的文件会被 themes/anzhiyu/source/css/index.styl 里的 @import '_extra/**/*.css' 自动收录,所以新增样式文件不用改入口。
一、开关:一个整数,两套版式
1 | # 首页双栏显示 |
二、模板:只加分支,不另写文件
原来的 mixin 是「有封面就渲染封面,没封面就整行文字」。列表卡片要的是”永远没有封面,但内容重新排布”,所以在 mixin 顶部算出两个变量,后面按条件走:
1 | mixin postUI(posts) |
三点改动:
- 卡片多了
post-list-card类,CSS 全部挂在这个类下面,别的版式完全不受影响; data-abbrlink留给后面的摘要脚本用(abbrlink是 hexo-abbrlink 生成的短链,唯一且稳定);- 封面那一段加
&& !isListCard,列表卡片不渲染.post_cover。
分类的位置也变了:封面卡片把它塞在标题上方的小字里,列表卡片把它做成胶囊放到元信息行。所以分类渲染也加了条件:
1 | if (!isListCard && theme.post_meta.page.categories && article.categories.data.length > 0) |
元信息行里(日期之后)再补一个分类胶囊,多级分类合成一个,用 / 连接:
1 | if (isListCard && theme.post_meta.page.categories && article.categories.data.length > 0) |
正文后面追加字数、阅读时长和右侧箭头(箭头是卡片的直接子元素,方便绝对定位到整卡高度):
1 | if showListWords || showListMin2read |
三、样式:全部挂在一个类下面
1 | /* 卡片外壳:注意这里用 width:auto,不是 100% */ |
颜色全部用主题变量(--anzhiyu-main / --anzhiyu-secondbg / --anzhiyu-theme-op),所以换主题色、深浅色模式都不用改样式。箭头那块淡色底用的是 --anzhiyu-theme-op(主色的 14% 透明度)。
四、踩到的五个坑
| # | 现象 | 原因 | 解法 |
|---|---|---|---|
| 1 | 列表卡片被压成两列窄条 | article_double_row: true 在 ≥1201px 上给 .recent-post-item 设了 width: 49% |
用 #recent-posts > .recent-post-item.post-list-card 提高权重,显式 width: 100% |
| 2 | 移动端卡片溢出屏幕右侧 | 我把 width: 100% 当默认值,而手机端卡片还有 margin: 20px |
默认改 width: auto,只在 ≥1201px 的媒体查询里写 width: 100% |
| 3 | 两个分类出现两个书图标 | 每个分类渲染一个胶囊 | 改成”一个胶囊装所有分类”,用 / 分隔 |
| 4 | 摘要不显示 | 主题全局有一条 .content { opacity: 0; height: 0 }(双栏版式为了省略摘要) |
列表卡片显式 opacity: 1; height: auto |
| 5 | 改完样式没生效 | Hexo 只按入口文件 mtime 判断,_page/*.styl、_extra/*.css 改了不会触发 CSS 重编译 |
hexo clean && hexo generate |
第 4 条值得多说一句:我一开始以为是”摘要没渲染”,查了半天模板,其实 HTML 里一直有 .content 节点,只是被 CSS 藏起来了。先看 DOM,再看 CSS,能省一半时间。
五、让摘要真的变成”摘要”
5.1 第一版:静态 JSON
最省事的做法是抓一遍,存成数据文件,模板里直接读:
1 | - const listAiSummary = isListCard && site.data.ai_summary ? site.data.ai_summary[article.abbrlink] : null |
_data/ai_summary.json 长这样,按 abbrlink 索引:
1 | { |
能跑,但有个死穴:它是快照。Redis 里的摘要重新生成、过期、或者你发了新文章,这个文件不会自动变,只能再抓一遍。
5.2 为什么首页拿不到摘要
看一下摘要服务的逻辑(Node + Express + Redis):
1 | function getCacheKey(text) { |
缓存键是正文清洗后的前 4000 字的 md5,不是文章 ID、不是 URL。
文章页能拿到摘要,是因为它自己在文章里,可以就地提取正文、算出这个指纹:
1 | 文章页 → getArticleText() 清洗正文 → md5 → 查 Redis → 命中/生成 → 显示 |
而首页只有标题和一句摘要,没有正文,算不出指纹,也就查不到。
顺带排掉两个误区:
cache-admin.sh list里那个ID(001、002……)是sort | nl每次现算的行号,不是持久标识,增删任何一条,后面全部漂移;- Redis 里根本没有”这篇文章对应哪条摘要”的映射,只有一串摘要正文和它们的 md5。
5.3 顺手排掉的一个隐性问题
调试时发现同一个 URL,带 Origin 请求有时拿不到跨域头:
1 | curl -H "Origin: https://blog.eucalyptus.cc" "…/api/summary?key=p:83caee76" # 没有 ACAO |
原因是宝塔的全局配置 /www/server/nginx/conf/proxy.conf 里开着:
1 | proxy_cache cache_one; |
它给所有反代站点加了一层缓存,缓存键是 $scheme$proxy_host$request_uri,不区分 Origin。于是”第一次谁访问的”决定了这份缓存里有没有 Access-Control-Allow-Origin——浏览器第二次来就撞上空头,跨域失败。
解法是在响应里加一行,让 nginx 别缓存这条响应:
1 | res.set('X-Accel-Expires', '0'); |
X-Accel-* 是 nginx 认的控制头,0 表示不缓存。加上之后同样的测试就稳定了。
5.4 最终方案:给每篇文章加一张”目录卡”
既然键算不出来,那就在写摘要的时候,额外按文章标识存一份。
服务端只加两个地方:
1 | // 写入时(POST /api/ai-summary),如果带了 key,就多存一份 |
文章页的请求体里带上 abbrlink:
1 | const requestBody = { |
abbrlink 从 GLOBAL_CONFIG_SITE 里取,在 head/config_site.pug 注入:
1 | abbrlink: '!{abbrlinkVal}', |
首页每个卡片只要认自己的 data-abbrlink,就能直接问:
1 | fetch(API + '?key=' + encodeURIComponent('p:' + abbrlink)) |
完整的卡片脚本(3 并发、失败静默、命中才替换):
1 | (function () { |
注意这段脚本只是”升级“,不是”加载”:首屏渲染的还是构建时 _data/ai_summary.json 里那份,脚本只是拿最新值覆盖。所以接口挂了、跨域被挡、Redis 里没这条,页面都照常显示,只是看不到更新。
六、结果
- 首页 10 张卡片,服务端渲染时就带着摘要,首屏不空;
- 脚本再发 10 个轻量
GET(3 并发),命中就把文字替换成 Redis 里的最新版本,并在元素上打一个data-summary-source="live"的标记,方便验证; - 现有 26 篇文章的别名已一次性写入(
ai:summary:p:<abbrlink>,TTL 与正文缓存一致,30 天); - 新文章:第一次有人打开文章页,就会顺手把这张”目录卡”写好,首页下次刷新自动跟上。
后记
这次改动最麻烦的不是 CSS,是”为什么首页拿不到摘要“这个问题。表面看是”接口不给我数据”,实际是键的设计:缓存按内容指纹编址,而首页手里只有文章标识。
补一层 abbrlink → 摘要 的别名,问题就没了。这类”看起来是权限问题、其实是寻址问题”的坑,值得记一笔。
几个能复用的经验:
- 给卡片加新样式,优先”加类名 + 新 CSS 文件”,而不是复制一份模板。模板会分叉,样式不会。
width: 100%和margin一起用必然溢出,块级元素让它自己撑开就好。- 样式不生效先想缓存:Hexo 的 CSS 只认入口文件 mtime,改了被
@import的局部文件必须clean。 - 静态兜底 + 动态升级是做”锦上添花”类功能的正确姿势:接口挂了页面不能挂。
- 反代层的缓存会吃掉跨域头,
X-Accel-Expires: 0是最省事的开关。
已知限制:摘要生成依赖模型额度,配额打满时会返回 429 insufficient_quota,此时候选文章的卡片会退回到构建时的快照——不影响阅读,只是不够新。








