TIP你可以直接将这篇文章丢给你的ai Agent,让它帮你实现。
为 Fuwari 博客添加 Umami 访问量统计
对于静态博客而言,了解访客来源与流量趋势是很有必要的。在不自建服务器的前提下,配置简单、功能强大的 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>记得将 apiUrl 和 shareId 替换为你自己的 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,};最终效果
完成以上步骤后,你将看到:
- 侧边栏统计卡片:实时显示博客总浏览量和总访问数,点击可跳转到 Umami 分享页
- 文章卡片浏览量:每篇文章显示独立的浏览量数据
- 数字动画:数据加载时带有平滑的增长动画
- SPA 兼容:使用 Swup 路由时数据会自动刷新
参考文献: