效果预览

与本站首页的“欢迎来访者-侧边栏卡片”同款

添加方法

创建页面文件

[blogroot]/themes/butterfly/layout/includes/widget中新建card_welcome.pug文件,并添加如下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.card-widget.card-welcome
.item-headline
i.fa.fa-user
span 欢迎
#welcome-info
.error-message
p
span.emoji 😥
br
span.text 遇到了问题
br
span.text 位置 API 请求错误
br
span.text 请等待博主修复呀🤗~
script(src='/js/card_welcome.js')

引入页面文件

将该结构插入到对应的位置,可以在当前文件夹[blogroot]/themes/butterfly/layout/includes/widget中的index.pug找到对应结构,如下:

  • 修改前
1
2
3
4
5
else
//- page
!=partial('includes/widget/card_author', {}, {cache: true})
!=partial('includes/widget/card_announcement', {}, {cache: true})
!=partial('includes/widget/card_top_self', {}, {cache: true})
  • 修改后
1
2
3
4
5
6
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_top_self', {}, {cache: true})

插入位置可以根据顺序自行调整

创建脚本文件

[blogroot]/source/js中新建card-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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
// 使用 fetch 从 IP 定位 API 获取用户位置
function fetchIpLocation() {
return fetch("https://ip-api.o0w0b.top/?lang=zh-CN")
.then(response => response.json())
.then(data => {
return {
ip: data.query, // IP 地址
data: {
country: data.country, // 国家
prov: data.regionName, // 省/州
city: data.city, // 城市
district: data.district, // 区
lat: data.lat, // 纬度
lon: data.lon // 经度
}
};
})
.catch(err => {
console.error("获取 IP 定位失败:", err);
return null;
});
}

// 计算两点距离函数(用 Haversine)
function getDistance(lon1, lat1, lon2, lat2) {
function toRad(d) { return d * Math.PI / 180; }
const R = 6371; // 地球半径 km
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return (R * c).toFixed(2); // 返回保留两位小数的公里数
}

// 显示欢迎信息
function showWelcome(ipLocation) {
if (!ipLocation || !ipLocation.data) {
console.error('ipLocation data is not available.');
return;
}

let lon = ipLocation.data.lon;
let lat = ipLocation.data.lat;
let dist = getDistance(126.904, 37.0849, lon, lat);
let pos = ipLocation.data.country;
let ip = ipLocation.ip;
let posdesc;

// 以下的代码需要根据新API返回的结果进行相应的调整
switch (ipLocation.data.country) {
case "日本":
posdesc = "こんにちは!<br>日本樱花盛开,景色如画";
break;
case "美国":
posdesc = "Hello! <br>美国大地辽阔,城市风光各异";
break;
case "英国":
posdesc = "Hello! <br>伦敦塔桥与泰晤士河相映成景";
break;
case "俄罗斯":
posdesc = "Привет! <br>俄罗斯广袤土地,冬日雪景迷人";
break;
case "法国":
posdesc = "Bonjour! <br>法国乡村与巴黎街景交相辉映";
break;
case "德国":
posdesc = "Hallo! <br>德国古堡林立,啤酒节热闹非凡";
break;
case "澳大利亚":
posdesc = "G’day! <br>澳大利亚海岸与内陆风光壮丽";
break;
case "加拿大":
posdesc = "Hey! <br>加拿大枫叶红遍,湖光山色宜人";
break;
case "韩国":
posdesc = "안녕하세요! <br>韩国泡菜、辣炒年糕色香味俱全";
break;
case "中国":
pos = ipLocation.data.prov + " " + ipLocation.data.city + " " + ipLocation.data.district;
switch (ipLocation.data.prov) {
case "北京市":
posdesc = "北京故宫庄严,天安门广场宏伟";
break;
case "天津市":
posdesc = "天津海河蜿蜒,古文化街风情浓";
break;
case "河北省":
posdesc = "河北长城蜿蜒,群山连绵";
break;
case "山西省":
posdesc = "山西古建筑众多,历史厚重";
break;
case "内蒙古自治区":
posdesc = "内蒙古草原辽阔,风吹草低见牛羊";
break;
case "辽宁省":
posdesc = "辽宁沿海风光秀丽,城市与海岸交错";
break;
case "吉林省":
posdesc = "吉林冬季雪景壮丽,松花江蜿蜒";
break;
case "黑龙江省":
posdesc = "黑龙江冰雪世界,松花江静谧";
break;
case "上海市":
posdesc = "上海外滩璀璨,高楼林立";
break;
case "江苏省":
switch (ipLocation.data.city) {
case "南京市":
posdesc = "南京古都,秦淮河夜色迷人";
break;
case "苏州市":
posdesc = "苏州园林精美,水乡小桥流水";
break;
default:
posdesc = "江苏江南水乡,河流纵横";
break;
}
break;
case "浙江省":
switch (ipLocation.data.city) {
case "杭州市":
posdesc = "杭州西湖烟雨,山水如画";
break;
default:
posdesc = "浙江山水秀丽,文化底蕴深厚";
break;
}
break;
case "河南省":
switch (ipLocation.data.city) {
case "郑州市":
posdesc = "郑州古今交融,城中绿地广阔";
break;
case "信阳市":
posdesc = "信阳茶园连片,绿意盎然";
break;
case "南阳市":
posdesc = "南阳河流纵横,历史遗迹丰富";
break;
case "驻马店市":
posdesc = "驻马店平原开阔,田园景色宜人";
break;
case "开封市":
posdesc = "开封古都,古建筑保存完好";
break;
case "洛阳市":
posdesc = "洛阳牡丹盛开,花城景色优美";
break;
default:
posdesc = "河南自然与历史景观丰富";
break;
}
break;
case "安徽省":
posdesc = "安徽黄山云海,奇峰异石层出";
break;
case "福建省":
posdesc = "福建山海相连,海岸风光秀丽";
break;
case "江西省":
posdesc = "江西庐山高耸,江河环绕";
break;
case "山东省":
posdesc = "山东泰山雄伟,沿海城市风光美";
break;
case "湖北省":
switch (ipLocation.data.city) {
case "黄冈市":
posdesc = "黄冈江河交错,山水相依";
break;
case "武汉市":
posdesc = "武汉江滩开阔,城市景色壮丽";
break;
default:
posdesc = "湖北山水秀丽,河湖众多";
break;
}
break;
case "湖南省":
posdesc = "湖南岳麓山秀美,江河湖泊环绕";
break;
case "广东省":
switch (ipLocation.data.city) {
case "广州市":
posdesc = "广州珠江两岸,城市天际线壮观";
break;
case "深圳市":
posdesc = "深圳高楼林立,现代都市景观丰富";
break;
default:
posdesc = "广东沿海城市众多,风光秀丽";
break;
}
break;
case "广西壮族自治区":
posdesc = "桂林山水甲天下,漓江蜿蜒";
break;
case "海南省":
posdesc = "海南海岸线长,沙滩与蓝天相映";
break;
case "四川省":
posdesc = "四川群山环绕,江河纵横";
break;
case "贵州省":
posdesc = "贵州喀斯特地貌奇特,山水秀丽";
break;
case "云南省":
posdesc = "云南高山湖泊众多,彩云之南美景";
break;
case "西藏自治区":
posdesc = "西藏高原辽阔,雪山与草原交错";
break;
case "陕西省":
posdesc = "陕西古城众多,历史遗迹丰富";
break;
case "甘肃省":
posdesc = "甘肃戈壁广阔,丝路文化厚重";
break;
case "青海省":
posdesc = "青海湖碧水环山,景色壮丽";
break;
case "宁夏回族自治区":
posdesc = "宁夏黄河蜿蜒,沙漠与绿洲交错";
break;
case "新疆维吾尔自治区":
posdesc = "新疆天山雪峰,高原风光辽阔";
break;
case "台湾省":
posdesc = "台湾岛屿纵横,山海景色独特";
break;
case "香港特别行政区":
posdesc = "香港维多利亚港,城市天际线迷人";
break;
case "澳门特别行政区":
posdesc = "澳门滨海风光与历史建筑交相辉映";
break;
default:
posdesc = "欢迎欢迎!";
break;
}
break;
default:
posdesc = "Hello! 欢迎来自国外的朋友";
break;
}

// 根据本地时间切换欢迎语
let timeChange;
let date = new Date();
if (date.getHours() >= 5 && date.getHours() < 11)
timeChange = "<span>🌅 早安!新的一天开始啦,记得吃早餐哦~</span>";
else if (date.getHours() >= 11 && date.getHours() < 13)
timeChange = "<span>🍴 中午好!吃点好吃的补充能量吧~</span>";
else if (date.getHours() >= 13 && date.getHours() < 17)
timeChange = "<span>☕ 下午好!来杯茶,继续加油吧~</span>";
else if (date.getHours() >= 17 && date.getHours() < 19)
timeChange = "<span>🌇 傍晚好!今天辛苦了,放松一下吧~</span>";
else if (date.getHours() >= 19 && date.getHours() < 24)
timeChange = "<span>🌙 晚上好!属于自己的时间到啦,随心享受吧~</span>";
else
timeChange = "<span>🌌 夜深了,早点休息,明天继续加油!</span>";

let welcomeInfoElement = document.getElementById("welcome-info");

if (welcomeInfoElement) {
welcomeInfoElement.innerHTML = `
<p>Hey~ 来自 <span class="user-location">${pos}</span> 的来访者!😝</p>
<p>${posdesc} 🏞️</p>
<!-- <p>目前距博主约 <span class="distance">${dist}</span> 公里!</p> -->
<p>经度:<span class="distance">${lon}</span><br>纬度:<span class="distance">${lat}</span></p>
<p>网络 IP:<span class="ip-address">${ip}</span></p>
<p class="time-greeting">${timeChange}</p>
`;
} else {
console.log("Pjax无法获取元素");
}
}

// 判断是否存在 "welcome-info" 元素
function isWelcomeInfoAvailable() {
let welcomeInfoElement = document.getElementById("welcome-info");
return welcomeInfoElement !== null;
}

// Pjax 完成后调用的处理函数
function handlePjaxComplete(ipLocation) {
if (isWelcomeInfoAvailable()) {
showWelcome(ipLocation);
}
}

// 加载时调用
function onLoad() {
fetchIpLocation().then(ipLocation => {
if (isWelcomeInfoAvailable()) {
showWelcome(ipLocation);
}
document.addEventListener("pjax:complete", () => handlePjaxComplete(ipLocation));
});
}

// 绑定 window.onload 事件
window.onload = onLoad;

如果要显示访客与你的距离,找到如下内容,并取消注释

1
<!-- <p>目前距博主约 <span class="distance">${dist}</span> 公里!</p> -->

计算距离部分,要修改为自己当前位置的经度、纬度(原文件中,经度是126.904、纬度是37.0849)

1
let dist = getDistance(126.904, 37.0849, ipLocation.data.lon, ipLocation.data.lat);

创建样式文件

[blogroot]/themes/source/css/_layout中新建card-welcome.styl文件,并添加如下内容

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
[data-theme=light]
--welcome-bg: rgba(230,230,235,0.8)
[data-theme=dark]
--welcome-bg: rgba(25,35,60,0.8)
#aside-content
.card-widget.card-welcome
padding: 20px 24px
.item-headline
margin: 0px
.item-content
margin: 0px
#welcome-info
text-align center
background-color: var(--welcome-bg)
border-radius: 8px
padding: 12px
.error-message
height: 200px
display: flex
justify-content: center
align-items: center
p
text-align: center
.emoji
font-size: 40px
.text
font-size: 16px
color: var(--text-color)
p
margin: 0px
.user-location
font-size: 16px
color: var(--default-bg-color)
font-weight: bold
.distance
font-size: 16px
color: var(--default-bg-color)
font-weight: bold
.ip-address
filter: blur(5px)
font-size: 16px
color: var(--default-bg-color)
transition: filter 0.5s
&:hover
filter: none
font-weight: bold
.time-greeting
font-size: 15px
color: var(--text-color)
font-weight: bold
margin-top: 8px

自建 API(可选)

起因

ip-api.com 的 HTTPS 端点只对「商业 Key」开放,免费用户直接走 HTTPS 会被 403 拒绝;免费版只能用明文 HTTP

解决办法

用 Cloudflare Worker 反向代理,Worker 自带 HTTPS 证书

准备工作

项目 说明
账号 免费 Cloudflare 账户
域名 任意已接入 Cloudflare 的域名(可选,后期绑定)

创建 Worker

  1. 登录 Cloudflare Dashboard
  2. 侧边栏 Workers → 创建服务 → 名称随意(例如 ip-api)→ 创建
  3. 点击右上角编辑代码,清空默认代码,替换为下方内容(二选一):
  • 不加白名单(任何网站都能用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
addEventListener('fetch', (event) => {
event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
// 1. 获取客户端 IP(Cloudflare 自带)
const clientIP = request.headers.get('CF-Connecting-IP');
// 2. 获取浏览器传来的查询参数 ?lang=zh-CN、?ip=8.8.8.8 等
const search = new URL(request.url).search;
// 3. 拼装 ip-api.com 的 HTTP 地址
const upstream = `http://ip-api.com/json/${clientIP}${search}`;
// 4. 转发并回源
return fetch(upstream);
}
  • 加白名单(只允许指定的网站使用)
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
addEventListener('fetch', (event) => {
// 当浏览器/客户端发起任何请求时,把处理权交给 handleRequest
event.respondWith(handleRequest(event.request));
});

async function handleRequest(request) {
/* ---------- 1. 取真实客户端 IP ---------- */
// Cloudflare 边缘会自动附加 "CF-Connecting-IP" 头,就是用户公网 IP
const clientIP = request.headers.get('CF-Connecting-IP');

/* ---------- 2. 透传查询参数 ---------- */
// 浏览器传来的 ?lang=zh-CN、?ip=8.8.8.8 等
const search = new URL(request.url).search;

/* ---------- 3. 拼装上游 URL ---------- */
// 拼装 ip-api.com 的 HTTP 地址
const upstream = `http://ip-api.com/json/${clientIP}${search}`;

/* ---------- 4. 获取数据 ---------- */
const res = await fetch(upstream);

/* ---------- 5. CORS 白名单逻辑 ---------- */
// 只允许这些来源调用;端口、协议、子域都要完全一样
const ALLOWED = [
'http://localhost:4000',
'https://blog.o0w0b.top'
];

// 默认响应头(不含 CORS)
const headers = {
'Content-Type': 'application/json',
};

// 取出浏览器发来的 Origin
const origin = request.headers.get('Origin');

// 在白名单就回显,不在就 403 拒绝
if (ALLOWED.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
} else {
return new Response('CORS not allowed', { status: 403 });
}

/* ---------- 6. 把上游数据+自定义头返回给浏览器 ---------- */
return new Response(res.body, {
status: res.status, // 上游状态码
statusText: res.statusText, // 对应的状态文字
headers // 自定义的响应头
});
}

修改这里面的内容就可以

1
2
3
4
5
// 只允许这些来源调用;端口、协议、子域都要完全一样
const ALLOWED = [
'http://localhost:4000',
'https://blog.o0w0b.top'
];
  1. 点击右上角部署,Workers 的访问链接 https://xxx.xxx.workers.dev 就是 API 地址

绑定自己的域名(可选)

网上有很多 Cloudflare Worker 绑定域名的教程