为 Fuwari 添加 Umami 访问统计卡片
WARNING2026年4月16号后获取数据失败的问题已修复
WARNING2026年4月17日重大重构:本次重构引入了全自动配置解析、数据动画、离线备用数据以及点击跳转等功能,代码健壮性与交互体验大幅提升。建议及时根据最新教程更新代码。
开始
对于静态博客而言,了解访客来源与流量趋势是很有必要的。在不自建服务器的前提下,配置简单,功能强大的 Umami 往往是我们的首选。
然而,直接挂载 Umami 的分享外链不够直观且破坏页面一致性。本文将教你如何将 Umami 的统计数据以原生组件的形式集成到 Fuwari 主题的侧边栏中,让你的博客实时展示访问数据。
教程开始。
教程
准备
- 一个已部署好的fuwari博客
- 启用Umami统计的站点(如果你不知道什么是umami,请先自行搜索教程后继续阅读)
获取数据
为了获取到图中的这些数据,我们需要启用 Umami 统计的 分享 URL。
你应该会得到一个类似 https://cloud.umami.is/share/EkS4mYbIXLu9vshR 格式的链接。记下这个链接,现在我们只需要它就能完成所有配置。
添加组件
创建组件
在 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="text-center py-2"> <div class="text-3xl 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="grid grid-cols-2 divide-x divide-neutral-200 dark:divide-neutral-700 text-center pt-2"> <div class="px-2"> <div class="text-xl 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 class="px-2"> <div class="text-xl font-bold text-neutral-900 dark:text-neutral-100" id="total-visitors">-</div> <div class="text-sm text-neutral-500 dark:text-neutral-400">游客数</div> </div> </div> </a></WidgetLayout>
<script>const UMAMI_CONFIG = { shareUrl: '此处填写你刚才获取的分享链接',};
let __UMAMI_INTERNAL = { baseUrl: '', websiteId: '', shareToken: '', shareId: '', isReady: false};
const FALLBACK_STATS = { pageviews: 1000, visits: 1000, visitors: 1000,};
async function initUmamiConfig() { try { const sharePath = UMAMI_CONFIG.shareUrl.split('/share/')[1]; if (!sharePath) throw new Error('Invalid Umami Share URL');
const region = UMAMI_CONFIG.shareUrl.includes('/analytics/eu/') ? 'eu' : 'us'; const apiBase = `https://cloud.umami.is/analytics/${region}/api`;
const res = await fetch(`${apiBase}/share/${sharePath}`); const data = await res.json();
__UMAMI_INTERNAL = { baseUrl: apiBase, websiteId: data.websiteId, shareToken: data.token, shareId: data.shareId, isReady: true };
const link = document.getElementById('umami-link'); if (link) link.setAttribute('href', UMAMI_CONFIG.shareUrl);
} 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; visitors: number }) { const pageviewsElement = document.getElementById('total-pageviews'); const visitsElement = document.getElementById('total-visits'); const visitorsElement = document.getElementById('total-visitors');
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); animateStat(visitorsElement, values.visitors);}
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_INTERNAL.baseUrl}/websites/${__UMAMI_INTERNAL.websiteId}/stats?startAt=${startAt}&endAt=${endAt}&unit=hour&timezone=Asia%2FShanghai`;
const response = await fetch(url, { headers: { 'x-umami-share-context': '1', 'x-umami-share-token': __UMAMI_INTERNAL.shareToken } });
if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); const getValue = (field: any) => (typeof field === 'object' ? field?.value : field) || 0;
setStats({ pageviews: getValue(data.pageviews), visits: getValue(data.visits), visitors: getValue(data.visitors), });
} 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>配置参数
在代码文件的 script 部分,填入你的分享链接:
const UMAMI_CONFIG = { shareUrl: '你的 Umami 分享链接 (例如 https://cloud.umami.is/share/...)',};系统会自动解析 API 路径、Website ID 和 验证 Token。
添加到侧边栏组件
在 src/components/widget/SideBar.astro 中导入并使用此组件:
---import { Image } from "astro:assets";import type { MarkdownHeading } from "astro";import { Icon } from "astro-icon/components";import Categories from "./Categories.astro";import Profile from "./Profile.astro";import Sponsors from "./Sponsors.astro";import UmamiStats from "./UmamiStats.astro";import VisitorIP from "./VisitorIP.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"> <Categories class="onload-animation" style="animation-delay: 150ms"></Categories> <UmamiStats class="onload-animation" style="animation-delay: 200ms"></UmamiStats> <VisitorIP class="onload-animation" style="animation-delay: 225ms"></VisitorIP> <Sponsors class="onload-animation" style="animation-delay: 250ms"></Sponsors> </div></div>自定义
如果你按照上述的步骤操作,那么你应该就能在页面左侧下方看到你的博客浏览量统计了,下面是进阶自定义教程:
修改统计周期
默认为 所有时间 ,如果你想要调整浏览量统计的时间范围,可以修改 fetchUmamiStats 函数内的 startAt 参数。
常用时间周期公式(单位:毫秒):
- 24小时:
24 * 60 * 60 * 1000=86400000 - 最近30天:
30 * 24 * 60 * 60 * 1000=2592000000 - 最近90天:
90 * 24 * 60 * 60 * 1000=7776000000
修改示例(例如改为最近 30 天):
try { const endAt = Date.now(); const startAt = 0; const startAt = Date.now() - 2592000000;配置备用数据
如果遇到 API 请求失败,全新的组件会显示你配置的备用数据以保持 UI 美观。你可以在 FALLBACK_STATS 对象中修改这些数值(默认为1000):
const FALLBACK_STATS = { pageviews: 1000, // 备用总浏览量 visits: 1000, // 备用访问数 visitors: 1000, // 备用游客数};结尾
通过以上步骤,你就成功为 Fuwari 添加了具有丰富交互感、自动解析配置且支持点击查看详情的 Umami 统计卡片。