anzhiyu主题侧边栏添加访客信息

前言

博客是写在网上的日记,但总有人悄悄路过。

侧边栏那个小小的卡片,它会尝试获取你的大致位置(不精确到门牌号,只是城市和距离),然后告诉博主:你离我多少公里,此刻是早安、午安还是晚安。

代码不多,但花了不少心思:多 API 降级、PJAX 兼容、距离计算、时间问候……甚至为了不泄露隐私,全程只使用公开的 IP 地理服务。

如果你是安知鱼主题的用户,可以直接照搬;如果你用其他主题,改改选择器也能用。

正文

文件路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
blog/
├── themes/
│ └── anzhiyu/
│ ├── layout/
│ │ ├── includes/
│ │ │ └── widget/
│ │ │ └── card_welcome.pug
│ │ │ └── index.pug
├── source/
│ └── cdn/
│ └── css/
│ │ └── welcome.css
│ └── js/
│ └── welcome.js
└── ...

新建card_welcome.pug

1
2
3
4
5
6
7
8
.card-widget.card-welcome
.card-content
.item-headline
i.fas.fa-map-marker-alt
#welcome-info(style="min-height: 100px; display: flex; align-items: center; justify-content: center;")
.loading-spinner
i.fas.fa-spinner.fa-spin
| 正在定位...

修改index.pug

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#aside-content.aside-content
//- post
if is_post()
- const tocStyle = page.toc_style_simple
- const tocStyleVal = tocStyle === true || tocStyle === false ? tocStyle : theme.toc.style_simple
if showToc && tocStyleVal
.sticky_layout
include ./card_post_toc.pug
else
!=partial('includes/widget/card_author', {}, {cache: true})
!=partial('includes/widget/card_announcement', {}, {cache: true})

// 添加访客信息卡片(公告下面)
!=partial('includes/widget/card_welcome', {}, {cache: true})

!=partial('includes/widget/card_weixin', {}, {cache: true})
!=partial('includes/widget/card_top_self', {}, {cache: true})
.sticky_layout
if showToc
include ./card_post_toc.pug
!=partial('includes/widget/card_recent_post', {}, {cache: true})
!=partial('includes/widget/card_ad', {}, {cache: true})
else
//- page
!=partial('includes/widget/card_author', {}, {cache: true})
!=partial('includes/widget/card_announcement', {}, {cache: true})

// 添加访客信息卡片(公告下面)
!=partial('includes/widget/card_welcome', {}, {cache: true})

!=partial('includes/widget/card_weixin', {}, {cache: true})
!=partial('includes/widget/card_top_self', {}, {cache: true})
!=partial('includes/widget/card_categories', {}, {cache: true})

.sticky_layout
if showToc
include ./card_post_toc.pug
.card-widget
!=partial('includes/widget/card_ad', {}, {cache: true})
!=partial('includes/widget/card_tags', {}, {cache: true})
!=partial('includes/widget/card_archives', {}, {cache: true})
!=partial('includes/widget/card_webinfo', {}, {cache: true})
!=partial('includes/widget/card_bottom_self', {}, {cache: true})

新建welcome.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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
// 使用 IIFE 避免全局污染
(function() {
// 标记是否已经获取过 IP 信息(当前会话内只获取一次即可)
let ipInfoLoaded = false;
let cachedHTML = null;

const fetchIpInfo = async () => {
const welcomeInfo = document.querySelector('#welcome-info');
if (!welcomeInfo) return;

// 如果已经加载过,直接恢复缓存的内容
if (ipInfoLoaded && cachedHTML) {
welcomeInfo.innerHTML = cachedHTML;
return;
}

// 显示加载状态
welcomeInfo.innerHTML = '<div class="loading"><i class="fas fa-spinner fa-spin"></i> 定位中...</div>';

// 备用 API 列表(ipinfo.io 在国内可能被拦截,放在后面)
const apis = [
{
url: 'https://api.ip.sb/geoip',
parser: (d) => ({
country: d.country,
region: d.region,
city: d.city,
loc: d.latitude && d.longitude ? `${d.latitude},${d.longitude}` : null
})
},
{
url: 'https://ipapi.co/json/',
parser: (d) => ({
country: d.country_code,
region: d.region,
city: d.city,
loc: d.latitude && d.longitude ? `${d.latitude},${d.longitude}` : null
})
},
{
url: 'https://ipinfo.io/json',
parser: (d) => ({
country: d.country,
region: d.region,
city: d.city,
loc: d.loc
})
}
];

let data = null;

for (const api of apis) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);

const res = await fetch(api.url, {
signal: controller.signal,
headers: { 'Accept': 'application/json' }
});
clearTimeout(timeout);

if (res.ok) {
const raw = await res.json();
data = api.parser(raw);
break;
}
} catch (e) {
continue;
}
}

// 渲染内容
if (data) {
const { country, region, city, loc } = data;
const [lat, lng] = loc ? loc.split(',').map(Number) : [];

const myLat = 31.707754;
const myLng = 119.825873;
const dist = (lat && lng) ? calculateDistance(lng, lat, myLng, myLat) : null;

const location = country === "CN" || country === "China"
? `${region || ''} ${city || ''}`.trim()
: (country || '地球');

cachedHTML = `
<div class="welcome-content">
<p>欢迎来自 <span class="location">${location || '神秘星球'}</span> 的道友 💖</p>
${dist ? `<p>距博主约 <span class="distance">${dist}</span> 公里</p>` : ''}
<p class="greeting">${getTimeGreeting()}</p>
</div>
`;
ipInfoLoaded = true;
welcomeInfo.innerHTML = cachedHTML;
} else {
cachedHTML = `
<div class="welcome-content">
<p>🎉 欢迎来到我的博客!</p>
<p class="greeting">${getTimeGreeting()}</p>
</div>
`;
ipInfoLoaded = true;
welcomeInfo.innerHTML = cachedHTML;
}
};

const calculateDistance = (lng1, lat1, lng2, lat2) => {
if (!lat1 || !lng1 || !lat2 || !lng2) return null;
const R = 6371;
const rad = Math.PI / 180;
const dLat = (lat2 - lat1) * rad;
const dLon = (lng2 - lng1) * rad;
const a = Math.sin(dLat/2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLon/2) ** 2;
return Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)));
};

const getTimeGreeting = () => {
const hour = new Date().getHours();
if (hour < 6) return "It's late, pay attention to rest 🌙";
if (hour < 12) return "Good morning, start your day. ☀️";
if (hour < 18) return "Good afternoon. Good work. ☕";
return "Good evening. May you have a good mood. 🌆";
};

// ===== 初始化策略 =====

// 1. 首次加载
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fetchIpInfo);
} else {
fetchIpInfo();
}

// 2. 安知鱼主题 PJAX 事件(关键)
// 安知鱼使用 pjax:complete,但 footer 不刷新,所以需要手动恢复内容
document.addEventListener('pjax:complete', () => {
// PJAX 完成后,检查 welcome-info 是否存在
setTimeout(() => {
const welcomeInfo = document.querySelector('#welcome-info');
if (welcomeInfo) {
if (cachedHTML && ipInfoLoaded) {
// 已经有缓存,直接恢复
welcomeInfo.innerHTML = cachedHTML;
} else {
// 首次或缓存丢失,重新获取
fetchIpInfo();
}
}
}, 100); // 延迟确保 DOM 已更新
});

// 3. 浏览器前进/后退按钮
window.addEventListener('popstate', () => {
setTimeout(() => {
const welcomeInfo = document.querySelector('#welcome-info');
if (welcomeInfo && cachedHTML && ipInfoLoaded) {
welcomeInfo.innerHTML = cachedHTML;
}
}, 100);
});

})();

新建welcome.css

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
29
30
31
32
33
34
35
36
37
38
39
40
41
.card-welcome .item-headline i {
color: #4dabf7;
}

.card-welcome #welcome-info {
color: var(--anzhiyu-fontcolor);
text-align: center;
padding: 15px;
}

.card-welcome #welcome-info .loading {
font-size: 14px;
}

.card-welcome #welcome-info .loading i {
margin-right: 8px;
}

.card-welcome .welcome-content .location {
color: #ffd700;
font-weight: bold;
font-size: 16px;
}

.card-welcome .welcome-content .distance {
color: #4dabf7;
font-weight: bold;
}

.card-welcome .welcome-content .ip-address {
font-size: 12px;
opacity: 0.7;
margin-top: 5px;
}

.card-welcome .welcome-content .greeting {
font-size: 14px;
line-height: 1.5;
opacity: 0.95;
letter-spacing: 0.3px;
}

_config.anzhiyu.yml 调用自定义 welcome.css和welcome.js

1
2
3
4
5
6
7
8
9
10
11
12
inject:
head:
# 自定义css
...
- <link rel="stylesheet" href="/cdn/css/welcome.css">
...

bottom:
# 自定义js
...
- <script defer src="/cdn/js/welcome.js"></script>
...