blog-v3 统计:Umami 访问趋势

blog-v3 统计:Umami 访问趋势

记录 blog-v3 统计页的设计与实现:公开聚合数据、近24小时逐小时趋势、日趋势、热力图和 Vercel 部署节奏。
统计页的原则

统计页展示的是站点聚合数据,不展示访客个人信息,也不把第三方分析服务的任意接口暴露成公共代理。

页面分成两类数据

当前统计页主要展示:

  • 概览 今日 PV、近 24 小时访客、近 24 小时 PV 和累计 PV。
  • 访问趋势 PV、访客和会话的时间序列。
  • 来源与内容 入口页面、来源网站、设备和浏览器等聚合排行。
  • 访问热力图 按北京时间展示一天中的访问分布。
  • 推送节奏 Vercel 生产部署历史的次数、间隔、连续推送和日期分布。

服务端统一调用固定的 Umami 分享接口,前端只请求博客自己的 /api/analytics:

text
浏览器 → /api/analytics → stats.moodlog.cn

这样做比让浏览器直接请求 Umami 更容易控制字段、错误和缓存。

默认显示近 24 小时

访问趋势现在默认使用 days=1,但这里的“1 天”不是自然日,而是:

text
当前时间 - 24 小时  ~  当前时间

数据按北京时间的小时边界分桶,因此通常会有 25 个时间点:第一个和最后一个点可能是不完整小时。

为什么不是 24 个点

24 小时范围跨越 25 个小时边界。保留首尾两个部分小时,可以让用户准确看到完整的滚动时间窗口,而不是悄悄丢掉边界数据。

接口返回粒度信息:

json
{
  "days": 1,
  "trendUnit": "hour",
  "startAt": 1791967877075,
  "endAt": 1792054277075,
  "trend": []
}

选择 7 天、30 天或 90 天后,接口切换回按日统计。

按小时绘制趋势

趋势图的每个点包含三个值:

ts
interface TrendPoint {
  date: string
  pageviews: number
  visitors: number
  sessions: number
}

PV 和会话可以从 Umami 的时间序列接口得到;访客需要按每个时间桶分别请求聚合统计,再合并到相同的小时桶中。

为了避免一次请求过多,访客统计按小批次执行:

ts
for (let i = 0; i < buckets.length; i += 6) {
  const rows = await Promise.all(
    buckets.slice(i, i + 6).map(bucket => getStats(bucket)),
  )
  counts.push(...rows.map(row => row.visitors))
}
小时访客不是全天独立访客

每小时访客是每个小时桶内独立去重的结果。一个访客在多个小时访问,可能在多个小时分别计数;它不能简单相加得到全天独立访客。

让坐标文字不跟着 SVG 缩小

早期版本把曲线、网格线和坐标文字全部放在固定 viewBox 中。手机上 SVG 等比例缩小时,坐标文字也会变小,日期还容易相互重叠。

现在的处理方式是:

  • 曲线和网格线继续使用 SVG
  • Y 轴文字改成普通 HTML 元素
  • X 轴时间文字也改成 HTML 元素
  • 坐标字号保持 13px
  • 首尾刻度分别左对齐和右对齐
  • 根据时间跨度减少中间刻度
css
.axis-x,
.axis-y {
  font-size: 13px;
  font-variant-numeric: tabular-nums;
}

这样文字不受 SVG viewBox 缩放影响,移动端更容易阅读。

推送节奏组件的移动端处理

推送组件包含三个指标:最近推送、累计推送和平均间隔。手机宽度有限时,如果三项强制并排,最近推送日期就会被省略。

响应式布局改成:

  • 桌面端:三列
  • 移动端:最近推送独占一行
  • 移动端:累计推送和平均间隔放在下面两列
  • 日期与时间拆成两行,不使用省略号
css
@container (max-width: 520px) {
  .rhythm-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .rhythm-latest {
    grid-column: 1 / -1;
  }
}

不要把 Vercel Token 返回给浏览器

推送节奏使用 Vercel API 时,Token 只能在服务端环境变量中读取:

ts
const token = process.env.VERCEL_TOKEN

接口只返回经过处理的公开数据,例如:

ts
const release = {
  total: 30,
  latest: '2026-10-14T10:55:32.778Z',
  recentInterval: 310037,
  averageInterval: 500000,
  streak: 3,
  days: [],
}

永远不要把 Token 写进:

  • app/ 前端代码
  • public/ 静态文件
  • Git 提交
  • 浏览器 URL
  • 错误响应

本地测试

sh
node scripts/test-analytics.mjs
node scripts/test-releases.mjs
pnpm exec eslint app/pages/stats.vue app/components/analytics/Trend.vue
pnpm build

接口验收可以直接检查默认响应:

sh
curl -s https://your-domain.example/api/analytics | jq '{days, trendUnit, points: (.trend | length)}'

预期结果类似:

json
{
  "days": 1,
  "trendUnit": "hour",
  "points": 25
}

小结

一个实用的统计页不只是把数字画出来,还要处理数据范围、时区、隐私、失败状态和小屏可读性。对个人博客来说,聚合数据已经足够回答几个重要问题:最近有没有人访问、哪些文章被看到、博客更新是否规律。

统计不是为了追逐数字,而是帮助我们知道哪些内容真的被读到,也帮助博客作者更有意识地持续写下去。

新故事即将发生
blog-v3 动态:接入 Memos

评论区

评论加载中...