本文由 ChatGPT 5.4 参与内容协同撰写与整理,经由 OpenClaw 自动化构建发布流程生成并发布。全文已通过火山大模型内容安全与合规性双重审核。
本文包含 AI 辅助生成与整理内容,示例代码与说明已经过人工校对,但仍可能因主题版本、接口返回格式或使用环境不同而产生差异。
正式使用前,请务必结合自己的站点环境自行测试与调整。
前言
很多博客侧边栏都会放一句“欢迎来访”,但如果只是静态文字,其实很快就看腻了。
这次我们来做一个稍微有意思一点的版本:
- 顶部先放一组固定欢迎信息。
- 下方根据访客 IP 获取地区信息。
- 自动显示“你来自哪里”“离博主多远”。
- 再根据当前时间和地区,给出一句更有温度的问候语。
- 还可以点击展开,查看 IP、地址和运营商。
如果你喜欢那种“看起来像是在认真接待访客”的侧边栏,这种模块会很合适。
- 想给博客侧边栏加一点互动感的人。
- 想直接复制一份代码然后按自己需求改的人。
- 想顺手学一下原生 HTML、CSS、JavaScript 小模块写法的人。
下面示例里的昵称、邮箱、API key 和坐标都已经换成了可公开发布的占位写法。
复制后只要把这几项改成你自己的信息,就可以直接使用。
最终能实现什么
写完以后,这个侧边栏模块会有三层体验:
- 页面一加载,就先显示一组固定欢迎信息。
- 动态区域先转圈加载,再请求访客地区数据。
- 成功后渲染出欢迎卡片,失败时也不会整块空白。
卡片里会展示:
- 来访地区
- 与博主的大致距离
- 当前时间段问候语
- 一句按地区生成的提示文案
- 可展开的 IP、地址、运营商详情
效果图

安装方法
方法一:安和鱼博客系统适配
如果你使用的是安和鱼(AnHeYu)博客系统,可以将本文提供的完整代码直接添加到后台侧边栏配置中。
配置步骤
- 登录安和鱼博客系统后台,通常地址为
你的域名/admin - 进入 系统管理 → 系统设置 → 外观配置 → 侧边栏
- 将本文后面的“完整可复制版本”代码粘贴到侧边栏配置中
- 保存配置并刷新前台页面
- 顶部昵称
- 邮箱链接
API_KEYBLOG_LAT和BLOG_LNG
先看整体思路
这个模块其实并不复杂,本质上就是三部分拼起来:
| 部分 | 作用 |
|---|---|
| CSS | 控制欢迎区、加载动画、按钮和详情面板样式 |
| HTML | 放固定欢迎内容和动态挂载区域 |
| JavaScript | 请求接口、生成问候语、计算距离、渲染卡片 |
你可以把它理解成一个“小型单文件组件”。
- 页面加载
先显示顶部固定欢迎内容,同时在动态区域放一个加载动画。
- 请求数据
向 IP 接口发起请求,拿到国家、省份、城市、经纬度和运营商。
- 处理文案
根据时间和地区,生成对应的欢迎语。
- 渲染卡片
把地区、距离、提示文案和详情按钮一起输出到页面。
- 用户交互
点击按钮后展开或收起来访详情。
第一步:写静态欢迎区
侧边栏里最好先有一块不依赖接口的内容。这样做有两个好处:
- 页面刚打开时不会显得空。
- 即使接口请求失败,顶部区域也还能正常显示。
先写这样一段 HTML:
<div class="welcome-section">
<div class="section-title">
<span class="icon">👤</span>
<span>欢迎来访者</span>
</div>
<div class="welcome-list">
<div class="welcome-list-item">
<span class="emoji">👋🏻</span>
<span>Hi,我是站长,欢迎你!</span>
</div>
<div class="welcome-list-item">
<span class="emoji">❓</span>
<span>如有问题欢迎评论区交流!</span>
</div>
<div class="welcome-list-item">
<span class="emoji">😫</span>
<span>页面异常?试试 <span class="kbd">Ctrl</span>+<span class="kbd">F5</span></span>
</div>
<div class="welcome-list-item">
<span class="emoji">📧</span>
<span>联系我:<a href="mailto:your-email@example.com">发送邮件 🚀</a></span>
</div>
</div>
</div>
<div class="section-divider"></div>
<div id="welcome-info">
<div class="loading-spinner"></div>
</div>
这里最关键的是 #welcome-info。
它就是后面 JavaScript 动态渲染内容的挂载点。
#welcome-info 可以理解成一个“预留出来的插槽”。第二步:把基础样式铺好
这个模块的样式重点不是炫技,而是“看起来清楚、舒服、有层次”。
欢迎区样式
欢迎区负责展示顶部固定内容,所以样式要轻一点:
.welcome-section {
margin-bottom: 16px;
}
.welcome-section .section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: var(--anzhiyu-fontcolor);
margin-bottom: 12px;
}
.welcome-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.welcome-list-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--anzhiyu-fontcolor);
line-height: 1.5;
}
这里用了主题变量:
--anzhiyu-fontcolor--anzhiyu-theme--anzhiyu-secondbg--anzhiyu-card-border
如果你不是在同一套主题里使用,就需要自己替换成固定颜色或你自己的变量。
很多人复制代码后,发现结构正常但颜色全不对,原因通常不是 HTML 或 JS 出错,而是主题变量对不上。
加载动画和卡片样式
动态区域里最先出现的是转圈动画,接口回来后才会被真正内容替换:
#welcome-info {
user-select: none;
}
.loading-spinner {
width: 32px;
height: 32px;
margin: 30px auto;
border: 3px solid var(--anzhiyu-secondbg);
border-top-color: var(--anzhiyu-theme);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.detail-panel {
background: var(--anzhiyu-secondbg);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
}
按钮、详情面板、提示文字,也都是围绕“侧边栏小卡片”这个定位来写的,所以尺寸控制得比较克制。
第三步:准备配置项
脚本部分先不要急着写逻辑,第一步先把配置项列出来:
const CONFIG = {
API_KEY: 'YOUR_API_KEY',
API_URL: 'https://v1.nsuuu.com/api/ipip',
BLOG_LAT: 39.9042,
BLOG_LNG: 116.4074
};
每一项分别代表:
API_KEY:IP 接口的访问密钥API_URL:IP 查询接口地址BLOG_LAT:博主所在地纬度BLOG_LNG:博主所在地经度
其中前两项决定“能不能查到访客数据”,后两项决定“能不能算出和博主的距离”。
这四项里:
API_KEY一定要换成你自己的BLOG_LAT和BLOG_LNG最好改成你自己的坐标- 顶部欢迎区昵称和邮箱也建议一起改掉
本文示例使用的是鸭梨 API。API_KEY 可前往 https://api.nsuuu.com/ 申请,申请完成后填入配置项即可使用。
因为卡片里会显示“你目前距博主约 xx 公里”。
这个距离不是接口直接给的,而是用访客经纬度和你的坐标算出来的。
第四步:先处理状态显示
在写主逻辑之前,最好先把“加载中”和“错误态”准备好。
const welcomeEl = document.getElementById('welcome-info');
if (!welcomeEl) return;
const showLoading = () => {
welcomeEl.innerHTML = '<div class="loading-spinner"></div>';
};
const showError = (msg) => {
welcomeEl.innerHTML = `
<div class="error-message">
<div class="error-icon">😕</div>
<p>${msg}</p>
<p>点击 <i id="retry-button">↻ 重试</i></p>
</div>
`;
document.getElementById('retry-button')?.addEventListener('click', () => {
showLoading();
fetchAndShow();
});
};
这两个函数的意义很简单:
showLoading():请求时显示转圈动画showError():请求失败时显示错误提示,并提供重试入口
虽然当前实现里失败后默认回退成普通欢迎卡片,但把错误函数先写好,是个很好的习惯。
第五步:计算访客与博主的距离
如果只显示地区,互动感还是差一点。
加上“你目前距博主约 xx 公里”之后,这块内容就会立刻生动很多。
const calcDistance = (lat, lng) => {
if (!lat || !lng || isNaN(lat) || isNaN(lng)) return '未知';
const R = 6371;
const dLat = (lat - CONFIG.BLOG_LAT) * Math.PI / 180;
const dLng = (lng - CONFIG.BLOG_LNG) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(CONFIG.BLOG_LAT * Math.PI / 180) * Math.cos(lat * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c);
};
这里用的是常见的地球球面距离算法。
对侧边栏这种场景来说,四舍五入到公里已经完全够用了。
这里有一个容易忽略的小坑
当前判断写的是 if (!lat || !lng)。
这意味着如果经纬度刚好为 0,也会被当成无效值。
更稳一点可以改成:
const calcDistance = (lat, lng) => {
if (lat == null || lng == null) return '未知';
if (Number.isNaN(lat) || Number.isNaN(lng)) return '未知';
// 下面保留原来的计算逻辑
};
第六步:给不同时间段配上问候语
一个纯数据卡片会很死,加入时间问候之后,立刻就会有“它在和人说话”的感觉。
const getTimeGreeting = () => {
const h = new Date().getHours();
if (h < 6) return "凌晨好🌙,夜深了注意休息~";
if (h < 9) return "早上好🌤️,一日之计在于晨";
if (h < 12) return "上午好☀️,工作顺利~";
if (h < 14) return "中午好🍱,记得午休喔~";
if (h < 17) return "下午好🕞,饮茶先啦!";
if (h < 19) return "傍晚好🌇,记得按时吃饭~";
if (h < 22) return "晚上好🌙,夜生活嗨起来!";
return "夜深了🌛,早点休息吧~";
};
这段逻辑非常简单,但很实用。
而且你完全可以按自己的博客风格去改这些文案。
第七步:按地区生成欢迎文案
这一段是整个模块最有辨识度的地方。
比起冷冰冰地显示“你来自广东 广州”,更自然的做法是:
- 日本访客看到“よろしく,一起去看樱花吗”
- 广州访客看到“看小蛮腰,喝早茶了嘛~”
- 成都访客看到“宽窄巷子,成都慢生活。”
实现函数如下:
const getRegionGreeting = (country, province, city) => {
if (!country) return "神秘的访客,欢迎来到我的小站~";
const foreignGreetings = {
"日本": "よろしく,一起去看樱花吗",
"美国": "Let us live in peace!",
"英国": "想同你一起夜乘伦敦眼"
};
if (foreignGreetings[country]) return foreignGreetings[country];
if (country !== "中国") return "带我去你的国家逛逛吧~";
const directGreetings = {
"北京": "北——京——欢迎你~~~",
"上海": "走在外滩,感受历史与现代的交融。",
"重庆": "山城重庆,火锅与夜景都很美!"
};
if (directGreetings[province]) return directGreetings[province];
// 下面继续做省份和城市匹配
};
完整版本里,你可以继续维护一张省份和城市映射表:
- 先按省份命中
- 再按城市命中
- 如果城市没命中,就退回省级
_default - 最后再给一个统一兜底
这个模块的体验感,几乎一半都来自这里。
技术上它并不复杂,但文案写得好不好,会直接影响成品质感。
为什么还要处理省份和城市后缀
接口返回值不一定统一。
比如同一个地区,可能返回:
广东广东省南京南京市
所以在做匹配前,最好先标准化一下:
let prov = province;
if (prov) {
prov = prov.replace(/省$|自治区$|回族自治区$|维吾尔自治区$|壮族自治区$/g, '');
}
if (city) {
const cityName = city.replace(/市$/, '');
}
这一步不花哨,但非常必要。
第八步:把卡片真正渲染出来
前面的函数都只是“准备材料”,真正把内容显示到侧边栏上的,是渲染函数。
const showWelcome = (data) => {
const { country, province, city, ip, latitude, longitude, isp } = data || {};
let location = '神秘地区';
if (country) {
if (country === '中国') {
location = [province, city].filter(Boolean).join(' ') || '中国';
} else {
location = country;
}
}
const dist = calcDistance(parseFloat(latitude), parseFloat(longitude));
const address = [country, province, city].filter(Boolean).join(' · ') || '--';
const ipDisplay = (ip || '未知').replace(/</g, '<').replace(/>/g, '>');
welcomeEl.innerHTML = `
<div class="welcome-card">
<p class="greeting">欢迎你~ <br>来自 <span class="highlight">${location}</span> 的小伙伴,你好呀!</p>
<p class="greeting">你目前距博主约 <span class="highlight">${dist} 公里</span></p>
<p class="greeting">${getTimeGreeting()}</p>
<p class="greeting greeting-tip">Tip:<span class="highlight">${getRegionGreeting(country, province, city)}</span> 🍂</p>
<div class="detail-expand">
<div class="expand-content" style="display: none;">
<div class="detail-panel">
<div class="detail-row"><span class="label">IP</span><span class="value">${ipDisplay}</span></div>
<div class="detail-row"><span class="label">地址</span><span class="value">${address}</span></div>
<div class="detail-row"><span class="label">运营商</span><span class="value">${isp || '未知'}</span></div>
</div>
</div>
<button type="button" class="toggle-btn">
<span class="toggle-icon">▼</span>
<span class="toggle-text">展开来访详情</span>
</button>
</div>
</div>
`;
};
这一段主要完成了几件事:
- 处理地区显示文字
- 计算距离
- 拼接地址
- 转义 IP 文本
- 组合成一整块 HTML 输出到页面
ipDisplay 这一行要保留,因为它做了最基础的 HTML 转义。
渲染结果里实际有哪些内容
- 一句欢迎语
- 来访地区
- 与博主距离
- 当前时间问候
- 地区提示文案
- 可展开详情面板
- IP
- 地址
- 运营商
第九步:实现展开和收起详情
卡片渲染出来之后,还要加上按钮交互。
const btn = welcomeEl.querySelector('.toggle-btn');
const content = welcomeEl.querySelector('.expand-content');
if (btn && content) {
btn.addEventListener('click', () => {
const isOpen = content.style.display !== 'none';
content.style.display = isOpen ? 'none' : 'block';
btn.classList.toggle('expanded', !isOpen);
btn.querySelector('.toggle-text').textContent = isOpen ? '展开来访详情' : '收起来访详情';
});
}
这里的逻辑非常直接:
- 默认详情区域隐藏
- 点击按钮后切换显示状态
- 按钮图标跟着旋转
- 按钮文字也跟着改
这类交互最适合侧边栏,因为它能把信息收起来,不会让小区域显得太拥挤。
第十步:请求接口并启动模块
最后一步,就是把前面所有函数串起来。
const fetchAndShow = async () => {
try {
const response = await fetch(CONFIG.API_URL, {
method: 'GET',
headers: {
'key': CONFIG.API_KEY
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.code === 200 && result.data) {
showWelcome(result.data);
} else {
throw new Error(result.message || '数据格式错误');
}
} catch (err) {
console.error('获取IP信息失败:', err);
showWelcome({});
}
};
fetchAndShow();
这一步就是整个模块真正的入口。
页面加载后:
- 请求接口
- 成功就渲染真实欢迎卡片
- 失败就渲染默认欢迎卡片
所以就算接口偶尔抽风,侧边栏也不会直接空掉。
完整可复制版本
如果你不想自己把前面的片段重新拼一遍,可以直接用下面这份完整代码。
这份是可以直接粘贴使用的版本,只要改掉里面的占位信息就行。
<style>
.welcome-section {
margin-bottom: 16px;
}
.welcome-section .section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: var(--anzhiyu-fontcolor);
margin-bottom: 12px;
}
.welcome-section .section-title .icon {
font-size: 1.2rem;
}
.welcome-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.welcome-list-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--anzhiyu-fontcolor);
line-height: 1.5;
}
.welcome-list-item .emoji {
font-size: 1rem;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.welcome-list-item a {
color: var(--anzhiyu-theme);
text-decoration: none;
}
.welcome-list-item a:hover {
text-decoration: underline;
}
.kbd {
display: inline-block;
padding: 2px 6px;
font-size: 11px;
font-family: monospace;
color: var(--anzhiyu-fontcolor);
background: var(--anzhiyu-secondbg);
border: 1px solid var(--anzhiyu-card-border);
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
margin: 0 2px;
}
.section-divider {
height: 1px;
background: linear-gradient(90deg, transparent, var(--anzhiyu-card-border), transparent);
margin: 8px 0;
}
#welcome-info {
user-select: none;
}
.loading-spinner {
width: 32px;
height: 32px;
margin: 30px auto;
border: 3px solid var(--anzhiyu-secondbg);
border-top-color: var(--anzhiyu-theme);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.welcome-card .greeting {
margin: 2px 0;
font-size: 13px;
line-height: 1.6;
color: var(--anzhiyu-fontcolor);
}
.welcome-card .greeting .highlight {
color: var(--anzhiyu-theme);
font-weight: 600;
}
.welcome-card .greeting.greeting-tip {
font-size: 12px;
color: var(--anzhiyu-secondtext);
padding-top: 10px;
margin-top: 10px;
border-top: 1px dashed var(--anzhiyu-card-border);
}
.detail-expand {
margin-top: 12px;
}
.expand-content {
overflow: hidden;
}
.detail-panel {
background: var(--anzhiyu-secondbg);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
}
.detail-row {
display: flex;
align-items: center;
padding: 6px 0;
font-size: 12px;
border-bottom: 1px dashed transparent;
}
.detail-row:not(:last-child) {
border-bottom-color: var(--anzhiyu-card-border);
}
.detail-row .label {
color: var(--anzhiyu-secondtext);
width: 50px;
flex-shrink: 0;
}
.detail-row .value {
color: var(--anzhiyu-fontcolor);
flex: 1;
}
.toggle-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 8px;
background: var(--anzhiyu-secondbg);
border: none;
border-radius: 6px;
color: var(--anzhiyu-theme);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.toggle-btn:hover {
background: var(--anzhiyu-theme-op);
}
.toggle-btn .toggle-icon {
transition: transform 0.2s;
}
.toggle-btn.expanded .toggle-icon {
transform: rotate(180deg);
}
.error-message {
text-align: center;
padding: 20px 0;
}
.error-message .error-icon {
font-size: 2.5rem;
margin-bottom: 10px;
}
.error-message p {
font-size: 13px;
color: var(--anzhiyu-fontcolor);
margin: 5px 0;
}
#retry-button {
color: var(--anzhiyu-theme);
cursor: pointer;
font-style: normal;
font-weight: 500;
}
#retry-button:hover {
text-decoration: underline;
}
</style>
<div class="welcome-section">
<div class="section-title">
<span class="icon">👤</span>
<span>欢迎来访者</span>
</div>
<div class="welcome-list">
<div class="welcome-list-item">
<span class="emoji">👋🏻</span>
<span>Hi,我是站长,欢迎你!</span>
</div>
<div class="welcome-list-item">
<span class="emoji">❓</span>
<span>如有问题欢迎评论区交流!</span>
</div>
<div class="welcome-list-item">
<span class="emoji">😫</span>
<span>页面异常?试试 <span class="kbd">Ctrl</span>+<span class="kbd">F5</span></span>
</div>
<div class="welcome-list-item">
<span class="emoji">📧</span>
<span>联系我:<a href="mailto:your-email@example.com">发送邮件 🚀</a></span>
</div>
</div>
</div>
<div class="section-divider"></div>
<div id="welcome-info">
<div class="loading-spinner"></div>
</div>
<script>
(function () {
'use strict';
const CONFIG = {
API_KEY: 'YOUR_API_KEY',
API_URL: 'https://v1.nsuuu.com/api/ipip',
BLOG_LAT: 39.9042,
BLOG_LNG: 116.4074
};
const welcomeEl = document.getElementById('welcome-info');
if (!welcomeEl) return;
const showLoading = () => {
welcomeEl.innerHTML = '<div class="loading-spinner"></div>';
};
const showError = (msg) => {
welcomeEl.innerHTML = `
<div class="error-message">
<div class="error-icon">😕</div>
<p>${msg}</p>
<p>点击 <i id="retry-button">↻ 重试</i></p>
</div>
`;
document.getElementById('retry-button')?.addEventListener('click', () => {
showLoading();
fetchAndShow();
});
};
const calcDistance = (lat, lng) => {
if (!lat || !lng || isNaN(lat) || isNaN(lng)) return '未知';
const R = 6371;
const dLat = (lat - CONFIG.BLOG_LAT) * Math.PI / 180;
const dLng = (lng - CONFIG.BLOG_LNG) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(CONFIG.BLOG_LAT * Math.PI / 180) * Math.cos(lat * Math.PI / 180) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return Math.round(R * c);
};
const getTimeGreeting = () => {
const h = new Date().getHours();
if (h < 6) return "凌晨好🌙,夜深了注意休息~";
if (h < 9) return "早上好🌤️,一日之计在于晨";
if (h < 12) return "上午好☀️,工作顺利~";
if (h < 14) return "中午好🍱,记得午休喔~";
if (h < 17) return "下午好🕞,饮茶先啦!";
if (h < 19) return "傍晚好🌇,记得按时吃饭~";
if (h < 22) return "晚上好🌙,夜生活嗨起来!";
return "夜深了🌛,早点休息吧~";
};
const getRegionGreeting = (country, province, city) => {
if (!country) return "神秘的访客,欢迎来到我的小站~";
const foreignGreetings = {
"日本": "よろしく,一起去看樱花吗",
"美国": "Let us live in peace!",
"英国": "想同你一起夜乘伦敦眼",
"俄罗斯": "干了这瓶伏特加!",
"法国": "C'est La Vie",
"德国": "Die Zeit verging im Fluge.",
"澳大利亚": "一起去大堡礁吧!",
"加拿大": "拾起一片枫叶赠予你",
"韩国": "안녕하세요,一起去首尔塔吧",
"新加坡": "花园城市欢迎你~",
"泰国": "萨瓦迪卡,泰国欢迎你~"
};
if (foreignGreetings[country]) return foreignGreetings[country];
if (country !== "中国") return "带我去你的国家逛逛吧~";
const directGreetings = {
"北京": "北——京——欢迎你~~~",
"上海": "走在外滩,感受历史与现代的交融。",
"天津": "来天津,尝尝狗不理包子~",
"重庆": "山城重庆,火锅与夜景都很美!",
"香港": "购物天堂香港,买买买!",
"澳门": "澳门,东方蒙特卡洛~",
"台湾": "宝岛台湾,夜市小吃超赞!"
};
if (directGreetings[province]) return directGreetings[province];
const provinceGreetings = {
"广东": {
"广州": "看小蛮腰,喝早茶了嘛~",
"深圳": "来深圳,感受科技与活力!",
"珠海": "浪漫之城珠海,海风轻拂。",
"东莞": "东莞,制造业之都,经济活跃。",
"佛山": "佛山,武术之乡,陶瓷文化深厚。",
"湛江": "湛江,南海之滨,海鲜美味。",
"_default": "带你感受广东的热情与美食!"
},
"浙江": {
"杭州": "西湖美景,三月天~",
"宁波": "来宁波,感受大海的气息。",
"温州": "温州人杰地灵,商贸繁荣。",
"绍兴": "绍兴,酒乡文化,古韵悠长。",
"湖州": "湖州,太湖之滨,风景如画。",
"_default": "这里是浙江,充满江南的韵味!"
},
"四川": {
"成都": "宽窄巷子,成都慢生活。",
"绵阳": "享受科技城的宁静与创新。",
"自贡": "自贡的盐文化与灯会,独具魅力。",
"德阳": "德阳,历史悠久,文化底蕴深厚。",
"乐山": "乐山大佛,世界文化遗产。",
"_default": "来四川,品麻辣火锅,赏壮丽山河。"
}
};
let prov = province;
if (prov) {
prov = prov.replace(/省$|自治区$|回族自治区$|维吾尔自治区$|壮族自治区$/g, '');
}
if (provinceGreetings[prov]) {
const cityMap = provinceGreetings[prov];
if (city && cityMap[city]) return cityMap[city];
if (city) {
const cityName = city.replace(/市$/, '');
if (cityMap[cityName]) return cityMap[cityName];
}
return cityMap["_default"] || "带我去你的城市逛逛吧!";
}
return "带我去你的城市逛逛吧!";
};
const showWelcome = (data) => {
const { country, province, city, ip, latitude, longitude, isp } = data || {};
let location = '神秘地区';
if (country) {
if (country === '中国') {
location = [province, city].filter(Boolean).join(' ') || '中国';
} else {
location = country;
}
}
const dist = calcDistance(parseFloat(latitude), parseFloat(longitude));
const address = [country, province, city].filter(Boolean).join(' · ') || '--';
const ipDisplay = (ip || '未知').replace(/</g, '<').replace(/>/g, '>');
welcomeEl.innerHTML = `
<div class="welcome-card">
<p class="greeting">欢迎你~ <br>来自 <span class="highlight">${location}</span> 的小伙伴,你好呀!</p>
<p class="greeting">你目前距博主约 <span class="highlight">${dist} 公里</span></p>
<p class="greeting">${getTimeGreeting()}</p>
<p class="greeting greeting-tip">Tip:<span class="highlight">${getRegionGreeting(country, province, city)}</span> 🍂</p>
<div class="detail-expand">
<div class="expand-content" style="display: none;">
<div class="detail-panel">
<div class="detail-row"><span class="label">IP</span><span class="value">${ipDisplay}</span></div>
<div class="detail-row"><span class="label">地址</span><span class="value">${address}</span></div>
<div class="detail-row"><span class="label">运营商</span><span class="value">${isp || '未知'}</span></div>
</div>
</div>
<button type="button" class="toggle-btn">
<span class="toggle-icon">▼</span>
<span class="toggle-text">展开来访详情</span>
</button>
</div>
</div>
`;
const btn = welcomeEl.querySelector('.toggle-btn');
const content = welcomeEl.querySelector('.expand-content');
if (btn && content) {
btn.addEventListener('click', () => {
const isOpen = content.style.display !== 'none';
content.style.display = isOpen ? 'none' : 'block';
btn.classList.toggle('expanded', !isOpen);
btn.querySelector('.toggle-text').textContent = isOpen ? '展开来访详情' : '收起来访详情';
});
}
};
const fetchAndShow = async () => {
try {
const response = await fetch(CONFIG.API_URL, {
method: 'GET',
headers: {
'key': CONFIG.API_KEY
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const result = await response.json();
if (result.code === 200 && result.data) {
showWelcome(result.data);
} else {
throw new Error(result.message || '数据格式错误');
}
} catch (err) {
console.error('获取IP信息失败:', err);
showWelcome({});
}
};
fetchAndShow();
})();
</script>
直接使用时,至少先改这 4 项:
- 顶部昵称
- 邮箱链接
API_KEYBLOG_LAT和BLOG_LNG
这几个地方你大概率会自己改
- 顶部欢迎区文字和邮箱链接
- 接口地址和 API key
- 博主坐标
- 国外问候语文案
- 直辖市问候语文案
- 各省各城市的欢迎提示
- 按钮文字和卡片语气
如果你想改成自己的风格,最推荐优先改这三部分:
- 顶部固定文案
- 地区问候语
- 卡片主文案
因为它们最影响“这个模块像不像你的站”。
使用时要注意什么
这段代码能用,但如果你准备长期维护,下面这些点最好提前知道。
API_KEY直接写在前端,访客是能看到的。- 当前版本虽然写了
showError(),但失败时默认还是走showWelcome({})。 calcDistance()把0当成了无效值。- 直辖市如果接口返回的是
北京市这种格式,可能匹配不到专属文案。 .permission-dialog这套样式现在属于预留状态,暂时没真正用起来。
如果你想把它做得更稳一点
可以继续往下优化:
- 用服务端代理接口,避免前端直接暴露 key。
- 给省份和城市做更统一的标准化处理。
- 失败时真正调用
showError(),再让用户手动重试。 - 给请求增加超时控制。
- 把地区映射表单独拆出去,方便长期维护。
结尾
这个侧边栏欢迎模块最有意思的地方,其实不在技术复杂度,而在“数据”和“文案”结合之后,能让一个普通侧边栏突然有了点人情味。
它并不只是告诉访客“你来自哪里”,而是在试着用一句更自然的话去接住这个来访动作。
如果你刚好也喜欢这种细节感,这种小模块会很值得折腾。
🍂
真正让这个模块好看的,不只是样式和动画,而是那句恰到好处的欢迎语。
本文由 ChatGPT 5.4 协同撰写与整理,由 OpenClaw 自动化构建发布。

评论区
评论加载中...