前言

最近在访问各个博客的时候,发现不少博客的侧边栏都添加了来访者卡片,于是基于青桔气球以及梦爱吃鱼

搞了一个适配solitude主题的安知鱼样式的来访者卡片

效果

来访者卡片

教程

首先就是去百度地图拾取坐标系统或者高德地图获取经纬度

source文件夹内的js文件夹中创建一个card-welcome.js文件,将以下内容复制到中并修改文件顶部配置信息

// 欢迎卡片配置
window.WELCOME_CONFIG = {
    API_KEY: '', // API密钥
    BLOG_LOCATION: {
        lng: 119.60, // 博主经度
        lat: 31.73   // 博主纬度
    },
    CACHE_DURATION: 1000 * 60 * 60, // 缓存时间(1小时)
    HOME_PAGE_ONLY: true, // 是否只在首页显示
    SHOW_IP: true, // 是否显示IP地址
    SHOW_DISTANCE: true // 是否显示距离
};

// 初始化欢迎卡片
const initWelcomeCard = () => {
    // 检查是否在首页
    if (WELCOME_CONFIG.HOME_PAGE_ONLY && !isHomePage()) {
        return;
    }

    // 查找欢迎卡片容器
    const welcomeContainer = findWelcomeContainer();
    if (!welcomeContainer) return;

    // 添加样式
    addWelcomeStyles();
    
    // 显示加载状态
    showLoadingState(welcomeContainer);
    
    // 获取IP信息
    fetchIpInfo(welcomeContainer);
};

// 查找欢迎卡片容器
const findWelcomeContainer = () => {
    // 查找现有的欢迎信息容器
    const welcomeInfo = document.getElementById('welcome-info');
    
    if (!welcomeInfo) {
        console.log('未找到欢迎信息容器');
        return null;
    }
    
    return welcomeInfo;
};

// 添加欢迎卡片样式
const addWelcomeStyles = () => {
    if (document.getElementById('welcome-card-styles')) return;
    
    const style = document.createElement('style');
    style.id = 'welcome-card-styles';
    style.textContent = `
        .card-welcome {
            border-radius: var(--radius);
            transition: all .3s;
            overflow: hidden;
            background: var(--efu-card-bg);
            box-shadow: var(--efu-shadow-black);
            border: var(--style-border);
            user-select: none;
        }
        
        .card-welcome .item-headline i {
            color: #ff4757;
        }
        
        .welcome-original {
            margin-bottom: 20px;
            line-height: 1.8;
            color: var(--efu-fontcolor);
        }

        card-widget {
          padding: 10px!important;
         }

        .welcome-original a {
            color: var(--efu-main);
            text-decoration: none;
        }
                        kbd {
            display: inline-block;
            color: #666;
            font: bold 9pt arial;
            text-decoration: none;
            text-align: center;
            padding: 2px 5px;
            margin: 0 5px;
            background: #eff0f2;
            -moz-border-radius: 4px;
            border-radius: 4px;
            border-top: 1px solid #f5f5f5;
            -webkit-box-shadow: inset 0 0 20px #e8e8e8, 0 1px 0 #c3c3c3, 0 1px 0 #c9c9c9, 0 1px 2px #333;
            -moz-box-shadow: inset 0 0 20px #e8e8e8, 0 1px 0 #c3c3c3, 0 1px 0 #c9c9c9, 0 1px 2px #333;
            -webkit-box-shadow: inset 0 0 20px #e8e8e8, 0 1px 0 #c3c3c3, 0 1px 0 #c9c9c9, 0 1px 2px #333;
            -webkit-box-shadow: inset 0 0 20px #e8e8e8, 0 1px 0 #c3c3c3, 0 1px 0 #c9c9c9, 0 1px 2px #333;
            box-shadow: inset 0 0 20px #e8e8e8, 0 1px 0 #c3c3c3, 0 1px 0 #c9c9c9, 0 1px 2px #333;
            text-shadow: 0 1px 0 #f5f5f5;
        }
        .welcome-original a:hover {
            text-decoration: underline;
        }
        
        .welcome-ip-info {
            margin-top: 15px;
        }
        
        .ip-info-container {
            background: #f0f2f5;
            border-radius: 8px;
            padding: 15px;
            text-align: center;
            line-height: 1.8;
            color: var(--efu-fontcolor);
            margin-top: 15px;
        }
        
        [data-theme=dark] .ip-info-container {
            background: #2a2d31;
        }
        
        .ip-info-container b {
            color: var(--efu-main);
            font-weight: 600;
        }
        
        .ip-info-container .location {
            font-size: 16px;
            margin-bottom: 8px;
        }
        
        .ip-info-container .distance {
            font-size: 14px;
            margin-bottom: 8px;
            opacity: 0.8;
        }
        
        .ip-info-container .ip-address {
            font-size: 14px;
            margin-bottom: 12px;
        }
        
        .ip-info-container .ip-address .ip-blur {
            filter: blur(3px);
            transition: filter 0.3s ease;
            cursor: pointer;
            color: var(--efu-main);
        }
        
        .ip-info-container .ip-address .ip-blur:hover {
            filter: blur(0);
        }
        
        .ip-info-container .time-greeting {
            font-size: 14px;
            margin-bottom: 12px;
        }
        
        .ip-info-container .greeting-tip {
            font-size: 13px;
            opacity: 0.8;
            line-height: 1.6;
        }
        
        .ip-info-container .greeting-tip .tip-content {
            color: var(--efu-main);
        }
        
        .loading-spinner {
            width: 40px;
            height: 40px;
            border: 3px solid var(--efu-card-border);
            border-radius: 50%;
            border-top: 3px solid var(--efu-main);
            animation: spin 1s linear infinite;
        }
        
        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }
        
        .error-message {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 12px;
            color: var(--efu-red);
        }
        
        .error-icon {
            font-size: 2.5rem;
            opacity: 0.7;
        }
        
        .error-text {
            font-size: 14px;
            text-align: center;
        }
        
        .retry-button {
            color: var(--efu-main);
            cursor: pointer;
            transition: transform 0.3s ease;
            font-size: 16px;
        }
        
        .retry-button:hover {
            transform: rotate(180deg);
        }
        
        .permission-dialog {
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 15px;
        }
        
        .permission-dialog button {
            margin: 5px;
            padding: 8px 16px;
            border: none;
            border-radius: 6px;
            background: var(--efu-main);
            color: var(--efu-white);
            cursor: pointer;
            transition: all 0.3s ease;
            font-size: 14px;
        }
        
        .permission-dialog button:hover {
            opacity: 0.8;
            transform: translateY(-1px);
        }
        
        .permission-dialog button[data-action="deny"] {
            background: var(--efu-gray);
        }
    `;
    document.head.appendChild(style);
};

// 显示加载状态
const showLoadingState = (container) => {
    container.innerHTML = '<div class="loading-spinner"></div>';
};

// 获取IP信息
const fetchIpInfo = async (container) => {
    try {
        // 检查缓存
        const cachedData = getCachedIpInfo();
        if (cachedData) {
            showWelcomeInfo(container, cachedData);
            return;
        }

        // 检查位置权限
        if (!checkLocationPermission()) {
            showPermissionDialog(container);
            return;
        }

        // 请求API
        const response = await fetch(`https://api.nsmao.net/api/ipip/query?key=${WELCOME_CONFIG.API_KEY}`);
        if (!response.ok) {
            throw new Error('网络请求失败');
        }

        const data = await response.json();
        
        // 缓存数据
        cacheIpInfo(data);
        
        // 显示欢迎信息
        showWelcomeInfo(container, data);
        
    } catch (error) {
        console.error('获取IP信息失败:', error);
        showErrorMessage(container);
    }
};

// 显示欢迎信息
const showWelcomeInfo = (container, data) => {
    if (!data || !data.data) {
        showErrorMessage(container);
        return;
    }

    const ipData = data.data;
    const ip = data.ip;
    
    // 计算距离
    const distance = WELCOME_CONFIG.SHOW_DISTANCE ? 
        calculateDistance(ipData.lng, ipData.lat) : 0;
    
    // 格式化位置信息
    const location = formatLocation(ipData.country, ipData.province, ipData.city);
    
    // 格式化IP地址
    const ipDisplay = WELCOME_CONFIG.SHOW_IP ? formatIpDisplay(ip) : '';
    
    // 获取问候语
    const greeting = getGreeting(ipData.country, ipData.province, ipData.city);
    const timeGreeting = getTimeGreeting();

    // 生成IP信息HTML,保留原来的欢迎文本
    const ipInfoHTML = `
        <div class="ip-info-container">
            <div class="location">
                欢迎来自 <b>${location}</b> 的小友 💖
            </div>
            ${distance > 0 ? `<div class="distance">当前位置距博主约 <b>${distance}</b> 公里</div>` : ''}
            ${ipDisplay ? `<div class="ip-address">${ipDisplay}</div>` : ''}
            <div class="time-greeting">${timeGreeting}</div>
            <div class="greeting-tip">Tip:<span class="tip-content">${greeting} 🍂</span></div>
        </div>
    `;
    
    // 将IP信息添加到容器中,而不是替换整个内容
    container.innerHTML = ipInfoHTML;
};

// 计算距离
const calculateDistance = (lng, lat) => {
    if (!lng || !lat) return 0;
    
    const R = 6371; // 地球半径(km)
    const rad = Math.PI / 180;
    const dLat = (lat - WELCOME_CONFIG.BLOG_LOCATION.lat) * rad;
    const dLon = (lng - WELCOME_CONFIG.BLOG_LOCATION.lng) * rad;
    const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
        Math.cos(WELCOME_CONFIG.BLOG_LOCATION.lat * rad) * Math.cos(lat * rad) *
        Math.sin(dLon / 2) * Math.sin(dLon / 2);

    return Math.round(R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)));
};

// 格式化位置信息
const formatLocation = (country, province, city) => {
    if (!country) return '神秘地区';
    if (country === "中国") {
        if (province && city) {
            return `${province} ${city}`;
        } else if (province) {
            return province;
        } else {
            return '中国';
        }
    }
    return country;
};

// 格式化IP地址
const formatIpDisplay = (ip) => {
    if (!ip) return '';
    const ipText = ip.includes(":") ? "IPv6地址 (好复杂,咱看不懂~)" : ip;
    return `您的IP地址:<span class="ip-blur">${ipText}</span>`;
};

// 获取问候语
const getGreeting = (country, province, city) => {
    const greetings = {
        "中国": {
            "北京市": "北——京——欢迎你~~~",
            "天津市": "讲段相声吧",
            "河北省": "山势巍巍成壁垒,天下雄关铁马金戈由此向,无限江山",
            "山西省": "展开坐具长三尺,已占山河五百余",
            "内蒙古自治区": "天苍苍,野茫茫,风吹草低见牛羊",
            "辽宁省": "我想吃烤鸡架!",
            "吉林省": "状元阁就是东北烧烤之王",
            "黑龙江省": "很喜欢哈尔滨大剧院",
            "上海市": "众所周知,中国只有两个城市",
            "江苏省": {
                "南京市": "这是我挺想去的城市啦",
                "苏州市": "上有天堂,下有苏杭",
                "其他": "散装是必须要散装的"
            },
            "浙江省": {
                "杭州市": "东风渐绿西湖柳,雁已还人未南归",
                "其他": "望海楼明照曙霞,护江堤白蹋晴沙"
            },
            "河南省": {
                "郑州市": "豫州之域,天地之中",
                "信阳市": "品信阳毛尖,悟人间芳华",
                "南阳市": "臣本布衣,躬耕于南阳此南阳非彼南阳!",
                "驻马店市": "峰峰有奇石,石石挟仙气嵖岈山的花很美哦!",
                "开封市": "刚正不阿包青天",
                "洛阳市": "洛阳牡丹甲天下",
                "其他": "可否带我品尝河南烩面啦?"
            },
            "安徽省": "蚌埠住了,芜湖起飞",
            "福建省": "井邑白云间,岩城远带山",
            "江西省": "落霞与孤鹜齐飞,秋水共长天一色",
            "山东省": "遥望齐州九点烟,一泓海水杯中泻",
            "湖北省": {
                "黄冈市": "红安将军县!辈出将才!",
                "其他": "来碗热干面~"
            },
            "湖南省": "74751,长沙斯塔克",
            "广东省": {
                "广州市": "看小蛮腰,喝早茶了嘛~",
                "深圳市": "今天你逛商场了嘛~",
                "阳江市": "阳春合水!博主家乡~ 欢迎来玩~",
                "其他": "来两斤福建人~"
            },
            "广西壮族自治区": "桂林山水甲天下",
            "海南省": "朝观日出逐白浪,夕看云起收霞光",
            "四川省": "康康川妹子",
            "贵州省": "茅台,学生,再塞200",
            "云南省": "玉龙飞舞云缠绕,万仞冰川直耸天",
            "西藏自治区": "躺在茫茫草原上,仰望蓝天",
            "陕西省": "来份臊子面加馍",
            "甘肃省": "羌笛何须怨杨柳,春风不度玉门关",
            "青海省": "牛肉干和老酸奶都好好吃",
            "宁夏回族自治区": "大漠孤烟直,长河落日圆",
            "新疆维吾尔自治区": "驼铃古道丝绸路,胡马犹闻唐汉风",
            "台湾省": "我在这头,大陆在那头",
            "香港特别行政区": "永定贼有残留地鬼嚎,迎击光非岁玉",
            "澳门特别行政区": "性感荷官,在线发牌",
            "其他": "带我去你的城市逛逛吧!"
        },
        "美国": "Let us live in peace!",
        "日本": "よろしく、一緒に桜を見ませんか",
        "俄罗斯": "干了这瓶伏特加!",
        "法国": "C'est La Vie",
        "德国": "Die Zeit verging im Fluge.",
        "澳大利亚": "一起去大堡礁吧!",
        "加拿大": "拾起一片枫叶赠予你",
        "其他": "带我去你的国家逛逛吧"
    };

    if (!country) return greetings["其他"];
    
    const countryGreeting = greetings[country] || greetings["其他"];
    if (typeof countryGreeting === 'string') {
        return countryGreeting;
    }
    
    if (province) {
        const provinceGreeting = countryGreeting[province] || countryGreeting["其他"];
        if (typeof provinceGreeting === 'string') {
            return provinceGreeting;
        }
        
        if (city) {
            return provinceGreeting[city] || provinceGreeting["其他"] || countryGreeting["其他"];
        }
        return provinceGreeting["其他"] || countryGreeting["其他"];
    }
    
    return countryGreeting["其他"] || greetings["其他"];
};

// 获取时间问候语
const getTimeGreeting = () => {
    const hour = new Date().getHours();
    if (hour < 11) return "🌤️ 早上好,一日之计在于晨";
    if (hour < 13) return "☀️ 中午好,记得午休喔~";
    if (hour < 17) return "🕞 下午好,饮茶先啦!";
    if (hour < 19) return "🚶‍♂️ 即将下班,记得按时吃饭~";
    return "🌙 晚上好,夜生活嗨起来!";
};

// 显示错误信息
const showErrorMessage = (container) => {
    container.innerHTML = `
        <div class="error-message">
            <div class="error-icon">😕</div>
            <div class="error-text">抱歉,无法获取信息</div>
            <div class="error-text">请<i class="retry-button solitude fas fa-arrows-rotate" onclick="retryFetchIpInfo()"></i>重试或检查网络连接</div>
        </div>
    `;
};

// 显示权限对话框
const showPermissionDialog = (container) => {
    container.innerHTML = `
        <div class="permission-dialog">
            <div class="error-icon">❓</div>
            <div class="error-text">是否允许访问您的位置信息?</div>
            <div>
                <button data-action="allow" onclick="handleLocationPermission('granted')">允许</button>
                <button data-action="deny" onclick="handleLocationPermission('denied')">拒绝</button>
            </div>
        </div>
    `;
};

// 处理位置权限
const handleLocationPermission = (permission) => {
    localStorage.setItem('locationPermission', permission);
    if (permission === 'granted') {
        const container = document.getElementById('welcome-info');
        if (container) {
            showLoadingState(container);
            fetchIpInfo(container);
        }
    } else {
        const container = document.getElementById('welcome-info');
        if (container) {
            container.innerHTML = `
                <div class="error-message">
                    <div class="error-icon">🔒</div>
                    <div class="error-text">您已拒绝访问位置信息</div>
                </div>
            `;
        }
    }
};

// 检查位置权限
const checkLocationPermission = () => {
    return localStorage.getItem('locationPermission') === 'granted';
};

// 缓存相关函数
const getCachedIpInfo = () => {
    const cached = localStorage.getItem('ip_info_cache');
    if (!cached) return null;

    const { data, timestamp } = JSON.parse(cached);
    if (Date.now() - timestamp > WELCOME_CONFIG.CACHE_DURATION) {
        localStorage.removeItem('ip_info_cache');
        return null;
    }
    return data;
};

const cacheIpInfo = (data) => {
    localStorage.setItem('ip_info_cache', JSON.stringify({
        data,
        timestamp: Date.now()
    }));
};

// 重试获取IP信息
const retryFetchIpInfo = () => {
    const container = document.getElementById('welcome-info');
    if (container) {
        showLoadingState(container);
        fetchIpInfo(container);
    }
};

// 检查是否为首页
const isHomePage = () => {
    return window.location.pathname === '/' || window.location.pathname === '/index.html';
};

// 初始化
document.addEventListener('DOMContentLoaded', initWelcomeCard);
document.addEventListener('pjax:complete', initWelcomeCard);

接下来就是到_config.solitude.yml文件中的body项中引入这个js,代码如下

    - <script src="/js/card-welcome.js"></script>

接着就是到_data文件夹内的aside.yml文件中添加如下代码

- name: welcome
  title: 来访者
  class: card-welcome
  id:
  icon: fas fa-location-dot
  content_html: |
    <div class="welcome-original">
      <span>👋🏻 Hi,我是莫忘,欢迎你!</span><br>
      <span>❓ 如有问题欢迎评论区交流!</span><br>
      <span>😫 页面异常?尝试<kbd>Ctrl</kbd>+<kbd>F5</kbd></span><br>
      <span>📧 如需联系我:<a href="mailto:mowang@xrbk.cn"><b>发送邮件🚀</b></a></span>
    </div>
    <div id="welcome-info" class="welcome-ip-info"></div>

最后在_config.solitude.yml文件中的aside项中的homecards项中添加如下代码Sticky中添加welcome即可。

至此,一键三连即可看到效果