[{"data":1,"prerenderedAt":370},["ShallowReactive",2],{"content:\u002Fumami-analytics-page":3,"surround:\u002Fumami-analytics-page":365},{"id":4,"title":5,"body":6,"categories":337,"date":339,"description":340,"draft":341,"extension":342,"image":343,"meta":344,"navigation":346,"path":347,"permalink":347,"published":348,"readingTime":349,"recommend":348,"references":348,"seo":354,"sitemap":355,"stem":356,"tags":357,"type":363,"updated":339,"__hash__":364},"content\u002Fposts\u002F2026\u002Fumami-analytics-page.md","blog-v3 统计：Umami 访问趋势",{"type":7,"value":8,"toc":325},"minimark",[9,18,22,25,63,71,81,84,88,95,101,104,111,114,122,125,128,131,139,142,145,151,158,162,169,172,192,200,206,209,212,215,229,235,239,242,248,251,257,260,283,286,294,297,303,306,312,315,318],[10,11,14],"alert",{"title":12,"type":13},"统计页的原则","info",[15,16,17],"p",{},"统计页展示的是站点聚合数据，不展示访客个人信息，也不把第三方分析服务的任意接口暴露成公共代理。",[19,20,21],"h2",{"id":21},"页面分成两类数据",[15,23,24],{},"当前统计页主要展示：",[26,27,28],"card-list",{},[29,30,31,39,45,51,57],"ul",{},[32,33,34,38],"li",{},[35,36,37],"strong",{},"概览"," 今日 PV、近 24 小时访客、近 24 小时 PV 和累计 PV。",[32,40,41,44],{},[35,42,43],{},"访问趋势"," PV、访客和会话的时间序列。",[32,46,47,50],{},[35,48,49],{},"来源与内容"," 入口页面、来源网站、设备和浏览器等聚合排行。",[32,52,53,56],{},[35,54,55],{},"访问热力图"," 按北京时间展示一天中的访问分布。",[32,58,59,62],{},[35,60,61],{},"推送节奏"," Vercel 生产部署历史的次数、间隔、连续推送和日期分布。",[15,64,65,66,70],{},"服务端统一调用固定的 Umami 分享接口，前端只请求博客自己的 ",[67,68,69],"code",{"code":69},"\u002Fapi\u002Fanalytics","：",[72,73,79],"pre",{"className":74,"code":76,"language":77,"meta":78},[75],"language-text","浏览器 → \u002Fapi\u002Fanalytics → stats.moodlog.cn\n","text","",[67,80,76],{"__ignoreMap":78},[15,82,83],{},"这样做比让浏览器直接请求 Umami 更容易控制字段、错误和缓存。",[19,85,87],{"id":86},"默认显示近-24-小时","默认显示近 24 小时",[15,89,90,91,94],{},"访问趋势现在默认使用 ",[67,92,93],{"code":93},"days=1","，但这里的“1 天”不是自然日，而是：",[72,96,99],{"className":97,"code":98,"language":77,"meta":78},[75],"当前时间 - 24 小时  ～  当前时间\n",[67,100,98],{"__ignoreMap":78},[15,102,103],{},"数据按北京时间的小时边界分桶，因此通常会有 25 个时间点：第一个和最后一个点可能是不完整小时。",[10,105,108],{"title":106,"type":107},"为什么不是 24 个点","tip",[15,109,110],{},"24 小时范围跨越 25 个小时边界。保留首尾两个部分小时，可以让用户准确看到完整的滚动时间窗口，而不是悄悄丢掉边界数据。",[15,112,113],{},"接口返回粒度信息：",[72,115,120],{"className":116,"code":118,"language":119,"meta":78},[117],"language-json","{\n  \"days\": 1,\n  \"trendUnit\": \"hour\",\n  \"startAt\": 1791967877075,\n  \"endAt\": 1792054277075,\n  \"trend\": []\n}\n","json",[67,121,118],{"__ignoreMap":78},[15,123,124],{},"选择 7 天、30 天或 90 天后，接口切换回按日统计。",[19,126,127],{"id":127},"按小时绘制趋势",[15,129,130],{},"趋势图的每个点包含三个值：",[72,132,137],{"className":133,"code":135,"language":136,"meta":78},[134],"language-ts","interface TrendPoint {\n  date: string\n  pageviews: number\n  visitors: number\n  sessions: number\n}\n","ts",[67,138,135],{"__ignoreMap":78},[15,140,141],{},"PV 和会话可以从 Umami 的时间序列接口得到；访客需要按每个时间桶分别请求聚合统计，再合并到相同的小时桶中。",[15,143,144],{},"为了避免一次请求过多，访客统计按小批次执行：",[72,146,149],{"className":147,"code":148,"language":136,"meta":78},[134],"for (let i = 0; i \u003C buckets.length; i += 6) {\n  const rows = await Promise.all(\n    buckets.slice(i, i + 6).map(bucket => getStats(bucket)),\n  )\n  counts.push(...rows.map(row => row.visitors))\n}\n",[67,150,148],{"__ignoreMap":78},[10,152,155],{"title":153,"type":154},"小时访客不是全天独立访客","warning",[15,156,157],{},"每小时访客是每个小时桶内独立去重的结果。一个访客在多个小时访问，可能在多个小时分别计数；它不能简单相加得到全天独立访客。",[19,159,161],{"id":160},"让坐标文字不跟着-svg-缩小","让坐标文字不跟着 SVG 缩小",[15,163,164,165,168],{},"早期版本把曲线、网格线和坐标文字全部放在固定 ",[67,166,167],{"code":167},"viewBox"," 中。手机上 SVG 等比例缩小时，坐标文字也会变小，日期还容易相互重叠。",[15,170,171],{},"现在的处理方式是：",[29,173,174,177,180,183,186,189],{},[32,175,176],{},"曲线和网格线继续使用 SVG",[32,178,179],{},"Y 轴文字改成普通 HTML 元素",[32,181,182],{},"X 轴时间文字也改成 HTML 元素",[32,184,185],{},"坐标字号保持 13px",[32,187,188],{},"首尾刻度分别左对齐和右对齐",[32,190,191],{},"根据时间跨度减少中间刻度",[72,193,198],{"className":194,"code":196,"language":197,"meta":78},[195],"language-css",".axis-x,\n.axis-y {\n  font-size: 13px;\n  font-variant-numeric: tabular-nums;\n}\n","css",[67,199,196],{"__ignoreMap":78},[15,201,202,203,205],{},"这样文字不受 SVG ",[67,204,167],{"code":167}," 缩放影响，移动端更容易阅读。",[19,207,208],{"id":208},"推送节奏组件的移动端处理",[15,210,211],{},"推送组件包含三个指标：最近推送、累计推送和平均间隔。手机宽度有限时，如果三项强制并排，最近推送日期就会被省略。",[15,213,214],{},"响应式布局改成：",[29,216,217,220,223,226],{},[32,218,219],{},"桌面端：三列",[32,221,222],{},"移动端：最近推送独占一行",[32,224,225],{},"移动端：累计推送和平均间隔放在下面两列",[32,227,228],{},"日期与时间拆成两行，不使用省略号",[72,230,233],{"className":231,"code":232,"language":197,"meta":78},[195],"@container (max-width: 520px) {\n  .rhythm-grid {\n    grid-template-columns: repeat(2, minmax(0, 1fr));\n  }\n\n  .rhythm-latest {\n    grid-column: 1 \u002F -1;\n  }\n}\n",[67,234,232],{"__ignoreMap":78},[19,236,238],{"id":237},"不要把-vercel-token-返回给浏览器","不要把 Vercel Token 返回给浏览器",[15,240,241],{},"推送节奏使用 Vercel API 时，Token 只能在服务端环境变量中读取：",[72,243,246],{"className":244,"code":245,"language":136,"meta":78},[134],"const token = process.env.VERCEL_TOKEN\n",[67,247,245],{"__ignoreMap":78},[15,249,250],{},"接口只返回经过处理的公开数据，例如：",[72,252,255],{"className":253,"code":254,"language":136,"meta":78},[134],"const release = {\n  total: 30,\n  latest: '2026-10-14T10:55:32.778Z',\n  recentInterval: 310037,\n  averageInterval: 500000,\n  streak: 3,\n  days: [],\n}\n",[67,256,254],{"__ignoreMap":78},[15,258,259],{},"永远不要把 Token 写进：",[29,261,262,268,274,277,280],{},[32,263,264,267],{},[67,265,266],{"code":266},"app\u002F"," 前端代码",[32,269,270,273],{},[67,271,272],{"code":272},"public\u002F"," 静态文件",[32,275,276],{},"Git 提交",[32,278,279],{},"浏览器 URL",[32,281,282],{},"错误响应",[19,284,285],{"id":285},"本地测试",[72,287,292],{"className":288,"code":290,"language":291,"meta":78},[289],"language-sh","node scripts\u002Ftest-analytics.mjs\nnode scripts\u002Ftest-releases.mjs\npnpm exec eslint app\u002Fpages\u002Fstats.vue app\u002Fcomponents\u002Fanalytics\u002FTrend.vue\npnpm build\n","sh",[67,293,290],{"__ignoreMap":78},[15,295,296],{},"接口验收可以直接检查默认响应：",[72,298,301],{"className":299,"code":300,"language":291,"meta":78},[289],"curl -s https:\u002F\u002Fyour-domain.example\u002Fapi\u002Fanalytics | jq '{days, trendUnit, points: (.trend | length)}'\n",[67,302,300],{"__ignoreMap":78},[15,304,305],{},"预期结果类似：",[72,307,310],{"className":308,"code":309,"language":119,"meta":78},[117],"{\n  \"days\": 1,\n  \"trendUnit\": \"hour\",\n  \"points\": 25\n}\n",[67,311,309],{"__ignoreMap":78},[19,313,314],{"id":314},"小结",[15,316,317],{},"一个实用的统计页不只是把数字画出来，还要处理数据范围、时区、隐私、失败状态和小屏可读性。对个人博客来说，聚合数据已经足够回答几个重要问题：最近有没有人访问、哪些文章被看到、博客更新是否规律。",[319,320,322],"quote",{"icon":321},"tabler:chart-line",[15,323,324],{},"统计不是为了追逐数字，而是帮助我们知道哪些内容真的被读到，也帮助博客作者更有意识地持续写下去。",{"title":78,"searchDepth":326,"depth":326,"links":327},4,[328,330,331,332,333,334,335,336],{"id":21,"depth":329,"text":21},2,{"id":86,"depth":329,"text":87},{"id":127,"depth":329,"text":127},{"id":160,"depth":329,"text":161},{"id":208,"depth":329,"text":208},{"id":237,"depth":329,"text":238},{"id":285,"depth":329,"text":285},{"id":314,"depth":329,"text":314},[338],"博客折腾","2026-10-16","记录 blog-v3 统计页的设计与实现：公开聚合数据、近24小时逐小时趋势、日趋势、热力图和 Vercel 部署节奏。",false,"md","https:\u002F\u002Fimg.moodlog.cn\u002Fi\u002F1\u002Fd532dc9feada45d523229056fedbe26c\u002F6ad04cfad3063.webp",{"slots":345},{},true,"\u002Fumami-analytics-page",null,{"text":350,"minutes":351,"time":352,"words":353},"6 min read",5.775,346500,1155,{"title":5,"description":340},{"loc":347},"posts\u002F2026\u002Fumami-analytics-page",[358,359,360,361,362],"Nuxt","Umami","Vercel","数据可视化","统计","story","UFRLjEG4PLiryILfx0wxguX4XOZFa3gqmRhUunvpMKc",[366,348],{"title":367,"path":368,"stem":369,"date":339,"type":363,"children":-1},"blog-v3 动态：接入 Memos","\u002Fmemos-dynamic-page","posts\u002F2026\u002Fmemos-dynamic-page",1792159297986]