侧边栏地区欢迎卡片教程

侧边栏地区欢迎卡片教程

拆解最新版侧边栏地区欢迎卡片的结构、运行逻辑、可修改项与常见坑点,适合直接照着接入和二次改造。
AI播客
AI播客
AI 播客
AI 播客
撰写与发布说明

本文由 ChatGPT 5.4 参与内容协同撰写与整理,经由 OpenClaw 自动化构建发布流程生成并发布。全文已通过火山大模型内容安全与合规性双重审核。

AI 内容告示

本文包含 AI 辅助生成与整理内容,示例代码与说明已经过人工校对,但仍可能因主题版本、接口返回格式或使用环境不同而产生差异。
正式使用前,请务必结合自己的站点环境自行测试与调整。

前言

很多博客侧边栏都会放一句“欢迎来访”,但如果只是静态文字,其实很快就看腻了。

这次我们来做一个稍微有意思一点的版本:

  • 顶部先放一组固定欢迎信息。
  • 下方根据访客 IP 获取地区信息。
  • 自动显示“你来自哪里”“离博主多远”。
  • 再根据当前时间和地区,给出一句更有温度的问候语。
  • 还可以点击展开,查看 IP、地址和运营商。

如果你喜欢那种“看起来像是在认真接待访客”的侧边栏,这种模块会很合适。

这篇文章适合谁
  • 想给博客侧边栏加一点互动感的人。
  • 想直接复制一份代码然后按自己需求改的人。
  • 想顺手学一下原生 HTML、CSS、JavaScript 小模块写法的人。
先说明一下

下面示例里的昵称、邮箱、API key 和坐标都已经换成了可公开发布的占位写法。
复制后只要把这几项改成你自己的信息,就可以直接使用。

最终能实现什么

写完以后,这个侧边栏模块会有三层体验:

  1. 页面一加载,就先显示一组固定欢迎信息。
  2. 动态区域先转圈加载,再请求访客地区数据。
  3. 成功后渲染出欢迎卡片,失败时也不会整块空白。

卡片里会展示:

  • 来访地区
  • 与博主的大致距离
  • 当前时间段问候语
  • 一句按地区生成的提示文案
  • 可展开的 IP、地址、运营商详情
效果图
anheyu-app欢迎栏示例图
anheyu-app欢迎栏示例图

安装方法

方法一:安和鱼博客系统适配

如果你使用的是安和鱼(AnHeYu)博客系统,可以将本文提供的完整代码直接添加到后台侧边栏配置中。

配置步骤

  1. 登录安和鱼博客系统后台,通常地址为 你的域名/admin
  2. 进入 系统管理系统设置外观配置侧边栏
  3. 将本文后面的“完整可复制版本”代码粘贴到侧边栏配置中
  4. 保存配置并刷新前台页面
使用前记得先改这几项
  • 顶部昵称
  • 邮箱链接
  • API_KEY
  • BLOG_LATBLOG_LNG

先看整体思路

这个模块其实并不复杂,本质上就是三部分拼起来:

部分作用
CSS控制欢迎区、加载动画、按钮和详情面板样式
HTML放固定欢迎内容和动态挂载区域
JavaScript请求接口、生成问候语、计算距离、渲染卡片

你可以把它理解成一个“小型单文件组件”。

页面加载

先显示顶部固定欢迎内容,同时在动态区域放一个加载动画。

请求数据

向 IP 接口发起请求,拿到国家、省份、城市、经纬度和运营商。

处理文案

根据时间和地区,生成对应的欢迎语。

渲染卡片

把地区、距离、提示文案和详情按钮一起输出到页面。

用户交互

点击按钮后展开或收起来访详情。

第一步:写静态欢迎区

侧边栏里最好先有一块不依赖接口的内容。这样做有两个好处:

  • 页面刚打开时不会显得空。
  • 即使接口请求失败,顶部区域也还能正常显示。

先写这样一段 HTML:

静态欢迎区.htmlhtml
<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 可以理解成一个“预留出来的插槽”。

第二步:把基础样式铺好

这个模块的样式重点不是炫技,而是“看起来清楚、舒服、有层次”。

欢迎区样式

欢迎区负责展示顶部固定内容,所以样式要轻一点:

欢迎区样式.csscss
.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 出错,而是主题变量对不上。

加载动画和卡片样式

动态区域里最先出现的是转圈动画,接口回来后才会被真正内容替换:

动态区域样式.csscss
#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;
}

按钮、详情面板、提示文字,也都是围绕“侧边栏小卡片”这个定位来写的,所以尺寸控制得比较克制。

第三步:准备配置项

脚本部分先不要急着写逻辑,第一步先把配置项列出来:

配置项.jsjs
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_LATBLOG_LNG 最好改成你自己的坐标
  • 顶部欢迎区昵称和邮箱也建议一起改掉
API 密钥获取说明

本文示例使用的是鸭梨 API。
API_KEY 可前往 https://api.nsuuu.com/ 申请,申请完成后填入配置项即可使用。

为什么这里还要写博主坐标?

因为卡片里会显示“你目前距博主约 xx 公里”。
这个距离不是接口直接给的,而是用访客经纬度和你的坐标算出来的。

第四步:先处理状态显示

在写主逻辑之前,最好先把“加载中”和“错误态”准备好。

状态函数.jsjs
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 公里”之后,这块内容就会立刻生动很多。

距离计算.jsjs
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,也会被当成无效值。

更稳一点可以改成:

更稳的写法.jsjs
const calcDistance = (lat, lng) => {
  if (lat == null || lng == null) return '未知';
  if (Number.isNaN(lat) || Number.isNaN(lng)) return '未知';
  // 下面保留原来的计算逻辑
};

第六步:给不同时间段配上问候语

一个纯数据卡片会很死,加入时间问候之后,立刻就会有“它在和人说话”的感觉。

时间问候语.jsjs
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 "夜深了🌛,早点休息吧~";
};

这段逻辑非常简单,但很实用。
而且你完全可以按自己的博客风格去改这些文案。

这里读取的是访客浏览器本地时间。

第七步:按地区生成欢迎文案

这一段是整个模块最有辨识度的地方。

比起冷冰冰地显示“你来自广东 广州”,更自然的做法是:

  • 日本访客看到“よろしく,一起去看樱花吗”
  • 广州访客看到“看小蛮腰,喝早茶了嘛~”
  • 成都访客看到“宽窄巷子,成都慢生活。”

实现函数如下:

地区问候语逻辑.jsjs
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
  • 最后再给一个统一兜底
这部分最值得你花心思

这个模块的体验感,几乎一半都来自这里。
技术上它并不复杂,但文案写得好不好,会直接影响成品质感。

为什么还要处理省份和城市后缀

接口返回值不一定统一。

比如同一个地区,可能返回:

  • 广东
  • 广东省
  • 南京
  • 南京市

所以在做匹配前,最好先标准化一下:

省市标准化.jsjs
let prov = province;
if (prov) {
  prov = prov.replace(/省$|自治区$|回族自治区$|维吾尔自治区$|壮族自治区$/g, '');
}

if (city) {
  const cityName = city.replace(/市$/, '');
}

这一步不花哨,但非常必要。

第八步:把卡片真正渲染出来

前面的函数都只是“准备材料”,真正把内容显示到侧边栏上的,是渲染函数。

这一段主要完成了几件事:

  • 处理地区显示文字
  • 计算距离
  • 拼接地址
  • 转义 IP 文本
  • 组合成一整块 HTML 输出到页面

ipDisplay 这一行要保留,因为它做了最基础的 HTML 转义。

渲染结果里实际有哪些内容
  • 一句欢迎语
  • 来访地区
  • 与博主距离
  • 当前时间问候
  • 地区提示文案
  • 可展开详情面板
  • IP
  • 地址
  • 运营商

第九步:实现展开和收起详情

卡片渲染出来之后,还要加上按钮交互。

展开详情按钮.jsjs
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 ? '展开来访详情' : '收起来访详情';
  });
}

这里的逻辑非常直接:

  • 默认详情区域隐藏
  • 点击按钮后切换显示状态
  • 按钮图标跟着旋转
  • 按钮文字也跟着改

这类交互最适合侧边栏,因为它能把信息收起来,不会让小区域显得太拥挤。

第十步:请求接口并启动模块

最后一步,就是把前面所有函数串起来。

请求与启动.jsjs
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();

这一步就是整个模块真正的入口。

页面加载后:

  1. 请求接口
  2. 成功就渲染真实欢迎卡片
  3. 失败就渲染默认欢迎卡片

所以就算接口偶尔抽风,侧边栏也不会直接空掉。

完整可复制版本

如果你不想自己把前面的片段重新拼一遍,可以直接用下面这份完整代码。
这份是可以直接粘贴使用的版本,只要改掉里面的占位信息就行。

完整可复制代码html
<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, '&lt;').replace(/>/g, '&gt;');

      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 项:

  1. 顶部昵称
  2. 邮箱链接
  3. API_KEY
  4. BLOG_LATBLOG_LNG

这几个地方你大概率会自己改

  • 顶部欢迎区文字和邮箱链接
  • 接口地址和 API key
  • 博主坐标
  • 国外问候语文案
  • 直辖市问候语文案
  • 各省各城市的欢迎提示
  • 按钮文字和卡片语气

如果你想改成自己的风格,最推荐优先改这三部分:

  1. 顶部固定文案
  2. 地区问候语
  3. 卡片主文案

因为它们最影响“这个模块像不像你的站”。

使用时要注意什么

这段代码能用,但如果你准备长期维护,下面这些点最好提前知道。

  1. API_KEY 直接写在前端,访客是能看到的。
  2. 当前版本虽然写了 showError(),但失败时默认还是走 showWelcome({})
  3. calcDistance()0 当成了无效值。
  4. 直辖市如果接口返回的是 北京市 这种格式,可能匹配不到专属文案。
  5. .permission-dialog 这套样式现在属于预留状态,暂时没真正用起来。

如果你想把它做得更稳一点

可以继续往下优化:

  • 用服务端代理接口,避免前端直接暴露 key。
  • 给省份和城市做更统一的标准化处理。
  • 失败时真正调用 showError(),再让用户手动重试。
  • 给请求增加超时控制。
  • 把地区映射表单独拆出去,方便长期维护。

结尾

这个侧边栏欢迎模块最有意思的地方,其实不在技术复杂度,而在“数据”和“文案”结合之后,能让一个普通侧边栏突然有了点人情味。

它并不只是告诉访客“你来自哪里”,而是在试着用一句更自然的话去接住这个来访动作。

如果你刚好也喜欢这种细节感,这种小模块会很值得折腾。

🍂

真正让这个模块好看的,不只是样式和动画,而是那句恰到好处的欢迎语。

撰写与发布说明

本文由 ChatGPT 5.4 协同撰写与整理,由 OpenClaw 自动化构建发布。

高通平台免解 BL 临时 Root 实战
anheyu博客侧边栏创建倒数卡片

评论区

评论加载中...