给博客做了一轮大修:书架、中控台、访客信息一次搞定

又是折腾博客的一晚上

博客用 AnZhiYu 主题用了一阵子,零零散散积了一堆小毛病:关于页的「追番」我根本没看几部番,空荡荡的;中控台最新评论莫名消失;浅色模式下个人卡片文字全白看不见;页脚信息太单薄;签到图标还是原作者的「安知鱼」。

一狠心,全给收拾了。顺便把侧边栏访客欢迎信息、倒计时、友链朋友圈后端这几块也整理记录一下,免得下次升级主题又得重来一遍。


一、追番页改书架

为啥要改

关于页那个「爱好番剧」板块,我就看过两三部,挂在那儿特别空旷。又不想为了填满硬去看一堆番,干脆换成「书架」——书我倒是真看了不少。

怎么改的

数据全在 source/_data/about.yml 里,找到 comic 这一段,把标题和列表换掉就行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
comic:
comic_tips: 爱好阅读
comic_title: 书架
comic_list:
- name: 三体
href: https://book.douban.com/subject/2567698/
cover: /img/books/three-body-1.jpg
- name: 三体II 黑暗森林
href: https://book.douban.com/subject/3066477/
cover: /img/books/three-body-2.jpg
- name: 三体III 死神永生
href: https://book.douban.com/subject/5366477/
cover: /img/books/three-body-3.jpg
- name: 流浪地球
href: https://book.douban.com/subject/3266609/
cover: /img/books/wandering-earth.jpg
- name: 活着
href: https://book.douban.com/subject/4913064/
cover: /img/books/to-live.jpg

封面图我是从豆瓣下到本地 source/img/books/ 里的,为啥不放外链?因为豆瓣有防盗链,直接引用会裂图。下到本地最稳。

踩坑

  • 别用主题默认的 cover 外链,豆瓣图片 Referer 一校验就 403,老老实实存本地。
  • 字段名 comic 不能改,主题模板里写死了,只改里面的内容。

二、中控台最新评论回来了

啥情况

点开右下角中控台,以前会显示最新评论和最新文章,结果某天突然就没了,光剩个切换边栏和暗色模式的按钮。

根因

主题这块要单独开开关,newest_comments.enable 默认是关的。在 _config.anzhiyu.yml 里加上:

1
2
3
4
5
6
# 最新评论(中控台展示)
newest_comments:
enable: true
limit: 5
storage: 0.6
avatar: true
  • limit:显示几条,我设 5
  • storage:缓存时长(小时),0.6 就是大约 36 分钟刷一次
  • avatar:显示评论者头像

评论数据是从 Twikoo 后端拉的,所以还得保证 twikoo.envId 配的地址能正常访问,不然就算开关开了也是空的。

验证后端

我直接 curl 了一下 Twikoo:

1
2
3
curl -X POST https://twikoo.346247.xyz/.netlify/functions/twikoo \
-H "Content-Type: application/json" \
-d '{"event":"getRecentComments","commentLength":5}'

返回 200 且有数据,说明后端没问题。所以之前「啥也没恢复」纯粹是因为部署还没推完,改完一定要硬刷新浏览器(Ctrl+Shift+R),主题的 localStorage 缓存特别顽固。


三、浅色模式个人卡片不空白了

啥情况

切到浅色模式,侧边栏个人卡片只剩头像和那个狗头状态图标,名字、描述、社交图标全看不见——不是没渲染,是字白色的,背景也是浅色,叠一起就「隐形」了。

根因

我之前为了统一卡片风格,用 CSS 把主题默认的渐变背景层(.card-info::before)隐藏了:

1
2
3
#aside-content > .card-widget.card-info::before {
display: none !important;
}

背景一没,原来设计在彩色背景上的白色文字就暴露问题了。

修复

_config.anzhiyu.ymlinject.head 里注入一段 CSS,浅色模式下把文字改成深色,深色模式保持白色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 浅色模式:个人卡片文字改为深色 */
#aside-content .card-info .author-info__name,
#aside-content .card-info .author-info__desc,
#aside-content .card-info #author-info__sayhi,
#aside-content .card-info .author-info__description,
#aside-content .card-info .card-info-social-icons i {
color: var(--anzhiyu-fontcolor) !important;
}
#aside-content .card-info #author-info__sayhi {
background: var(--anzhiyu-card-op) !important;
}
/* 深色模式保持白色文字 */
[data-theme="dark"] #aside-content .card-info .author-info__name,
[data-theme="dark"] #aside-content .card-info .author-info__desc,
[data-theme="dark"] #aside-content .card-info #author-info__sayhi,
[data-theme="dark"] #aside-content .card-info .author-info__description,
[data-theme="dark"] #aside-content .card-info .card-info-social-icons i {
color: var(--anzhiyu-white) !important;
}

用主题自带的 CSS 变量 --anzhiyu-fontcolor--anzhiyu-white,这样不用自己定颜色,跟着主题走。


四、页脚补上框架信息

啥情况

页脚那栏就一个「2025-2026 By Cheymin」,右边空着。别人的博客这儿都挂着「Powered by Hexo · Theme · Hosted on Cloudflare」,我也想要。

怎么加

主题自带 footerBar 配置,在 _config.anzhiyu.yml 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
footer:
footerBar:
enable: true
authorLink: /
linkList:
- text: Hexo
link: https://hexo.io/
- text: AnZhiYu
link: https://docs.anheyu.com/
- text: Cloudflare
link: https://www.cloudflare.com/
cc:
enable: false

linkList 里的每一项就会渲染成页脚右侧的小链接,点开跳对应官网。cc(知识共享徽章)我关了,嫌乱。


五、签到图标换成自己的

啥情况

页脚那个签到图标原来写的是「安知鱼 下班啦」,鼠标悬浮提示「下班了就改….」。这是原作者的,得换成我自己的。

怎么改

_config.anzhiyu.ymlfooter.runtime 里:

1
2
3
4
5
6
7
runtime:
enable: true
launch_time: 08/20/2025 15:20:50
work_img: /img/sign-work.svg
work_description: Cheymin 上班摸鱼中...
offduty_img: /img/sign-offduty.svg
offduty_description: 放学了就该好好休息啦!

图标我一开始自己画了个圆角矩形 SVG,结果风格跟主题格格不入。后来学乖了,模仿主题原来 shields.io 徽章的风格,左侧图标+名字、右侧状态,只改字:

1
2
3
4
5
6
7
<!-- source/img/sign-offduty.svg -->
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="40" viewBox="0 0 160 40">
<rect x="0" y="0" width="160" height="40" rx="20" ry="20" fill="#FF7B3A"/>
<rect x="0" y="0" width="70" height="40" rx="20" ry="20" fill="#555"/>
<text x="35" y="26" font-family="sans-serif" font-size="14" font-weight="700" fill="#fff" text-anchor="middle">Cheymin</text>
<text x="115" y="26" font-family="sans-serif" font-size="14" font-weight="700" fill="#fff" text-anchor="middle">放学啦</text>
</svg>

悬浮提示就是 work_description / offduty_description 这俩字段,按当前时间自动切换上班/下班图标。


六、侧边栏访客欢迎信息

这块是我最满意的改动,每次有人进博客,侧边栏公告卡片里会显示「欢迎来自 XX 的朋友,距博主约 XXX km」。

效果

  • 国内访客:欢迎来自「重庆市 重庆市」的朋友!距博主约 XX km
  • 国外访客:带我去你的城市玩玩吧(高德查不到时降级显示)
  • IP 地址默认模糊处理,鼠标悬浮才清楚显示,保护隐私

实现思路

整个组件就一个 JS 文件 source/js/card-welcome.js,挂在公告卡片的 #welcome-info 容器里。流程:

1
访客进站 → 取公网IP → 高德IP定位 → 算与博主距离 → 渲染欢迎语

1. 公告卡片留容器

_config.anzhiyu.yml 的公告卡片配置里,content 末尾留一个空 div:

1
2
3
4
5
6
aside:
card_announcement:
enable: true
content: |
欢迎来到我的博客!
<div id="welcome-info"></div>

JS 会往这个 #welcome-info 里塞内容。

2. 引入脚本

inject.bottom 里引入:

1
2
3
inject:
bottom:
- <script src="/js/card-welcome.js"></script>

3. 配置 API 密钥

脚本顶部有个配置对象,关键是高德 Web 服务 API 密钥:

1
2
3
4
5
6
7
8
9
window.IP_CONFIG = {
API_KEY: '你的高德key', // 高德 Web 服务 API 密钥
BLOG_LOCATION: {
lng: 106.66, // 博主位置经度(重庆)
lat: 29.97, // 博主位置纬度(重庆)
},
CACHE_DURATION: 1000 * 60 * 60, // 缓存 1 小时
HOME_PAGE_ONLY: false, // false=所有页面都显示
};

高德 key 去 高德开放平台 申请,选「Web 服务」类型,免费的,每天 30 万次额度个人博客根本用不完。

4. 核心:拿 IP + 定位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const fetchIpData = async () => {
// 并行:获取 IP(展示用) + 高德定位(用请求方 IP 定位,最可靠)
const [ip, amapData] = await Promise.all([
fetchClientIp(),
(async () => {
const response = await fetch(
`https://restapi.amap.com/v3/ip?key=${encodeURIComponent(IP_CONFIG.API_KEY)}`
);
const data = await response.json();
if (data.status !== '1') throw new Error(data.info || '高德 API 错误');
return data;
})(),
]);

// 高德返回的是 rectangle(经纬度矩形),取中心点算坐标
const rect = amapData.rectangle || '';
let lng = 0, lat = 0;
if (rect) {
const pts = rect.split(';');
const [lng1, lat1] = pts[0].split(',').map(Number);
const [lng2, lat2] = pts[1].split(',').map(Number);
lng = (lng1 + lng2) / 2;
lat = (lat1 + lat2) / 2;
}
return { data: { lng, lat, prov: amapData.province, city: amapData.city }, ip };
};

公网 IP 用了三个源并发抢答,谁先返回用谁,避免单源挂了整个卡片就废:

1
2
3
4
5
const sources = [
'https://4.ipw.cn',
'https://api.ipify.org?format=json',
'https://api.ip.sb/ip',
];

5. 算距离

用 Haversine 公式算访客和博主的球面距离:

1
2
3
4
5
6
7
8
9
10
11
12
const calculateDistance = (lng, lat) => {
if (!lng || !lat) return '??';
const R = 6371; // 地球半径(km)
const rad = Math.PI / 180;
const dLat = (lat - IP_CONFIG.BLOG_LOCATION.lat) * rad;
const dLon = (lng - IP_CONFIG.BLOG_LOCATION.lng) * rad;
const a = Math.sin(dLat / 2) ** 2 +
Math.cos(IP_CONFIG.BLOG_LOCATION.lat * rad) *
Math.cos(lat * rad) *
Math.sin(dLon / 2) ** 2;
return Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
};

踩坑

  • 高德 API 返回的 province/city 有时是空数组:查不到定位时高德会返回 [] 而不是空字符串,直接用会显示「来自 [] 的朋友」。得判断一下:Array.isArray(amapData.province) ? '' : amapData.province
  • 国外 IP 查不到:高德 IP 定位只支持国内,国外 IP 返回的 province/city 是空的。这种情况降级显示「带我去你的城市玩玩吧」,不显示距离,体验好一点。
  • IP 混淆 HTTP/HTTPS:博客是 https 的,如果 IP 接口是 http 的会被浏览器拦截。三个源全换成 https 的才行。
  • 缓存:每次进站都调高德 API 太浪费,用 localStorage 缓存 1 小时,同一个访客一小时内刷新页面直接读缓存。
  • pjax 适配:主题开了 pjax,页面切换不会重新执行脚本。得监听 pjax:complete 或者用 MutationObserver 监听 #welcome-info 是否被重新插入。

七、侧边栏倒计时组件

公告卡片下面挂了个倒计时,显示今天/本周/本月/本年还剩多少,外加最近一个节假日倒计时。

效果

1
2
3
4
5
6
7
今日还剩  8 小时    [▓▓▓▓░░░░] 66%
本周还剩 3 天 [▓▓▓░░░░░] 42%
本月还剩 12 天 [▓▓░░░░░░] 60%
本年还剩 153 天 [▓▓▓▓░░░░] 58%

距国庆还有 45 天
2026-10-01

实现

两个文件:source/js/countdown.js(逻辑)+ source/css/countdown.css(样式),通过 source/_data/widget.yml 挂到侧边栏:

1
2
3
4
5
6
7
# source/_data/widget.yml
top:
- class_name: card-countdown
id_name: card-countdown
name: 倒计时
icon: anzhiyu-icon-hourglass-start
html: '<div id="countdown-timer"></div>'

主题会自动把这个 html 渲染进一个带图标的卡片容器,我只要管往 #countdown-timer 里塞内容就行。

1. 算今日/本周/本月/本年剩余

纯 JS 计算,不依赖任何接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function calcProgress() {
var now = new Date();

// 今日剩余小时
var dayPassed = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds();
var dayRemaining = 86400 - dayPassed;

// 本周剩余天(周一为起点)
var wd = now.getDay();
var weekPassed = (wd === 0 ? 6 : wd - 1);
var weekRemaining = 6 - weekPassed;

// 本月剩余天
var monthTotal = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
var monthRemaining = monthTotal - now.getDate() + 1;

// 本年剩余天
var yearStart = new Date(now.getFullYear(), 0, 1);
var yearEnd = new Date(now.getFullYear() + 1, 0, 1);
var yearRemainingSec = (yearEnd - yearStart) / 1000 - (now - yearStart) / 1000;

return {
day: { remaining: Math.floor(dayRemaining / 3600), percentage: (dayPassed / 86400) * 100, unit: "小时" },
week: { remaining: weekRemaining, percentage: ((weekPassed + 1) / 7) * 100, unit: "天" },
month: { remaining: monthRemaining, percentage: ((now.getDate() - 1) / monthTotal) * 100, unit: "天" },
year: { remaining: Math.floor(yearRemainingSec / 86400), percentage: ((now - yearStart) / (yearEnd - yearStart)) * 100, unit: "天" },
};
}

2. 节假日数据

节假日不用自己维护,用 NateScarlet/holiday-cn 这个仓库,它自动同步国务院每年的放假安排,通过 jsDelivr 直接拉 JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var HOLIDAY_CN_BASE = "https://cdn.jsdelivr.net/gh/NateScarlet/holiday-cn@master/";

function fetchNextHoliday(cb) {
var year = new Date().getFullYear();
var urls = [HOLIDAY_CN_BASE + year + ".json"];
// 12月预取下一年
if (new Date().getMonth() >= 10) urls.push(HOLIDAY_CN_BASE + (year + 1) + ".json");

var allDays = [];
urls.forEach(function (url) {
fetch(url).then(r => r.json()).then(data => {
allDays = allDays.concat(data.days);
}).then(() => {
// 合并完所有年份后回调
cb(resolveNextHoliday(allDays, new Date()));
});
});
}

3. 找最近一个假期

数据里 isOffDay: true 的就是放假日,但要把连续的放假日归并成一个假期,取第一天作为倒计时目标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function resolveNextHoliday(days, now) {
// 按日期排序
days.sort((a, b) => new Date(a.date) - new Date(b.date));

// 把连续的 isOffDay=true 归为一段
var groups = [], current = null;
for (var i = 0; i < days.length; i++) {
var d = days[i];
if (!d.isOffDay) continue;
var t = new Date(d.date + "T00:00:00").getTime();
if (current && t - current.endTime === 86400000) {
current.endTime = t; // 连续,合并
} else {
current = { startTime: t, endTime: t, days: [d] };
groups.push(current);
}
}

// 找现在之后的第一个假期
for (var j = 0; j < groups.length; j++) {
if (groups[j].startTime >= now.getTime()) {
var diffDays = Math.ceil((groups[j].startTime - new Date(now.getFullYear(), now.getMonth(), now.getDate())) / 86400000);
return { name: groups[j].days[0].name, date: groups[j].days[0].date, diffDays: diffDays };
}
}
return null;
}

踩坑

  • 节假日在 JSON 里是按天列的,不是按假期:国庆 7 天会有 7 条记录。一开始我没合并,显示「距国庆还有 7 天」「距国庆还有 6 天」……一串,蠢死了。归并成一段取第一天才对。
  • 跨年问题:12 月查下一年的 JSON,不然 1 月初查本年会查不到春节(春节数据在上一年的 JSON 里)。
  • pjax 刷新:跟访客卡片一样,监听 pjax:complete 重新渲染。另外用 setInterval 每分钟刷新一次进度条,不然进度条永远不动。

八、友链朋友圈后端

主题自带的「朋友圈」页面能聚合所有友链博客的最新文章,但要自己部署后端抓取。

为啥要搞

手动点开每个友链博客看更新太累,朋友圈把大家的 atom.xml 抓过来按时间排序,一屏看完,爽。

部署后端

用的是 HexoCircle 的后端(Vercel 部署版),fork 一份改配置就行。关键配置项是友链列表,从博客的 /link/ 页面自动抓。

后端跑起来后会暴露一个 API,我部署在 https://friend.346247.xyz/

主题配置

_config.anzhiyu.yml 里:

1
2
3
4
5
# 朋友圈配置(抓取友链博客的 atom.xml 文章)
friends_vue:
enable: true
vue_js: /js/fcircle_vue.js
apiurl: https://friend.346247.xyz/
  • vue_js:前端渲染脚本,放本地 source/js/fcircle_vue.js,避免每次都从 CDN 拉
  • apiurl:后端地址,末尾要带斜杠,不带的话前端拼接 URL 会 404

踩坑

  • apiurl 斜杠:跟 Qexo 的配置一样,这里必须带斜杠,前端是字符串拼接不是 URL 对象,apiurl + "api/..." 不带斜杠就变成 friend.346247.xyzapi/...
  • Vercel 函数超时:免费版函数执行上限 10 秒,友链一多抓不完。解决办法是后端定时抓取缓存到 KV,前端只读缓存数据,不实时抓。
  • 友链格式要求:后端靠解析友链页面的 HTML 拿博客地址,友链的 atom.xml 路径得规范(一般是 /atom.xml)。有些友链博客没开 RSS 或者路径不对,就抓不到,会出现在「无法抓取」列表里,正常现象。
  • CORS:后端 API 要允许博客域名跨域,不然前端 fetch 会被浏览器拦。Vercel 函数响应头加 Access-Control-Allow-Origin: * 最省事。

最后说一句

这一轮折腾下来最大的感受是:能改配置就别改源码,能用主题变量就别写死颜色

  • top_img: false 一行配置搞定标题的事,别用 CSS 硬藏
  • footerBar.linkList 加链接,别去改 footer.pug 模板
  • 颜色用 var(--anzhiyu-xxx),浅色深色自动适配,省得自己写两套

唯一动源码的就是补回误删的组件(比如音乐馆切换按钮),这种改动记得记一笔,下次主题升级好对照着改回去。

至于访客信息和倒计时这种自写组件,关键是把数据源选对、把缓存做好、把 pjax 适配好,剩下的就是体力活了。

大概就这样,博客终于清爽了,收工。