feat(stats): 新增公开数据页 /stats,入口收敛到页脚

首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /stats:
今日实时数、概览六格、近 30 天折线与 12 个月柱状、三个占比环、
7×24 时段热力图、提问字数与回答耗时分布。

- 聚合在 internal/db/analytics.go(SUM(CASE WHEN),方言差异收敛在 analyticsDialectFor)
- 结果按自然日缓存,跨日重算一次后全站共用;重算失败降级返回上一份快照
- 图表零前端依赖:服务端算坐标的 SVG + CSS grid,禁用 JS 页面仍完整
- 环比取「上月 vs 上上月」:本月未过完,否则每月 1 号会得出 -95%
- 入口只在页脚「社区规则」与「找站长」之间;route.Home 不再计算统计
- 补 questions(created_at) 索引;启动后异步预热当天快照
- 测试:analytics 连真库验证软删除不计入、分桶下标、DOW 对齐
This commit is contained in:
tamakyi
2026-10-24 00:57:08 +08:00
parent c680671b7e
commit a5441c99fd
25 changed files with 2280 additions and 80 deletions
+129
View File
@@ -0,0 +1,129 @@
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package template
import (
"fmt"
"strconv"
"strings"
)
// 图表辅助函数。
//
// 全站数据页刻意不引入任何前端图表库:本站历史上为消除一个 1.6KB 的外部 defer
// 脚本就把页脚计时器内联了,再挂一个 60KB+ 的 CDN 依赖并不划算。
// 需要坐标的只有折线(SVG),柱状 / 环形 / 热力图都能用 CSS 直接画。
// 因此这里只把「折线要的坐标」算出来,其余交给模板里的百分比与 grid。
// NumInt 把整数格式化成带千分位分隔的字符串,如 56321 → "56,321"。
func NumInt(v int64) string {
sign := ""
if v < 0 {
sign = "-"
v = -v
}
s := strconv.FormatInt(v, 10)
if len(s) <= 3 {
return sign + s
}
var b strings.Builder
lead := len(s) % 3
if lead > 0 {
b.WriteString(s[:lead])
}
for i := lead; i < len(s); i += 3 {
if b.Len() > 0 {
b.WriteByte(',')
}
b.WriteString(s[i : i+3])
}
return sign + b.String()
}
// PctText 返回 part 占 total 的百分比,保留一位小数(不含 % 号,便于模板拼接)。
func PctText(part, total int64) string {
if total <= 0 {
return "0.0"
}
return strconv.FormatFloat(float64(part)/float64(total)*100, 'f', 1, 64)
}
// ratio 把数值序列归一化到 [0,1],空序列或全零时返回全 0(不会出现除零)。
func ratioOf(values []int64) ([]float64, float64) {
maxV := int64(0)
for _, v := range values {
if v > maxV {
maxV = v
}
}
out := make([]float64, len(values))
if maxV == 0 {
return out, 0
}
for i, v := range values {
out[i] = float64(v) / float64(maxV)
}
return out, float64(maxV)
}
// Polyline 生成折线图的 points 属性值。
//
// w/h 是 viewBox 尺寸,padTop/padBottom 给顶部留呼吸空间、底部留给轴标签。
// 返回 "x1,y1 x2,y2 ...";空数据返回空串(模板里 SVG 折线自然消失)。
func Polyline(values []int64, w, h, padTop, padBottom float64) string {
n := len(values)
if n == 0 {
return ""
}
ratios, _ := ratioOf(values)
usable := h - padTop - padBottom
if usable < 1 {
usable = 1
}
var b strings.Builder
for i, r := range ratios {
var x float64
if n == 1 {
x = w / 2
} else {
x = float64(i) * w / float64(n-1)
}
y := padTop + usable - r*usable
if i > 0 {
b.WriteByte(' ')
}
fmt.Fprintf(&b, "%.1f,%.1f", x, y)
}
return b.String()
}
// AreaPath 把同一组数值渲染成闭合的面积路径,用于折线下方的浅色填充。
func AreaPath(values []int64, w, h, padTop, padBottom float64) string {
points := Polyline(values, w, h, padTop, padBottom)
if points == "" {
return ""
}
return "M0," + strconv.FormatFloat(h, 'f', 1, 64) + " L" +
strings.ReplaceAll(points, " ", " L") +
" L" + strconv.FormatFloat(w, 'f', 1, 64) + "," + strconv.FormatFloat(h, 'f', 1, 64) + " Z"
}
// HeatLevel 把热力图的一个格子映射到 0-4 的强度档位(0 表示没有提问)。
// 用档位而不是连续透明度:连续值在小方块上肉眼分不出来,反而让人怀疑渲染出错。
func HeatLevel(n, max int64) int {
if n <= 0 || max <= 0 {
return 0
}
switch {
case n*4 <= max:
return 1
case n*2 <= max:
return 2
case n*4 <= max*3:
return 3
default:
return 4
}
}
+8
View File
@@ -231,6 +231,14 @@ func FuncMap() []template.FuncMap {
"moderationStrictness": func(raw string) string {
return string(moderation.StrictnessFor(raw))
},
// 全站数据页的图表辅助:数字格式化 + 折线坐标(见 chart.go)。
// 站点不引前端图表库,柱状 / 环形 / 热力图都用 CSS 画,只有折线需要坐标。
"NumInt": NumInt,
"PctText": PctText,
"Polyline": Polyline,
"AreaPath": AreaPath,
"HeatLevel": HeatLevel,
}}
})
return funcMap