教程

为 Fuwari 博客添加 Umami 访问量统计

TIP

你可以直接将这篇文章丢给你的ai Agent,让它帮你实现。

对于静态博客而言,了解访客来源与流量趋势是很有必要的。在不自建服务器的前提下,配置简单、功能强大的 Umami 往往是我们的首选。

然而,直接挂载 Umami 的分享外链不够直观且破坏页面一致性。本文将教你如何将 Umami 的统计数据以原生组件的形式集成到博客中,实现以下效果:

  • 侧边栏显示总浏览量访问数
  • 每篇文章卡片显示该文章的独立浏览量
  • 数据实时获取,支持数字动画

前置条件

  • 一个已部署好的 Fuwari 博客
  • 启用 Umami 统计的站点
  • 获取 Umami 的分享链接

获取 Umami 分享链接

在 Umami 后台启用"分享 URL",你应该会得到一个类似 https://umami.example.com/share/OSwi3PpuZgZ2Io3a 格式的链接。记下这个链接,稍后配置需要用到。

步骤一:添加 Umami 跟踪脚本

首先需要在博客的 <head> 中注入 Umami 的跟踪脚本,这样才能记录访问数据。

src/layouts/Layout.astro<head> 区域添加:

<script defer src="https://example.com/script.js" data-website-id="你的website-id"></script>

提示:这在Umami的后台配置中可以找到。

步骤二:创建侧边栏统计组件

src/components/widget/ 目录下创建 UmamiStats.astro

---
import WidgetLayout from "./WidgetLayout.astro";

interface Props {
	class?: string;
	style?: string;
}
const { class: className, style } = Astro.props;
---

<WidgetLayout name="统计" id="umami-stats" class:list={[className, "cursor-pointer transition-opacity active:scale-95"]} {style}>
    <a id="umami-link" target="_blank" rel="noopener noreferrer" class="block">
        <div class="grid grid-cols-2 divide-x divide-neutral-200 dark:divide-neutral-700 text-center py-2">
            <div class="px-2">
                <div class="text-2xl font-bold text-neutral-900 dark:text-neutral-100" id="total-pageviews">-</div>
                <div class="text-sm text-neutral-500 dark:text-neutral-400">总浏览量</div>
            </div>
            <div class="px-2">
                <div class="text-2xl font-bold text-neutral-900 dark:text-neutral-100" id="total-visits">-</div>
                <div class="text-sm text-neutral-500 dark:text-neutral-400">访问数</div>
            </div>
        </div>
    </a>
</WidgetLayout>

<script>
const UMAMI_CONFIG = {
    apiUrl: 'https://umami.flygeon.eu.org/api',
    shareId: 'OSwi3PpuZgZ2Io3a',
};

let __UMAMI_INTERNAL = {
    websiteId: '',
    shareToken: '',
    isReady: false
};

const FALLBACK_STATS = {
    pageviews: 1000,
    visits: 1000,
};

async function initUmamiConfig() {
    try {
        const res = await fetch(`${UMAMI_CONFIG.apiUrl}/share/${UMAMI_CONFIG.shareId}`);
        const data = await res.json();

        __UMAMI_INTERNAL = {
            websiteId: data.websiteId,
            shareToken: data.token,
            isReady: true
        };

        const link = document.getElementById('umami-link');
        if (link) link.setAttribute('href', `https://umami.flygeon.eu.org/share/${UMAMI_CONFIG.shareId}`);

    } catch (e) {
        console.error('Umami Config Init Failed:', e);
    }
}

function formatNumber(num: number): string {
    if (num >= 1000000) {
        return (num / 1000000).toFixed(1) + 'M';
    } else if (num >= 1000) {
        return (num / 1000).toFixed(1) + 'K';
    }
    return Math.round(num).toString();
}

function setStats(values: { pageviews: number; visits: number }) {
    const pageviewsElement = document.getElementById('total-pageviews');
    const visitsElement = document.getElementById('total-visits');

    const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
    const animHandles = new Map<HTMLElement, number>();

    const animateStat = (el: HTMLElement | null, to: number, duration = 2000) => {
        if (!el) return;

        const prev = animHandles.get(el);
        if (prev) cancelAnimationFrame(prev);

        const from = 0;
        const startTime = performance.now();

        const tick = (now: number) => {
            const elapsed = now - startTime;
            const progress = Math.min(1, elapsed / duration);
            const easedProgress = easeOutCubic(progress);

            const current = from + (to - from) * easedProgress;
            el.textContent = formatNumber(current);

            if (progress < 1) {
                animHandles.set(el, requestAnimationFrame(tick));
            }
        };
        animHandles.set(el, requestAnimationFrame(tick));
    };

    animateStat(pageviewsElement, values.pageviews);
    animateStat(visitsElement, values.visits);
}

async function fetchUmamiStats() {
    if (!__UMAMI_INTERNAL.isReady) {
        await initUmamiConfig();
    }

    if (!__UMAMI_INTERNAL.isReady) {
        setStats(FALLBACK_STATS);
        return;
    }

    try {
        const endAt = Date.now();
        const startAt = 0;
        const url = `${UMAMI_CONFIG.apiUrl}/websites/${__UMAMI_INTERNAL.websiteId}/stats?startAt=${startAt}&endAt=${endAt}&unit=hour&timezone=Asia%2FShanghai`;

        const response = await fetch(url, {
            headers: {
                'x-umami-share-token': __UMAMI_INTERNAL.shareToken
            }
        });

        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();

        setStats({
            pageviews: data.pageviews || 0,
            visits: data.visits || 0,
        });

    } catch (error) {
        console.error('Umami Fetch Failed:', error);
        setStats(FALLBACK_STATS);
    }
}

let __umamiStatsStarted = false;
function startUmamiStats() {
    if (__umamiStatsStarted) return;
    __umamiStatsStarted = true;
    fetchUmamiStats();
}

function initUmamiStatsVisibility() {
    const container = document.getElementById('umami-stats');
    const io = new IntersectionObserver((entries) => {
        if (entries[0].isIntersecting) {
            startUmamiStats();
            io.disconnect();
        }
    }, { threshold: 0.1 });

    if (container) io.observe(container);
}

initUmamiStatsVisibility();

if (window.swup) {
    window.swup.hooks.on('page:view', () => {
        __umamiStatsStarted = false;
        initUmamiStatsVisibility();
    });
}
</script>

记得将 apiUrlshareId 替换为你自己的 Umami 信息,如果直接照抄我的配置,你的网站统计会算在我博客上。

我是自建umami服务器的,如果你使用Umami官方的服务,你的 apiUrl 应该是https://cloud.umami.is/analytics/地区/api

美国的是us,欧洲的是eu

如果你和我一样自建服务器的话,地址则是https://服务器地址/api

步骤三:将统计组件添加到侧边栏

编辑 src/components/widget/SideBar.astro,导入并使用 UmamiStats 组件:

---
import type { MarkdownHeading } from "astro";
import Categories from "./Categories.astro";
import Profile from "./Profile.astro";
import RouteSwitch from "./RouteSwitch.astro";
import Tag from "./Tags.astro";
import UmamiStats from "./UmamiStats.astro";

interface Props {
	class?: string;
	headings?: MarkdownHeading[];
}

const className = Astro.props.class;
---
<div id="sidebar" class:list={[className, "w-full"]}>
    <div class="flex flex-col w-full gap-4 mb-4">
        <Profile></Profile>
    </div>
    <div id="sidebar-sticky" class="transition-all duration-700 flex flex-col w-full gap-4 top-4 sticky top-4">
        <RouteSwitch class="onload-animation" style="animation-delay: 100ms"></RouteSwitch>
        <Categories class="onload-animation" style="animation-delay: 150ms"></Categories>
        <Tag class="onload-animation" style="animation-delay: 200ms"></Tag>
        <UmamiStats class="onload-animation" style="animation-delay: 250ms"></UmamiStats>
    </div>
</div>

步骤四:为文章卡片添加独立浏览量

编辑 src/components/PostCard.astro,在 frontmatter 区域添加文章路径提取:

const postPath = url.replace(/\/$/, "");

在文章卡片的元信息区域(字数和阅读时间旁边)添加浏览量显示:

<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition">
    <div>
        {remarkPluginFrontmatter.words} {" " + i18n(remarkPluginFrontmatter.words === 1 ? I18nKey.wordCount : I18nKey.wordsCount)}
    </div>
    <div>|</div>
    <div>
        {remarkPluginFrontmatter.minutes} {" " + i18n(remarkPluginFrontmatter.minutes === 1 ? I18nKey.minuteCount : I18nKey.minutesCount)}
    </div>
    <div>|</div>
    <div class="flex items-center gap-1 post-views" data-path={postPath}>
        <Icon name="material-symbols:visibility-outline-rounded" class="text-base"></Icon>
        <span class="post-views-count">-</span>
    </div>
</div>

在文件末尾添加统计脚本:

<script>
const UMAMI_POST_CONFIG = {
    apiUrl: 'https://umami.flygeon.eu.org/api',
    shareId: 'OSwi3PpuZgZ2Io3a',
};

let __UMAMI_POST_INTERNAL = {
    websiteId: '',
    shareToken: '',
    isReady: false
};

async function initUmamiPostConfig() {
    try {
        const res = await fetch(`${UMAMI_POST_CONFIG.apiUrl}/share/${UMAMI_POST_CONFIG.shareId}`);
        const data = await res.json();

        __UMAMI_POST_INTERNAL = {
            websiteId: data.websiteId,
            shareToken: data.token,
            isReady: true
        };
    } catch (e) {
        console.error('Umami Post Config Init Failed:', e);
    }
}

function formatNumber(num: number): string {
    if (num >= 1000000) {
        return (num / 1000000).toFixed(1) + 'M';
    } else if (num >= 1000) {
        return (num / 1000).toFixed(1) + 'K';
    }
    return Math.round(num).toString();
}

async function fetchPostStats(path: string) {
    if (!__UMAMI_POST_INTERNAL.isReady) {
        await initUmamiPostConfig();
    }

    if (!__UMAMI_POST_INTERNAL.isReady) {
        return null;
    }

    try {
        const endAt = Date.now();
        const startAt = 0;
        // Umami v3 API: use path=eq. prefix for exact match
        const apiUrl = `${UMAMI_POST_CONFIG.apiUrl}/websites/${__UMAMI_POST_INTERNAL.websiteId}/stats?startAt=${startAt}&endAt=${endAt}&unit=hour&timezone=Asia%2FShanghai&path=eq.${encodeURIComponent(path + '/')}`;

        const response = await fetch(apiUrl, {
            headers: {
                'x-umami-share-token': __UMAMI_POST_INTERNAL.shareToken
            }
        });

        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();

        return {
            pageviews: data.pageviews || 0,
            visits: data.visits || 0,
        };
    } catch (error) {
        console.error('Umami Post Fetch Failed:', error);
        return null;
    }
}

async function initPostViews() {
    const viewElements = document.querySelectorAll('.post-views');
    if (viewElements.length === 0) return;

    for (const el of viewElements) {
        const path = el.getAttribute('data-path');
        const countEl = el.querySelector('.post-views-count');
        if (!path || !countEl) continue;

        const stats = await fetchPostStats(path);
        if (stats) {
            countEl.textContent = formatNumber(stats.pageviews);
        } else {
            countEl.textContent = '-';
        }
    }
}

initPostViews();

if (window.swup) {
    window.swup.hooks.on('page:view', () => {
        initPostViews();
    });
}
</script>

自定义选项

修改统计周期

默认统计所有时间的数据(startAt = 0)。如需调整时间范围,可修改 startAt 参数:

// 最近24小时
const startAt = Date.now() - 86400000;

// 最近30天
const startAt = Date.now() - 2592000000;

// 最近90天
const startAt = Date.now() - 7776000000;

修改备用数据

如果 API 请求失败,组件会显示备用数据。可在 FALLBACK_STATS 中修改:

const FALLBACK_STATS = {
    pageviews: 1000,
    visits: 1000,
};

最终效果

完成以上步骤后,你将看到:

  1. 侧边栏统计卡片:实时显示博客总浏览量和总访问数,点击可跳转到 Umami 分享页
  2. 文章卡片浏览量:每篇文章显示独立的浏览量数据
  3. 数字动画:数据加载时带有平滑的增长动画
  4. SPA 兼容:使用 Swup 路由时数据会自动刷新

参考文献

继续阅读

相关
解决fuwari博客使用umami统计访问量时重复统计问题

本文解决在fuwari博客主题中使用umami进行访问量统计时有时会出现重复计算的问题

教程2026-06-07
部署你的Astrbot QQ机器人吧相关
部署你的Astrbot QQ机器人吧

从零开始部署一个功能强大的AstrBot QQ机器人,支持多种AI模型接入,实现智能群聊回复、私聊对话、自定义插件扩展等功能,成为赛博群老婆((

教程2026-06-18
相关
0成本将你的OneDrive部署至互联网,以及博客的一些小规划

使用CVercel和OneDrive API,无需服务器和域名即可将OneDrive网盘部署到互联网,实现文件在线预览和下载

教程2026-06-14
使用 AI 推理一键获取人声 MIDI:Vocal2Midi 项目体验随机
使用 AI 推理一键获取人声 MIDI:Vocal2Midi 项目体验

基于 Qwen3-ASR 与 GAME 模型的人声转 MIDI 工具 Vocal2Midi 体验分享,支持一键生成 MIDI / USTX / 对齐辅助文件,适配 UTAU、Vocaloid 等虚拟歌姬工作流。

有趣的项目2026-06-06
随机
介绍Filebase免费对象存储,以及快速搭建图床教程

本文主要介绍Filebase这个新的免费对象存储平台提供5 GB的免费存储空间,兼容S3端点,非常适合搭建图床服务

有趣的项目2026-07-27
哎我去这个Pages CMS真神了随机
哎我去这个Pages CMS真神了

通过向你的静态博客仓库导入Pages CMS实现在浏览器里优雅地管理你的文章

教程2026-07-04