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
+27
View File
@@ -221,3 +221,30 @@ Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着
- 量手机宽度别用 `chrome --window-size=360`:Windows headless=old 有 ~500px 最小窗宽, - 量手机宽度别用 `chrome --window-size=360`:Windows headless=old 有 ~500px 最小窗宽,
实测 innerWidth 恒为 500。用 360px 宽 iframe + `--allow-file-access-from-files`, 实测 innerWidth 恒为 500。用 360px 宽 iframe + `--allow-file-access-from-files`,
结果由 iframe 里 `window.top.document.title` 带回,`--dump-dom` 读。 结果由 iframe 里 `window.top.document.title` 带回,`--dump-dom` 读。
### 手机端时间的年份:逐条补年,不做按年分组(DateCompact)
- `Template.FuncMap` 新增 `DateCompact`(内部 `dateCompact(t, now)`):同年 → `01-02 15:04`,
跨年 → `2006-01-02 15:04`。桌面端仍走 `tb-text` 的完整格式,不受影响。
- 用户问过「省了年要不要按年把问题隔开」——**不做**。理由是列表带游标分页(`*_cursor`)+ 可切
排序 + 4 个筛选 + 关键词搜索:组标题只按当前页算,翻页会重复出现同一年标题;换了排序
(最早→最新)或搜索时又会出现空组/错位。逐条补年份零结构成本、无耦合。
真要年份维度,顺序应是:①跨年补年(已做)②筛选栏加「年份」下拉(distinct year,走 SQL)。
- 实测(360 宽 306px):跨年格式占 109px(vs 同年 73px)。跨年 + 2 标签 + 2 按钮 = 309px,
差 3px 会在 360 宽折行;390 及以上的 2 标签组合都单行。预览数据里放了一条 2025 年条目用于核对。
## 全站数据页 /stats(替换首页统计卡)
- 首页底部「站点统计」三行小卡 → 换成一行摘要 + 入口按钮;完整数据在新公开页 `/stats`。
明细见 `topics/stats.md`。
- 新增:`internal/db/analytics.go`(聚合 SQL,方言收敛)、`internal/stats/overview.go`
(自然日缓存 + 补齐空日期)、`internal/stats/page.go`(i18n 标签 + 洞察文案)、
`route/stats.go`、`templates/stats.html`、`internal/template/chart.go`(折线坐标等 5 个函数)。
- 图表零依赖:折线 SVG,柱状/分布条用百分比高度,环形用 `pathLength=100` 的 dasharray,
热力图 CSS grid + 主色 opacity 分 4 档。
- 口径修正:环比改成「上月 vs 上上月」(本月残月比整月会在每月 1 号算出 -95%)。
- 补索引 `questions(created_at)`;启动后 3s 异步预热当天快照。
- 测试:`internal/db/analytics_test.go` 连真库验证(软删除不计入、分桶下标、DOW 对齐)、
`internal/stats/overview_test.go`(补齐日期、月底不溢出、除零)。全绿。
- 踩坑:预览页 base.css 用 `href="/static/..."` 绝对路径,file:// 下取不到 →
令牌全缺、图表全透明。`writePreview` 已改成同时重写 `href="/static/`。
+15
View File
@@ -0,0 +1,15 @@
# 2026-10-24
## /stats 入口收敛到页脚
- 用户要求:首页那张统计入口卡取消,入口只放页脚「社区规则」与「找站长」之间。
- 改动:
- `templates/home.html`:删掉统计入口卡整块 + `.tb-stats-entry*` 样式(原 `</details>` 后那一段)。
- `route/home.go`:`Home` 不再调 `stats.Get`(模板已不读 `.SiteStats`),去掉 `logrus` import;
`/api/v1/site-stats` 与 `SiteStatsAPI` 保留未动。
- 四份 i18n 字典删掉已无引用的 `site.stats.open`(`site.stats` 老词条保留)。
- 页脚 `templates/base/footer.html` 的 `site.footer.stats` 位置本就在 rules 与 admin 之间,无需再动。
- 验证:`go build` + `./internal/i18n` / `./templates` / `./route/...` 测试全绿;gofmt 无差异;
重新生成预览确认首页无 `tb-stats-entry`,页脚渲染为「社区规则 | 数据 | 找站长」。
- 教训:删掉页面唯一消费方后,服务端的取数调用(`stats.Get`)要同步删,
否则每次访问首页白算一次快照;对应 i18n 词条也要一起清,避免留死条目。
+1 -1
View File
@@ -3,7 +3,7 @@
明细已拆分到 `topics/*.md`(按需 Read 全文): 明细已拆分到 `topics/*.md`(按需 Read 全文):
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 / `coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 / `gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 /
`honeypot` 蜜罐多家族伪装 / `misc` 其它约定。 `honeypot` 蜜罐多家族伪装 / `stats` 全站数据页 / `misc` 其它约定。
## 协作 ## 协作
+66
View File
@@ -0,0 +1,66 @@
# 全站数据页(/stats)
2026-10 新增。完整数据在 `/stats`(公开、只读、纯服务端渲染)。
入口只有一处:**页脚「社区规则 | 数据 | 找站长」中间那格**
(`templates/base/footer.html`,词条 `site.footer.stats`)。
首页那张「站点统计」卡与后来的入口卡都已下线(用户明确要求首页不再放),
`route.Home` 也不再调 `stats.Get`;`/api/v1/site-stats` 接口保留未动。
新增入口不要再往首页加——那会让两处数字需要同步维护。
## 分层
```
db.Analytics.SiteAnalytics(ctx, now) // 纯 SQL 聚合,无缓存
→ stats.OverviewData(ctx) // 按自然日缓存(每天只算一次)
→ stats.NewPage(lang, ov) // i18n 标签 + 百分比 + 文字洞察
→ route.Stats → templates/stats.html
```
- `internal/db/analytics.go`:`SiteAnalyticsStore`。用 `SUM(CASE WHEN)` 而不是
`COUNT(*) FILTER`(MySQL 没有 FILTER)。方言差异(`to_char`/`DATE_FORMAT`、
`EXTRACT(DOW)`/`DAYOFWEEK-1`、`EPOCH`/`TIMESTAMPDIFF`)收敛在 `analyticsDialectFor`。
- `internal/stats/overview.go`:缓存 key 是**自然日字符串**,不是 TTL —— 换日即失效,
当天重算一次后全站共用。重算串行化(`ovRefresh`),失败时返回上一次成功的旧值。
- `internal/stats/page.go`:`Page` 嵌 `*Overview`。桶标签在 Go 侧拼好,别在模板里
`printf "stats.len.%s"` —— 缺词条会把 key 印到页面上。
- `internal/cmd/web.go`:启动后 3s 异步预热,把当天首访的等待挪出请求路径。
## 口径(改之前先看)
- 已回答 = `answer <> ''`;已读 = `is_read OR answer <> ''`。
- 匿名 = `asker_user_id = 0`;登录 = 总数 − 匿名。
- 回答耗时用 `updated_at - created_at`,但**置顶等写操作也会刷新 updated_at**,
所以只对外给「24 小时内占比」这种粗结论,不宣称中位数。
- 环比是「上月 vs 上上月」,不是「本月 vs 上月」:本月没过完,每月 1 号会得出 -95%。
- 累计与图表是当日 0 点快照,「今日提问/回答」两项实时。页面底部已写明。
## 图表实现(零前端库)
站点为消除一个 1.6KB 外部 defer 就把页脚计时器内联了,这里不再挂 60KB 图表库。
- 折线:`Polyline` / `AreaPath` 模板函数算坐标,SVG `viewBox` + `preserveAspectRatio="none"`
+ `vector-effect: non-scaling-stroke`(否则窄屏描边被压细)。
- 柱状 / 分布条:`style="height:{{printf "%.1f" $pct}}%"`,CSS 直接用百分比。
- 环形:`<circle pathLength="100" stroke-dasharray="{{pct}} 100">` + `rotate(-90)`,
不用算周长。
- 热力图:CSS grid 24 列 + `aspect-ratio:1`,档位用 `--tb-primary` 的 opacity 分 4 档
(连续透明度在小方块上肉眼分不出来)。
- 7×24 的 DOW 下标:PG `EXTRACT(DOW)` 与 Go `time.Weekday()` 都是周日=0;
展示顺序(周一起)在 `weekdayOrder` 里转。
## 预览与验证
- `TEST_PREVIEW=1 go test ./templates -run TestGenPreview` 出
`outputs/preview-stats-{zh-CN,en}-{light,dark}.html`。
- `writePreview` 会把 `href="/static/` 换成本地相对路径 —— 不换的话预览页走 file://,
`:root` 令牌全缺,`var(--tb-*)` 变无效值:图表全透明、卡片没边框(一度以为是渲染 bug)。
- `internal/db/analytics_test.go` 连真库跑(`DB_TYPE=postgres ...`),验证软删除不计入、
各分桶下标、DOW 对齐。对着 mock 断言等于没验证。
- 窄屏别用 `chrome --window-size=390`:Windows headless 最小窗宽约 485,实测 innerWidth=485,
截图再裁到 390 就成了假的"溢出"。用 390px 宽 iframe 包一层再截。
## 隐私红线
公开页只给聚合数字。不显示 IP、不显示单条内容、不做箱主排行(会招骚扰)。
「收到过提问的箱子」这类计数可以,具体是谁不行。
+11
View File
@@ -20,6 +20,7 @@ import (
"github.com/tamakyi/TamaBox/internal/route" "github.com/tamakyi/TamaBox/internal/route"
"github.com/tamakyi/TamaBox/internal/security/honeypot" "github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/shared" "github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/stats"
"github.com/tamakyi/TamaBox/internal/tracing" "github.com/tamakyi/TamaBox/internal/tracing"
) )
@@ -100,6 +101,16 @@ func runWeb(ctx *cli.Context) error {
r := route.New() r := route.New()
r.Use(tracing.Middleware("TamaBox")) r.Use(tracing.Middleware("TamaBox"))
// 全站数据页预热:整页聚合是若干次全表 GROUP BY,若摊在当天第一个访客身上,
// 首屏要白等一秒以上。这里延后几秒在后台先算一次,把等待挪出请求路径。
// 失败只告警:页面在真正被访问时会自己重算并(若仍失败)回退到上一次成功的结果。
go func() {
time.Sleep(3 * time.Second)
if _, err := stats.OverviewData(ctx.Context); err != nil {
logrus.WithError(err).Warn("预热全站统计数据失败")
}
}()
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时: // 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
// - ReadHeaderTimeout 防止慢速客户端占用连接 // - ReadHeaderTimeout 防止慢速客户端占用连接
// - ReadTimeout / WriteTimeout 防止读写挂死 // - ReadTimeout / WriteTimeout 防止读写挂死
+315
View File
@@ -0,0 +1,315 @@
// 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 db
import (
"context"
"fmt"
"time"
"github.com/pkg/errors"
"gorm.io/gorm"
)
// SiteAnalyticsStore 提供「全站公开数据页」所需的聚合查询。
//
// 与 internal/stats 的分工:本层只做数据库聚合(纯查询,不缓存、不生成文案),
// 缓存、补齐空日期与展示模型都在 stats 层。这样聚合 SQL 可以脱离 HTTP 单测。
type SiteAnalyticsStore interface {
SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error)
}
// Analytics 全站聚合查询的包级入口,在 Init 中随其他 store 一起初始化。
var Analytics SiteAnalyticsStore
// SiteAnalytics 是一次全站聚合的原始结果,所有计数都是「累及至今」或「窗口内」的口径,
// 具体窗口由调用方传入的 now 决定(见 stats 包的 Overview)。
type SiteAnalytics struct {
RegisteredUsers int64
TotalQuestions int64
Answered int64
Private int64
Read int64
Anonymous int64
ActiveBoxes int64 // 至少收到过一条提问的箱主数
AnsweringBoxes int64 // 至少回答过一条提问的箱主数
TotalMessages int64
LastQuestionAt *time.Time
LastAnswerAt *time.Time
Daily []CountPoint // 近 30 天(按日)
Monthly []CountPoint // 近 12 个月(按月)
// Heatmap 一周 7 天 × 24 小时的提问量,下标 0 为周日(与 EXTRACT(DOW) 对齐)。
Heatmap [7][24]int64
HeatmapDays int // Heatmap 的统计窗口天数,用于在页面上说明口径
LengthBins [6]int64 // 提问字数分布
ReplyBins [6]int64 // 回答耗时分布(仅统计已回答的提问)
TodayQuestions int64
TodayAnswers int64
}
// CountPoint 一个分组键上的计数,Key 已由数据库格式化成可直接展示的字符串。
type CountPoint struct {
Key string
N int64
}
func NewSiteAnalyticsStore(db *gorm.DB) SiteAnalyticsStore {
return &siteAnalytics{db}
}
type siteAnalytics struct {
*gorm.DB
}
const (
// 趋势窗口:近 30 天按日、近 12 个月按月。
analyticsDailyDays = 30
analyticsMonths = 12
// 热力图窗口:一年太贵(全表聚合),半年足以代表「全站习惯」。
analyticsHeatmapDays = 180
// 字数分桶上限(左闭右开),最后一桶为 500 以上。
lengthBinExpr = `CASE
WHEN CHAR_LENGTH(content) < 20 THEN 0
WHEN CHAR_LENGTH(content) < 50 THEN 1
WHEN CHAR_LENGTH(content) < 100 THEN 2
WHEN CHAR_LENGTH(content) < 200 THEN 3
WHEN CHAR_LENGTH(content) < 500 THEN 4
ELSE 5 END`
)
// analyticsDialect 把两套数据库里含义相同、写法不同的表达式收敛到一处。
//
// 项目实际部署用 PostgreSQL(见 Init 的索引分支),MySQL 分支只是保留可用性:
// 配置切到 MySQL 时不应整页 500,而应安静地少几个数。
type analyticsDialect struct {
day string
month string
dow string
hour string
replyMin string
}
func analyticsDialectFor(db *gorm.DB) analyticsDialect {
if db.Dialector.Name() == "mysql" {
return analyticsDialect{
day: "DATE_FORMAT(created_at, '%Y-%m-%d')",
month: "DATE_FORMAT(created_at, '%Y-%m')",
dow: "(DAYOFWEEK(created_at) - 1)",
hour: "HOUR(created_at)",
replyMin: "TIMESTAMPDIFF(MINUTE, created_at, updated_at)",
}
}
// PostgreSQL:EXTRACT 返回 numeric,显式转 int 以免扫描进 int64 时类型不匹配。
return analyticsDialect{
day: "to_char(created_at, 'YYYY-MM-DD')",
month: "to_char(created_at, 'YYYY-MM')",
dow: "CAST(EXTRACT(DOW FROM created_at) AS INTEGER)",
hour: "CAST(EXTRACT(HOUR FROM created_at) AS INTEGER)",
replyMin: "CAST(EXTRACT(EPOCH FROM (updated_at - created_at)) / 60 AS INTEGER)",
}
}
func (db *siteAnalytics) SiteAnalytics(ctx context.Context, now time.Time) (*SiteAnalytics, error) {
loc := now.Location()
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
dailySince := startOfDay.AddDate(0, 0, -(analyticsDailyDays - 1))
// 先归到当月 1 号再回退,避免月底 AddDate 溢出(3-31 减 11 个月会滚到 3-03)。
monthSince := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -(analyticsMonths - 1), 0)
heatmapSince := startOfDay.AddDate(0, 0, -analyticsHeatmapDays)
out := &SiteAnalytics{HeatmapDays: analyticsHeatmapDays}
if err := db.aggregateTotals(ctx, out); err != nil {
return nil, err
}
if err := db.aggregateTrends(ctx, out, dailySince, monthSince); err != nil {
return nil, err
}
if err := db.aggregateHeatmap(ctx, out, heatmapSince); err != nil {
return nil, err
}
if err := db.aggregateContent(ctx, out); err != nil {
return nil, err
}
if err := db.WithContext(ctx).Model(&User{}).Count(&out.RegisteredUsers).Error; err != nil {
return nil, errors.Wrap(err, "count registered users")
}
if err := db.WithContext(ctx).Model(&Message{}).Count(&out.TotalMessages).Error; err != nil {
return nil, errors.Wrap(err, "count messages")
}
// 今日数字单独查:它们不进「每天一次」的缓存窗口,页面顶部要显示实时值。
if err := db.WithContext(ctx).Model(&Question{}).
Where("created_at >= ?", startOfDay).Count(&out.TodayQuestions).Error; err != nil {
return nil, errors.Wrap(err, "count today's questions")
}
if err := db.WithContext(ctx).Model(&Question{}).
Where("answer <> '' AND updated_at >= ?", startOfDay).Count(&out.TodayAnswers).Error; err != nil {
return nil, errors.Wrap(err, "count today's answers")
}
return out, nil
}
// aggregateTotals 用一条 SQL 取回所有「不分时间窗口」的计数。
//
// 用 SUM(CASE WHEN ...) 而不是 COUNT(*) FILTER (WHERE ...):前者 MySQL 也支持,
// 后者只在 PostgreSQL 上可用。
func (db *siteAnalytics) aggregateTotals(ctx context.Context, out *SiteAnalytics) error {
var row struct {
Total int64
Answered int64
Private int64
Read int64
Anonymous int64
ActiveBoxes int64 `gorm:"column:active_boxes"`
AnsweringBoxes int64 `gorm:"column:answering_boxes"`
LastQ *time.Time `gorm:"column:last_q"`
LastA *time.Time `gorm:"column:last_a"`
}
err := db.WithContext(ctx).Model(&Question{}).
Select(`COUNT(*) AS total,
COALESCE(SUM(CASE WHEN answer <> '' THEN 1 ELSE 0 END), 0) AS answered,
COALESCE(SUM(CASE WHEN is_private THEN 1 ELSE 0 END), 0) AS private,
COALESCE(SUM(CASE WHEN is_read OR answer <> '' THEN 1 ELSE 0 END), 0) AS read,
COALESCE(SUM(CASE WHEN asker_user_id = 0 THEN 1 ELSE 0 END), 0) AS anonymous,
COUNT(DISTINCT user_id) AS active_boxes,
COUNT(DISTINCT CASE WHEN answer <> '' THEN user_id END) AS answering_boxes,
MAX(created_at) AS last_q,
MAX(CASE WHEN answer <> '' THEN updated_at END) AS last_a`).
Scan(&row).Error
if err != nil {
return errors.Wrap(err, "aggregate question totals")
}
out.TotalQuestions = row.Total
out.Answered = row.Answered
out.Private = row.Private
out.Read = row.Read
out.Anonymous = row.Anonymous
out.ActiveBoxes = row.ActiveBoxes
out.AnsweringBoxes = row.AnsweringBoxes
out.LastQuestionAt = row.LastQ
out.LastAnswerAt = row.LastA
return nil
}
// aggregateTrends 按日与按月各取一次计数。未被 GROUP BY 覆盖的空日期由 stats 层补齐。
func (db *siteAnalytics) aggregateTrends(ctx context.Context, out *SiteAnalytics, dailySince, monthSince time.Time) error {
d := analyticsDialectFor(db.DB)
var daily []struct {
Key string
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.day+" AS key, COUNT(*) AS n").
Where("created_at >= ?", dailySince).
Group(d.day).Order(d.day).
Scan(&daily).Error; err != nil {
return errors.Wrap(err, "aggregate daily trend")
}
for _, row := range daily {
out.Daily = append(out.Daily, CountPoint{Key: row.Key, N: row.N})
}
var monthly []struct {
Key string
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.month+" AS key, COUNT(*) AS n").
Where("created_at >= ?", monthSince).
Group(d.month).Order(d.month).
Scan(&monthly).Error; err != nil {
return errors.Wrap(err, "aggregate monthly trend")
}
for _, row := range monthly {
out.Monthly = append(out.Monthly, CountPoint{Key: row.Key, N: row.N})
}
return nil
}
func (db *siteAnalytics) aggregateHeatmap(ctx context.Context, out *SiteAnalytics, since time.Time) error {
d := analyticsDialectFor(db.DB)
var rows []struct {
Dow int
Hr int
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(d.dow+" AS dow, "+d.hour+" AS hr, COUNT(*) AS n").
Where("created_at >= ?", since).
Group(d.dow + ", " + d.hour).
Scan(&rows).Error; err != nil {
return errors.Wrap(err, "aggregate weekday/hour heatmap")
}
for _, row := range rows {
// 越界值直接丢弃:MySQL 的 DAYOFWEEK 或异常时区数据都可能给出 7 以上的下标,
// 落进固定数组会 panic,而这份数据只影响一张图,不值得让整页失败。
if row.Dow < 0 || row.Dow > 6 || row.Hr < 0 || row.Hr > 23 {
continue
}
out.Heatmap[row.Dow][row.Hr] = row.N
}
return nil
}
func (db *siteAnalytics) aggregateContent(ctx context.Context, out *SiteAnalytics) error {
d := analyticsDialectFor(db.DB)
var lengths []struct {
Bin int
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(lengthBinExpr + " AS bin, COUNT(*) AS n").
Group(lengthBinExpr).
Scan(&lengths).Error; err != nil {
return errors.Wrap(err, "aggregate content length distribution")
}
for _, row := range lengths {
if row.Bin >= 0 && row.Bin < len(out.LengthBins) {
out.LengthBins[row.Bin] = row.N
}
}
// 回答耗时:只用已回答的提问,按分钟分桶。
// 口径注意:updated_at 也会被置顶等写操作刷新,因此这里算出的耗时偏大;
// 展示时只呈现「24 小时内回答占比」这类粗粒度结论,不宣称精确中位数。
replyBinExpr := fmt.Sprintf(`CASE
WHEN %[1]s < 60 THEN 0
WHEN %[1]s < 360 THEN 1
WHEN %[1]s < 1440 THEN 2
WHEN %[1]s < 4320 THEN 3
WHEN %[1]s < 10080 THEN 4
ELSE 5 END`, d.replyMin)
var replies []struct {
Bin int
N int64
}
if err := db.WithContext(ctx).Model(&Question{}).
Select(replyBinExpr + " AS bin, COUNT(*) AS n").
Where("answer <> ''").
Group(replyBinExpr).
Scan(&replies).Error; err != nil {
return errors.Wrap(err, "aggregate reply latency distribution")
}
for _, row := range replies {
if row.Bin >= 0 && row.Bin < len(out.ReplyBins) {
out.ReplyBins[row.Bin] = row.N
}
}
return nil
}
+136
View File
@@ -0,0 +1,136 @@
// 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 db
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestSiteAnalytics 验证 /stats 页的聚合口径。
//
// 这里刻意连真库跑而不是塞 mock:整段都是数据库方言相关的 SQL
// (to_char / EXTRACT / CHAR_LENGTH / SUM(CASE WHEN)),
// 对着 mock 断言等于什么都没验证。
func TestSiteAnalytics(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("users", "questions", "messages"); err != nil {
t.Fatal(err)
}
})
ctx := context.Background()
now := time.Now()
store := NewSiteAnalyticsStore(db)
questions := NewQuestionsStore(db)
require.NoError(t, db.Create(&User{UID: "u1", Name: "box", Domain: "boxa", Email: "[email protected]"}).Error)
// 一条登录用户提的、已回答的长问题
answered, err := questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: strings.Repeat("字", 120), FromIP: "1.1.1.1", AskerUserID: 7,
})
require.NoError(t, err)
require.NoError(t, questions.AnswerByID(ctx, answered.ID, "答案"))
// 一条匿名提的私密短问题,未读未答
_, err = questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: "短问题", FromIP: "1.1.1.2", IsPrivate: true,
})
require.NoError(t, err)
// 一条被软删除的:不该出现在任何数字里
deleted, err := questions.Create(ctx, CreateQuestionOptions{
UserID: 1, Content: "会被删掉", FromIP: "1.1.1.3",
})
require.NoError(t, err)
require.NoError(t, questions.DeleteByID(ctx, deleted.ID))
got, err := store.SiteAnalytics(ctx, now)
require.NoError(t, err)
// 计数
require.EqualValues(t, 1, got.RegisteredUsers)
require.EqualValues(t, 2, got.TotalQuestions, "软删除的提问不能计入总数")
require.EqualValues(t, 1, got.Answered)
require.EqualValues(t, 1, got.Private)
require.EqualValues(t, 1, got.Read, "已回答的提问恒为已读")
require.EqualValues(t, 1, got.Anonymous)
require.EqualValues(t, 1, got.ActiveBoxes)
require.EqualValues(t, 1, got.AnsweringBoxes)
// 今日(提问是刚才写的,必然落在今天)
require.EqualValues(t, 2, got.TodayQuestions)
require.EqualValues(t, 1, got.TodayAnswers)
// 日/月趋势里能找到今天与本月
today := now.Format("2006-01-02")
require.EqualValues(t, 2, countOf(got.Daily, today))
require.EqualValues(t, 2, countOf(got.Monthly, now.Format("2006-01")))
// 热力图:两条提问都发生在当前这一小时
dow := int(now.Weekday()) // Go 的 Weekday 与 EXTRACT(DOW) 都是周日 0
require.EqualValues(t, 2, got.Heatmap[dow][now.Hour()])
// 字数分桶:120 字落在 100–199(下标 3),"短问题" 3 字落在 <20(下标 0)
require.EqualValues(t, 1, got.LengthBins[0])
require.EqualValues(t, 1, got.LengthBins[3])
// 回答耗时:刚回答完,落在 1 小时内(下标 0);未回答的不计入
require.EqualValues(t, 1, got.ReplyBins[0])
var replyTotal int64
for _, n := range got.ReplyBins {
replyTotal += n
}
require.EqualValues(t, 1, replyTotal, "耗时分布只统计已回答的提问")
// 最后时间戳
require.NotNil(t, got.LastQuestionAt)
require.NotNil(t, got.LastAnswerAt)
}
// TestSiteAnalyticsEmpty 空库不应报错,也不应把零值算成异常。
func TestSiteAnalyticsEmpty(t *testing.T) {
t.Parallel()
db, cleanup := newTestDB(t)
t.Cleanup(func() {
if err := cleanup("users", "questions", "messages"); err != nil {
t.Fatal(err)
}
})
got, err := NewSiteAnalyticsStore(db).SiteAnalytics(context.Background(), time.Now())
require.NoError(t, err)
require.Zero(t, got.TotalQuestions)
require.Zero(t, got.RegisteredUsers)
require.Nil(t, got.LastQuestionAt)
require.Nil(t, got.LastAnswerAt)
var heatTotal int64
for _, row := range got.Heatmap {
for _, n := range row {
heatTotal += n
}
}
require.Zero(t, heatTotal)
}
func countOf(points []CountPoint, key string) int64 {
for _, p := range points {
if p.Key == key {
return p.N
}
}
return 0
}
+4
View File
@@ -127,6 +127,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
Restrictions = NewRestrictionsStore(db) Restrictions = NewRestrictionsStore(db)
Reports = NewReportsStore(db) Reports = NewReportsStore(db)
Honeypots = NewHoneypotStore(db) Honeypots = NewHoneypotStore(db)
Analytics = NewSiteAnalyticsStore(db)
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段, // 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
// 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。 // 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。
@@ -217,6 +218,9 @@ func dedupeUniqueFields(db *gorm.DB) error {
func ensureFrequentlyUsedIndexes(db *gorm.DB) error { func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
indexes := []string{ indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_questions_asker_user_id ON questions(asker_user_id)`, `CREATE INDEX IF NOT EXISTS idx_questions_asker_user_id ON questions(asker_user_id)`,
// 全站数据页(/stats):按 created_at 做范围扫描后按天/小时分组。
// 没有这个索引时,趋势与热力图会退化成全表扫描 + 排序;加上之后是范围扫描 + 聚合。
`CREATE INDEX IF NOT EXISTS idx_questions_created_at ON questions(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_questions_user_private_created ON questions(user_id, is_private, created_at)`, `CREATE INDEX IF NOT EXISTS idx_questions_user_private_created ON questions(user_id, is_private, created_at)`,
// 「下一条未读」:user_id + is_read 过滤后按 created_at 取最早一条。 // 「下一条未读」:user_id + is_read 过滤后按 created_at 取最早一条。
// 上面那条中间隔着 is_private,帮不上这个排序。 // 上面那条中间隔着 is_private,帮不上这个排序。
+77
View File
@@ -951,4 +951,81 @@ var enDict = map[string]string{
"theme.name.sakura": "Sakura", "theme.name.sakura": "Sakura",
"theme.name.shiroko": "Shiroko", "theme.name.shiroko": "Shiroko",
"theme.name.new-sakura": "New Sakura", "theme.name.new-sakura": "New Sakura",
// Site statistics page (/stats): aggregate numbers only
"site.footer.stats": "Stats",
"stats.title": "Site statistics",
"stats.lead": "Only aggregate numbers here: how many questions the site receives, how fast they get answered, and when people tend to show up. No individual question, asker identity, or source address is shown.",
"stats.unavailable": "Statistics are temporarily unavailable. Please check back later.",
"stats.today.questions": "Asked today",
"stats.today.answers": "Answered today",
"stats.today.tag": "(live)",
"stats.kpi.users": "Registered users",
"stats.kpi.questions": "Questions asked",
"stats.kpi.answered": "Answered",
"stats.kpi.answerRate": "Answer rate",
"stats.kpi.activeBoxes": "Boxes that received questions",
"stats.kpi.messages": "Direct messages",
"stats.insight.title": "At a glance",
"stats.insight.note": "Each line below is read straight from the numbers above — no guesswork.",
"stats.insight.empty": "Not enough question data yet.",
"stats.insight.daily30": "%s questions in the last 30 days, about %.1f per day.",
"stats.insight.peak": "Busiest day was %s with %s questions.",
"stats.insight.busiest": "Questions cluster around %s %d:00.",
"stats.insight.mom": "Last month versus the month before: %s%% questions.",
"stats.insight.fastReply": "%s%% of answered questions got a reply within 24 hours.",
"stats.insight.private": "%s%% of questions were sent privately.",
"stats.trend.dailyTitle": "Questions over the last 30 days",
"stats.trend.dailyNote": "Daily question count.",
"stats.trend.monthlyTitle": "Questions per month (last 12)",
"stats.trend.monthlyNote": "The last bar is the current month, still in progress.",
"stats.mix.title": "Composition",
"stats.mix.note": "Three ratios: public or private, answered or not, and whether the asker was signed in.",
"stats.mix.public": "Public questions",
"stats.mix.answered": "Answered",
"stats.mix.login": "From signed-in users",
"stats.heat.title": "When questions arrive",
"stats.heat.note": "Questions from the last %d days grouped by weekday and hour — darker means busier.",
"stats.heat.less": "Less",
"stats.heat.more": "More",
"stats.content.title": "Length and pace",
"stats.content.note": "How long questions are, and how fast they get answered.",
"stats.content.lengthTitle": "Question length",
"stats.content.replyTitle": "Time to answer",
"stats.len.1": "under 20 chars",
"stats.len.2": "20–49 chars",
"stats.len.3": "50–99 chars",
"stats.len.4": "100–199 chars",
"stats.len.5": "200–499 chars",
"stats.len.6": "500+ chars",
"stats.reply.1": "within 1 hour",
"stats.reply.2": "1–6 hours",
"stats.reply.3": "6–24 hours",
"stats.reply.4": "1–3 days",
"stats.reply.5": "3–7 days",
"stats.reply.6": "over 7 days",
"stats.wd.1": "Mon",
"stats.wd.2": "Tue",
"stats.wd.3": "Wed",
"stats.wd.4": "Thu",
"stats.wd.5": "Fri",
"stats.wd.6": "Sat",
"stats.wd.7": "Sun",
"stats.foot.dataDate": "Data as of",
"stats.foot.updated": "computed at",
"stats.foot.prevMonth": "Last question:",
"stats.foot.prevAnswer": "Last answer:",
"stats.foot.privacy": "All figures are aggregates; no individual question, asker, or source address is shown.",
"stats.foot.cacheNote": "Totals and charts are a snapshot taken at 00:00 local time, refreshed once a day; the two \"today\" figures are live.",
} }
+77
View File
@@ -950,4 +950,81 @@ var jaDict = map[string]string{
"theme.name.sakura": "さくら", "theme.name.sakura": "さくら",
"theme.name.shiroko": "シロコ", "theme.name.shiroko": "シロコ",
"theme.name.new-sakura": "新さくら", "theme.name.new-sakura": "新さくら",
// サイト統計ページ(/stats):集計値のみ
"site.footer.stats": "統計",
"stats.title": "サイト統計",
"stats.lead": "ここにあるのは集計値だけです。サイト全体にどれだけ質問が届き、どれくらいで回答され、いつ人が来るのか。個々の質問内容・質問者の身元・送信元アドレスは一切表示しません。",
"stats.unavailable": "統計データを一時的に取得できません。しばらくしてから再度お試しください。",
"stats.today.questions": "今日の質問",
"stats.today.answers": "今日の回答",
"stats.today.tag": "(リアルタイム)",
"stats.kpi.users": "登録ユーザー",
"stats.kpi.questions": "質問の総数",
"stats.kpi.answered": "回答済み",
"stats.kpi.answerRate": "回答率",
"stats.kpi.activeBoxes": "質問を受け取った箱",
"stats.kpi.messages": "DM の総数",
"stats.insight.title": "ひと目でわかる数字",
"stats.insight.note": "以下の文はすべて上の数値から直接読み取ったもので、推測は含みません。",
"stats.insight.empty": "まだ分析できるほどの質問データがありません。",
"stats.insight.daily30": "直近 30 日で %s 件の質問、1 日あたり約 %.1f 件。",
"stats.insight.peak": "最も多かった日は %s で %s 件でした。",
"stats.insight.busiest": "質問は %s %d 時ごろに集中しています。",
"stats.insight.mom": "先月は前月比 %s%%。",
"stats.insight.fastReply": "回答済みのうち %s%% が 24 時間以内に回答されています。",
"stats.insight.private": "質問の %s%% は非公開が選ばれています。",
"stats.trend.dailyTitle": "直近 30 日の質問数",
"stats.trend.dailyNote": "1 日ごとの質問数です。",
"stats.trend.monthlyTitle": "直近 12 か月の質問数",
"stats.trend.monthlyNote": "最後の棒は当月で、まだ集計中です。",
"stats.mix.title": "内訳",
"stats.mix.note": "3 つの割合:公開か非公開か、回答済みか、ログイン済みの利用者か。",
"stats.mix.public": "公開の質問",
"stats.mix.answered": "回答済み",
"stats.mix.login": "ログイン利用者の質問",
"stats.heat.title": "質問が届く時間帯",
"stats.heat.note": "直近 %d 日の質問を「曜日 × 時間」で集計。色が濃いほど集中しています。",
"stats.heat.less": "少",
"stats.heat.more": "多",
"stats.content.title": "長さと速さ",
"stats.content.note": "質問の長さと、回答までの速さ。",
"stats.content.lengthTitle": "質問の文字数",
"stats.content.replyTitle": "回答までの時間",
"stats.len.1": "20 文字未満",
"stats.len.2": "20–49 文字",
"stats.len.3": "50–99 文字",
"stats.len.4": "100–199 文字",
"stats.len.5": "200–499 文字",
"stats.len.6": "500 文字以上",
"stats.reply.1": "1 時間以内",
"stats.reply.2": "1–6 時間",
"stats.reply.3": "6–24 時間",
"stats.reply.4": "1–3 日",
"stats.reply.5": "3–7 日",
"stats.reply.6": "7 日以上",
"stats.wd.1": "月",
"stats.wd.2": "火",
"stats.wd.3": "水",
"stats.wd.4": "木",
"stats.wd.5": "金",
"stats.wd.6": "土",
"stats.wd.7": "日",
"stats.foot.dataDate": "集計基準日:",
"stats.foot.updated": "算出時刻",
"stats.foot.prevMonth": "最後の質問:",
"stats.foot.prevAnswer": "最後の回答:",
"stats.foot.privacy": "数値はすべて集計値です。個々の質問内容・質問者の身元・送信元アドレスは表示しません。",
"stats.foot.cacheNote": "累計値とグラフは当日 0 時のスナップショットで、1 日 1 回だけ更新されます。「今日」の 2 項目はリアルタイムです。",
} }
+77
View File
@@ -951,4 +951,81 @@ var zhCNDict = map[string]string{
"form.register.recaptcha": "无法加载人机验证,请刷新页面或切换网络后重试", "form.register.recaptcha": "无法加载人机验证,请刷新页面或切换网络后重试",
"form.register.agreeRules": "请先阅读并同意社区规则", "form.register.agreeRules": "请先阅读并同意社区规则",
"form.register.repeatPassword": "两次输入的密码不一致", "form.register.repeatPassword": "两次输入的密码不一致",
// 全站数据页(/stats):只展示聚合数字,不含任何单条内容
"site.footer.stats": "数据",
"stats.title": "全站数据",
"stats.lead": "这里只有汇总后的数字:全站收到了多少提问、回答得有多快、大家习惯什么时候来。不含任何一条具体提问的内容、提问者身份或来源地址。",
"stats.unavailable": "数据暂时不可用,稍后再刷新看看。",
"stats.today.questions": "今日提问",
"stats.today.answers": "今日回答",
"stats.today.tag": "(实时)",
"stats.kpi.users": "注册用户",
"stats.kpi.questions": "累计提问",
"stats.kpi.answered": "已回答",
"stats.kpi.answerRate": "回答率",
"stats.kpi.activeBoxes": "收到过提问的箱子",
"stats.kpi.messages": "累计私聊消息",
"stats.insight.title": "数据速览",
"stats.insight.note": "下面几句是从上面的数字直接读出来的,没有推测。",
"stats.insight.empty": "提问数据还太少,暂时看不出什么。",
"stats.insight.daily30": "近 30 天共收到 %s 条提问,日均 %.1f 条。",
"stats.insight.peak": "最忙的一天是 %s,收到 %s 条提问。",
"stats.insight.busiest": "提问最集中在 %s %d 点前后。",
"stats.insight.mom": "上月提问量比前一个月 %s%%。",
"stats.insight.fastReply": "已回答的提问中,%s%% 在 24 小时内拿到了回复。",
"stats.insight.private": "%s%% 的提问选择了私密(不公开)。",
"stats.trend.dailyTitle": "近 30 天提问趋势",
"stats.trend.dailyNote": "按天统计的提问量。",
"stats.trend.monthlyTitle": "近 12 个月提问量",
"stats.trend.monthlyNote": "最后一根柱子是当月,还没过完。",
"stats.mix.title": "构成",
"stats.mix.note": "三组比例:公开还是私密、有没有被回答、提问者有没有登录。",
"stats.mix.public": "公开提问",
"stats.mix.answered": "已回答",
"stats.mix.login": "登录用户提问",
"stats.heat.title": "一周内的提问时段",
"stats.heat.note": "最近 %d 天的提问按「星期 × 小时」聚合,颜色越深表示越集中。",
"stats.heat.less": "少",
"stats.heat.more": "多",
"stats.content.title": "内容与节奏",
"stats.content.note": "提问有多长,回答有多快。",
"stats.content.lengthTitle": "提问字数分布",
"stats.content.replyTitle": "回答耗时分布",
"stats.len.1": "20 字以内",
"stats.len.2": "20–49 字",
"stats.len.3": "50–99 字",
"stats.len.4": "100–199 字",
"stats.len.5": "200–499 字",
"stats.len.6": "500 字以上",
"stats.reply.1": "1 小时内",
"stats.reply.2": "1–6 小时",
"stats.reply.3": "6–24 小时",
"stats.reply.4": "1–3 天",
"stats.reply.5": "3–7 天",
"stats.reply.6": "7 天以上",
"stats.wd.1": "一",
"stats.wd.2": "二",
"stats.wd.3": "三",
"stats.wd.4": "四",
"stats.wd.5": "五",
"stats.wd.6": "六",
"stats.wd.7": "日",
"stats.foot.dataDate": "数据基准日:",
"stats.foot.updated": "计算于",
"stats.foot.prevMonth": "最后一条提问:",
"stats.foot.prevAnswer": "最后一条回答:",
"stats.foot.privacy": "所有数字均为聚合结果,不展示单条提问内容、提问者身份或来源地址。",
"stats.foot.cacheNote": "累计数字与图表是当日 0 点的快照,每天只更新一次;「今日」两项为实时数字。",
} }
+77
View File
@@ -950,4 +950,81 @@ var zhTWDict = map[string]string{
"theme.name.sakura": "櫻花", "theme.name.sakura": "櫻花",
"theme.name.shiroko": "砂狼白子", "theme.name.shiroko": "砂狼白子",
"theme.name.new-sakura": "新·櫻花", "theme.name.new-sakura": "新·櫻花",
// 全站數據頁(/stats):只顯示彙總數字,不含任何單則內容
"site.footer.stats": "數據",
"stats.title": "全站數據",
"stats.lead": "這裡只有彙總後的數字:全站收到多少提問、回答得有多快、大家習慣什麼時候來。不含任何一則具體提問的內容、提問者身分或來源位址。",
"stats.unavailable": "數據暫時無法使用,稍後再重新整理看看。",
"stats.today.questions": "今日提問",
"stats.today.answers": "今日回答",
"stats.today.tag": "(即時)",
"stats.kpi.users": "註冊使用者",
"stats.kpi.questions": "累計提問",
"stats.kpi.answered": "已回答",
"stats.kpi.answerRate": "回答率",
"stats.kpi.activeBoxes": "收到過提問的箱子",
"stats.kpi.messages": "累計私聊訊息",
"stats.insight.title": "數據速覽",
"stats.insight.note": "下面幾句是直接從上面的數字讀出來的,沒有推測。",
"stats.insight.empty": "提問數據還太少,暫時看不出什麼。",
"stats.insight.daily30": "近 30 天共收到 %s 則提問,日均 %.1f 則。",
"stats.insight.peak": "最忙的一天是 %s,收到 %s 則提問。",
"stats.insight.busiest": "提問最集中在 %s %d 點前後。",
"stats.insight.mom": "上月提問量比前一個月 %s%%。",
"stats.insight.fastReply": "已回答的提問中,%s%% 在 24 小時內得到回覆。",
"stats.insight.private": "%s%% 的提問選擇了私密(不公開)。",
"stats.trend.dailyTitle": "近 30 天提問趨勢",
"stats.trend.dailyNote": "依天統計的提問量。",
"stats.trend.monthlyTitle": "近 12 個月提問量",
"stats.trend.monthlyNote": "最後一根柱子是當月,還沒過完。",
"stats.mix.title": "組成",
"stats.mix.note": "三組比例:公開還是私密、有沒有被回答、提問者有沒有登入。",
"stats.mix.public": "公開提問",
"stats.mix.answered": "已回答",
"stats.mix.login": "登入使用者提問",
"stats.heat.title": "一週內的提問時段",
"stats.heat.note": "最近 %d 天的提問依「星期 × 小時」彙總,顏色越深表示越集中。",
"stats.heat.less": "少",
"stats.heat.more": "多",
"stats.content.title": "內容與節奏",
"stats.content.note": "提問有多長,回答有多快。",
"stats.content.lengthTitle": "提問字數分布",
"stats.content.replyTitle": "回答耗時分布",
"stats.len.1": "20 字以內",
"stats.len.2": "20–49 字",
"stats.len.3": "50–99 字",
"stats.len.4": "100–199 字",
"stats.len.5": "200–499 字",
"stats.len.6": "500 字以上",
"stats.reply.1": "1 小時內",
"stats.reply.2": "1–6 小時",
"stats.reply.3": "6–24 小時",
"stats.reply.4": "1–3 天",
"stats.reply.5": "3–7 天",
"stats.reply.6": "7 天以上",
"stats.wd.1": "一",
"stats.wd.2": "二",
"stats.wd.3": "三",
"stats.wd.4": "四",
"stats.wd.5": "五",
"stats.wd.6": "六",
"stats.wd.7": "日",
"stats.foot.dataDate": "數據基準日:",
"stats.foot.updated": "計算於",
"stats.foot.prevMonth": "最後一則提問:",
"stats.foot.prevAnswer": "最後一則回答:",
"stats.foot.privacy": "所有數字均為彙總結果,不展示單則提問內容、提問者身分或來源位址。",
"stats.foot.cacheNote": "累計數字與圖表是當日 0 點的快照,每天只更新一次;「今日」兩項為即時數字。",
} }
+3
View File
@@ -231,6 +231,9 @@ func New() *flamego.Flame {
f.Get("/about-me", route.AboutMe) f.Get("/about-me", route.AboutMe)
// 社区规则:公开页,处置时的引用依据。 // 社区规则:公开页,处置时的引用依据。
f.Get("/rules", route.Rules) f.Get("/rules", route.Rules)
// 全站数据页:公开只读,纯聚合数字,不含任何单条内容或来源信息。
// 数据按自然日缓存(每天算一次),所以这里不需要额外限流。
f.Get("/stats", route.Stats)
f.Get("/robots.txt", func(c context.Context) { f.Get("/robots.txt", func(c context.Context) {
fs, err := static.FS.Open("robots.txt") fs, err := static.FS.Open("robots.txt")
if err != nil { if err != nil {
+303
View File
@@ -0,0 +1,303 @@
// 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 stats
import (
"context"
"sync"
"time"
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/db"
)
// Overview 是「全站数据」页的展示模型。
//
// 与 View 的区别:View 是首页那张三行小卡片的口径(30 分钟缓存、只有三个累计数),
// Overview 是完整聚合(趋势、构成、热力图、内容分布),按自然日缓存 —— 也就是
// 「每天更新一次」:当天第一次访问重算,之后全站共用同一份结果。
type Overview struct {
// 累计概览
RegisteredUsers int64
TotalQuestions int64
Answered int64
Public int64
Private int64
Read int64
Unread int64
Anonymous int64
LoggedIn int64
ActiveBoxes int64
AnsweringBoxes int64
TotalMessages int64
LastQuestionAt string
LastAnswerAt string
AnswerRate float64 // 已回答 / 总提问,百分比
FastReplyRate float64 // 24 小时内获得回答的比例,百分比
Total30 int64 // 近 30 天提问总数
AvgDailyLast30 float64 // 近 30 天日均提问
GrowthMoM float64 // 本月提问量相对上月的环比,百分比(上月为 0 时置 0)
HasPrevMonth bool // 上月是否有数据,决定要不要展示环比
// 今日实时(不参与每日缓存)
TodayQuestions int64
TodayAnswers int64
// 趋势(已补齐空日期,值为 0 的日期也占位,避免折线跳过)
Daily []DailyPoint
Monthly []MonthlyPoint
DailyMax int64
MonthlyMax int64
PeakDay DailyPoint
BusiestWD int // 0=周日
BusiestHr int // 0-23
// 热力图
Heatmap [7][24]int64
HeatmapMax int64
HeatmapDays int
// 内容分布
LengthBins [6]int64
ReplyBins [6]int64
// 元信息
UpdatedAt string
DataDate string
}
// DailyPoint 某一天的提问量。
type DailyPoint struct {
Date string // 2026-10-01
Label string // 10-01
N int64
}
// MonthlyPoint 某个月的提问量。
type MonthlyPoint struct {
Month string // 2026-10
Label string // 10
N int64
Current bool // 是否为当月(页面高亮最后一根柱子)
}
const (
dailyWindowDays = 30
monthlyWindow = 12
)
var (
ovMu sync.Mutex
ovCached *Overview
ovDay string
// ovRefresh 串行化重算:一天的第一个请求承担全部聚合,其余请求等它算完直接复用,
// 避免多个并发请求同时打一轮全表 GROUP BY(1C1G 上会互相拖慢)。
ovRefresh sync.Mutex
)
// OverviewData 返回全站数据页所需的聚合结果,按自然日缓存。
//
// 聚合失败时若手上还有昨天的结果,就继续返回旧的(页面照常打开,只是数据停在昨天);
// 完全没有可用结果才返回错误。
func OverviewData(ctx context.Context) (*Overview, error) {
today := time.Now().Format("2006-01-02")
ovMu.Lock()
cached, day := ovCached, ovDay
ovMu.Unlock()
if cached != nil && day == today {
return cached, nil
}
ovRefresh.Lock()
defer ovRefresh.Unlock()
// 二次检查:等锁期间可能已有别的请求刷新过。
ovMu.Lock()
cached, day = ovCached, ovDay
ovMu.Unlock()
if cached != nil && day == today {
return cached, nil
}
if db.Analytics == nil {
// 单测里没有初始化数据库时不应 panic,交给调用方回退。
return nil, errNoDatabase
}
now := time.Now()
raw, err := db.Analytics.SiteAnalytics(ctx, now)
if err != nil {
logrus.WithContext(ctx).WithError(err).Warn("计算全站统计数据失败")
if cached != nil {
return cached, nil
}
return nil, err
}
v := buildOverview(raw, now)
ovMu.Lock()
ovCached = v
ovDay = today
ovMu.Unlock()
return v, nil
}
// InvalidateOverview 丢弃缓存,供需要立即看到新数字的场景调用。
func InvalidateOverview() {
ovMu.Lock()
ovCached = nil
ovDay = ""
ovMu.Unlock()
}
var errNoDatabase = errString("database not initialized")
type errString string
func (e errString) Error() string { return string(e) }
func buildOverview(raw *db.SiteAnalytics, now time.Time) *Overview {
v := &Overview{
RegisteredUsers: raw.RegisteredUsers,
TotalQuestions: raw.TotalQuestions,
Answered: raw.Answered,
Public: raw.TotalQuestions - raw.Private,
Private: raw.Private,
Read: raw.Read,
Unread: raw.TotalQuestions - raw.Read,
Anonymous: raw.Anonymous,
LoggedIn: raw.TotalQuestions - raw.Anonymous,
ActiveBoxes: raw.ActiveBoxes,
AnsweringBoxes: raw.AnsweringBoxes,
TotalMessages: raw.TotalMessages,
TodayQuestions: raw.TodayQuestions,
TodayAnswers: raw.TodayAnswers,
Heatmap: raw.Heatmap,
HeatmapDays: raw.HeatmapDays,
LengthBins: raw.LengthBins,
ReplyBins: raw.ReplyBins,
DataDate: now.Format("2006-01-02"),
}
if v.Public < 0 {
v.Public = 0
}
if v.Unread < 0 {
v.Unread = 0
}
if v.LoggedIn < 0 {
v.LoggedIn = 0
}
if raw.LastQuestionAt != nil {
v.LastQuestionAt = raw.LastQuestionAt.Format("2006-01-02 15:04:05")
}
if raw.LastAnswerAt != nil {
v.LastAnswerAt = raw.LastAnswerAt.Format("2006-01-02 15:04:05")
}
if v.TotalQuestions > 0 {
v.AnswerRate = percent(v.Answered, v.TotalQuestions)
}
if answered := v.Answered; answered > 0 {
v.FastReplyRate = percent(v.ReplyBins[0]+v.ReplyBins[1]+v.ReplyBins[2], answered)
}
v.Daily = buildDaily(raw.Daily, now, dailyWindowDays)
v.Monthly = buildMonthly(raw.Monthly, now, monthlyWindow)
for _, p := range v.Daily {
if p.N > v.DailyMax {
v.DailyMax = p.N
}
if p.N >= v.PeakDay.N {
v.PeakDay = p
}
}
var dailySum int64
for _, p := range v.Daily {
dailySum += p.N
}
v.Total30 = dailySum
v.AvgDailyLast30 = float64(dailySum) / float64(dailyWindowDays)
for _, p := range v.Monthly {
if p.N > v.MonthlyMax {
v.MonthlyMax = p.N
}
}
// 环比取「上月 vs 上上月」而不是「本月 vs 上月」:本月通常还没过完,
// 拿一个残月去比完整月份,每月 1 号都会得出 -95% 这种毫无意义的结论。
if n := len(v.Monthly); n >= 3 && v.Monthly[n-3].N > 0 {
prev := float64(v.Monthly[n-3].N)
v.HasPrevMonth = true
v.GrowthMoM = (float64(v.Monthly[n-2].N) - prev) / prev * 100
}
for _, row := range v.Heatmap {
for _, n := range row {
if n > v.HeatmapMax {
v.HeatmapMax = n
}
}
}
v.BusiestWD, v.BusiestHr = busiestSlot(v.Heatmap)
v.UpdatedAt = now.Format("2006-01-02 15:04:05")
return v
}
// buildDaily 把「只有有数据的那几天」补齐成连续 N 天,折线才不会在无数据的日期断开。
func buildDaily(raw []db.CountPoint, now time.Time, days int) []DailyPoint {
index := make(map[string]int64, len(raw))
for _, p := range raw {
index[p.Key] = p.N
}
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).
AddDate(0, 0, -(days - 1))
out := make([]DailyPoint, 0, days)
for i := 0; i < days; i++ {
d := start.AddDate(0, 0, i)
key := d.Format("2006-01-02")
out = append(out, DailyPoint{Date: key, Label: d.Format("01-02"), N: index[key]})
}
return out
}
func buildMonthly(raw []db.CountPoint, now time.Time, months int) []MonthlyPoint {
index := make(map[string]int64, len(raw))
for _, p := range raw {
index[p.Key] = p.N
}
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).
AddDate(0, -(months - 1), 0)
out := make([]MonthlyPoint, 0, months)
for i := 0; i < months; i++ {
m := start.AddDate(0, i, 0)
key := m.Format("2006-01")
out = append(out, MonthlyPoint{Month: key, Label: m.Format("01"), N: index[key], Current: i == months-1})
}
return out
}
// busiestSlot 找出提问最集中的「星期 × 小时」。同值时取更早的下标,结果稳定可复现。
func busiestSlot(heatmap [7][24]int64) (weekday, hour int) {
best := int64(-1)
for d := 0; d < 7; d++ {
for h := 0; h < 24; h++ {
if heatmap[d][h] > best {
best = heatmap[d][h]
weekday, hour = d, h
}
}
}
return weekday, hour
}
func percent(part, total int64) float64 {
if total <= 0 {
return 0
}
return float64(part) / float64(total) * 100
}
+107
View File
@@ -0,0 +1,107 @@
// 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 stats
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/tamakyi/TamaBox/internal/db"
)
// TestBuildDailyFillsGaps 没有数据的日期必须占位,否则折线会把两天连成一条直线,
// 让人以为「那天也有提问」。
func TestBuildDailyFillsGaps(t *testing.T) {
now := time.Date(2026, 10, 23, 15, 30, 0, 0, time.Local)
got := buildDaily([]db.CountPoint{{Key: "2026-10-20", N: 5}}, now, 5)
require.Len(t, got, 5)
require.Equal(t, "2026-10-19", got[0].Date)
require.Equal(t, "2026-10-23", got[4].Date)
require.EqualValues(t, 0, got[0].N)
require.EqualValues(t, 5, got[1].N, "10-20 是窗口里的第 2 天")
}
// TestBuildMonthlyNoMonthOverflow 月底不能溢出:3-31 直接 AddDate(0,-11,0) 会滚到 3 月初,
// 12 根柱子就变成 11 根。实现里先归到当月 1 号,这里守住这个行为。
func TestBuildMonthlyNoMonthOverflow(t *testing.T) {
now := time.Date(2026, 3, 31, 0, 0, 0, 0, time.Local)
got := buildMonthly(nil, now, 12)
require.Len(t, got, 12)
require.Equal(t, "2025-04", got[0].Month)
require.Equal(t, "2026-03", got[11].Month)
require.True(t, got[11].Current, "最后一根柱子是当月")
require.False(t, got[0].Current)
}
// TestBusiestSlot 同值时取更早的下标,保证同一份数据每次算出来的结论一致。
func TestBusiestSlot(t *testing.T) {
var heatmap [7][24]int64
heatmap[2][9] = 10
heatmap[5][20] = 10
wd, hr := busiestSlot(heatmap)
require.Equal(t, 2, wd)
require.Equal(t, 9, hr)
}
// TestOverviewEmptyNoDivideByZero 全零数据不能除零,也不能编出不存在的结论。
func TestOverviewEmptyNoDivideByZero(t *testing.T) {
v := buildOverview(&db.SiteAnalytics{}, time.Now())
require.Zero(t, v.AnswerRate)
require.Zero(t, v.FastReplyRate)
require.Zero(t, v.GrowthMoM)
require.False(t, v.HasPrevMonth)
require.Len(t, v.Daily, dailyWindowDays)
require.Len(t, v.Monthly, monthlyWindow)
page := NewPage("zh-CN", v)
require.Len(t, page.Insights, 1, "空库只说一句「数据还太少」")
require.Equal(t, "提问数据还太少,暂时看不出什么。", page.Insights[0])
}
// TestOverviewDerived 派生指标:回答率、环比、最忙时段、24 小时内回复占比。
func TestOverviewDerived(t *testing.T) {
now := time.Now()
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
prevMonth := monthStart.AddDate(0, -1, 0).Format("2006-01")
monthBefore := monthStart.AddDate(0, -2, 0).Format("2006-01")
raw := &db.SiteAnalytics{
TotalQuestions: 200, Answered: 50, Private: 20,
Anonymous: 120,
Daily: []db.CountPoint{{Key: now.Format("2006-01-02"), N: 7}},
// 环比看的是「上月」与「上上月」:本月还没过完,当年 10 月为 0 会把结论带偏。
Monthly: []db.CountPoint{
{Key: monthBefore, N: 100},
{Key: prevMonth, N: 50},
},
LengthBins: [6]int64{1, 2, 3, 4, 5, 6},
ReplyBins: [6]int64{10, 10, 10, 10, 5, 5},
}
raw.Heatmap[1][8] = 40
v := buildOverview(raw, now)
require.InDelta(t, 25.0, v.AnswerRate, 0.001)
require.InDelta(t, 60.0, v.FastReplyRate, 0.001, "(10+10+10)/50 在 24 小时内")
require.Equal(t, 180, int(v.Public))
require.Equal(t, 80, int(v.LoggedIn))
require.Equal(t, 1, v.BusiestWD)
require.Equal(t, 8, v.BusiestHr)
require.EqualValues(t, 40, v.HeatmapMax)
require.True(t, v.HasPrevMonth)
require.InDelta(t, -50.0, v.GrowthMoM, 0.001, "9 月 50 相对 8 月 100 是 -50%")
page := NewPage("zh-CN", v)
require.NotEmpty(t, page.Insights)
require.Len(t, page.WeekdayLabels, 7)
require.Len(t, page.LengthLabels, 6)
require.Len(t, page.ReplyLabels, 6)
}
+152
View File
@@ -0,0 +1,152 @@
// 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 stats
import (
"fmt"
"math"
"github.com/tamakyi/TamaBox/internal/i18n"
)
// Page 是 /stats 模板的渲染模型:在 Overview 之上补齐多语言标签、
// 图形需要的百分比,以及自动生成的文字结论。
//
// 为什么标签在这一层拼而不是模板里拼:桶标签是按序号取的(stats.len.3 之类),
// 模板里 printf 拼 key 一旦缺词条就会把 key 本身印到页面上;集中在这里拼,
// 缺词条时页面最多少一行文字,不会露出内部 key。
type Page struct {
*Overview
WeekdayLabels []string
LengthLabels []string
ReplyLabels []string
Insights []string
DailyValues []int64
MonthlyValues []int64
// HeatmapRows 按「周一 … 周日」重排后的热力图(Overview 里以周日为下标 0)。
HeatmapRows [7][24]int64
MonthlyPcts []float64
LengthPcts []float64
ReplyPcts []float64
}
// weekdayOrder 热力图与图例的展示顺序:从周一排到周日(数据库的 DOW 以周日为 0)。
var weekdayOrder = []int{1, 2, 3, 4, 5, 6, 0}
// NewPage 把聚合结果转成模板可直接渲染的模型。
func NewPage(lang string, ov *Overview) *Page {
p := &Page{Overview: ov}
for i := range weekdayOrder {
p.WeekdayLabels = append(p.WeekdayLabels, i18n.T(lang, fmt.Sprintf("stats.wd.%d", i+1)))
}
for i := 1; i <= len(ov.LengthBins); i++ {
p.LengthLabels = append(p.LengthLabels, i18n.T(lang, fmt.Sprintf("stats.len.%d", i)))
}
for i := 1; i <= len(ov.ReplyBins); i++ {
p.ReplyLabels = append(p.ReplyLabels, i18n.T(lang, fmt.Sprintf("stats.reply.%d", i)))
}
for _, d := range ov.Daily {
p.DailyValues = append(p.DailyValues, d.N)
}
for _, m := range ov.Monthly {
p.MonthlyValues = append(p.MonthlyValues, m.N)
}
for i, d := range weekdayOrder {
p.HeatmapRows[i] = ov.Heatmap[d]
}
p.MonthlyPcts = toPercents(p.MonthlyValues)
p.LengthPcts = toPercents(ov.LengthBins[:])
p.ReplyPcts = toPercents(ov.ReplyBins[:])
p.Insights = insights(lang, ov)
return p
}
// toPercents 把一组计数换算成相对最大值的百分比,供 CSS 直接当宽度 / 高度用。
func toPercents(values []int64) []float64 {
var max int64
for _, n := range values {
if n > max {
max = n
}
}
out := make([]float64, len(values))
if max == 0 {
return out
}
for i, n := range values {
out[i] = float64(n) / float64(max) * 100
}
return out
}
// insights 生成几句人话结论。
//
// 只陈述能从聚合数字直接读到的事实(最忙的一天、最集中的时段、环比、回复速度),
// 不做趋势预测、也不写「大家更活跃了」这类评价 —— 支撑那种判断的数据这张表里没有。
func insights(lang string, ov *Overview) []string {
t := func(key string, args ...interface{}) string { return i18n.T(lang, key, args...) }
out := make([]string, 0, 6)
if ov.TotalQuestions == 0 {
return append(out, t("stats.insight.empty"))
}
out = append(out, t("stats.insight.daily30",
numText(ov.Total30), math.Round(ov.AvgDailyLast30*10)/10))
if ov.PeakDay.N > 0 {
out = append(out, t("stats.insight.peak", ov.PeakDay.Label, numText(ov.PeakDay.N)))
}
if ov.HeatmapMax > 0 {
// DOW 以周日为 0,展示顺序以周一为 1,换算到 stats.wd.N。
wd := (ov.BusiestWD+6)%7 + 1
out = append(out, t("stats.insight.busiest",
i18n.T(lang, fmt.Sprintf("stats.wd.%d", wd)), ov.BusiestHr))
}
if ov.HasPrevMonth {
out = append(out, t("stats.insight.mom", fmt.Sprintf("%+.1f", ov.GrowthMoM)))
}
if ov.Answered > 0 {
out = append(out, t("stats.insight.fastReply", fmt.Sprintf("%.1f", ov.FastReplyRate)))
}
out = append(out, t("stats.insight.private",
fmt.Sprintf("%.1f", 100-float64(ov.Public)/float64(ov.TotalQuestions)*100)))
return out
}
// numText 加千分位(2148 → "2,148")。
//
// 模板侧有同名功能的 NumInt,但它在 internal/template 里;统计层反过来依赖渲染层
// 会让依赖方向变脏,所以这里重新写一份十行的实现。
func numText(v int64) string {
s := fmt.Sprintf("%d", v)
if len(s) <= 3 {
return s
}
var b []byte
lead := len(s) % 3
if lead > 0 {
b = append(b, s[:lead]...)
}
for i := lead; i < len(s); i += 3 {
if len(b) > 0 {
b = append(b, ',')
}
b = append(b, s[i:i+3]...)
}
return string(b)
}
+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 { "moderationStrictness": func(raw string) string {
return string(moderation.StrictnessFor(raw)) return string(moderation.StrictnessFor(raw))
}, },
// 全站数据页的图表辅助:数字格式化 + 折线坐标(见 chart.go)。
// 站点不引前端图表库,柱状 / 环形 / 热力图都用 CSS 画,只有折线需要坐标。
"NumInt": NumInt,
"PctText": PctText,
"Polyline": Polyline,
"AreaPath": AreaPath,
"HeatLevel": HeatLevel,
}} }}
}) })
return funcMap return funcMap
+4 -7
View File
@@ -5,19 +5,16 @@
package route package route
import ( import (
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context" "github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/i18n" "github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/stats" "github.com/tamakyi/TamaBox/internal/stats"
) )
// Home 首页。
//
// 首页不再展示站点统计(原「提问箱信息统计」卡片已下线,完整数据在 /stats),
// 因此不必在每次访问首页时计算一次统计快照。
func Home(ctx context.Context) { func Home(ctx context.Context) {
if v, err := stats.Get(ctx.Request().Context()); err != nil {
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to compute site stats")
} else {
ctx.Data["SiteStats"] = v
}
ctx.Success("home") ctx.Success("home")
} }
+35
View File
@@ -0,0 +1,35 @@
// 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 route
import (
"github.com/sirupsen/logrus"
"github.com/tamakyi/TamaBox/internal/context"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/stats"
)
// Stats 全站公开数据页。
//
// 数据每天只算一次(stats.OverviewData 按自然日缓存),页面是纯服务端渲染:
// 折线用 SVG、柱状/环形/热力图用 CSS,不挂任何前端图表库,也不需要 JS。
// 这样即使访客禁用脚本、或外部 CDN 抽风,数字和图表都还在。
func Stats(ctx context.Context) {
lang := ctx.Lang()
ctx.SetTitle(i18n.T(lang, "stats.title"))
ov, err := stats.OverviewData(ctx.Request().Context())
if err != nil {
// 数据页不该因为一次聚合失败就 500:模板在 Overview 缺失时显示「数据暂不可用」,
// 标题、说明、导航照常可用。
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("加载全站统计数据失败")
ctx.Success("stats")
return
}
ctx.Data["Overview"] = stats.NewPage(lang, ov)
ctx.Success("stats")
}
+1
View File
@@ -1,6 +1,7 @@
<p class="{{ if .IsPixel }}uk-container{{end}} uk-text-right uk-text-muted uk-text-small"> <p class="{{ if .IsPixel }}uk-container{{end}} uk-text-right uk-text-muted uk-text-small">
<br> <br>
<a href="/rules">{{t .LangCode "site.footer.rules"}}</a> | <a href="/rules">{{t .LangCode "site.footer.rules"}}</a> |
<a href="/stats">{{t .LangCode "site.footer.stats"}}</a> |
<!--<a href="/pixel">画板</a> | --> <!--<a href="/pixel">画板</a> | -->
<a href="https://box.shiroko.one/_/tamakyi" target="_blank">{{t .LangCode "site.footer.admin"}}</a> <a href="https://box.shiroko.one/_/tamakyi" target="_blank">{{t .LangCode "site.footer.admin"}}</a>
<br> <br>
+1 -1
View File
@@ -8,5 +8,5 @@ import (
"embed" "embed"
) )
//go:embed auth base mail question user chat vote home.html about-me.html sponsor.html rules.html change-logs.html pixel.html maintenance-mode.html //go:embed auth base mail question user chat vote home.html about-me.html sponsor.html rules.html change-logs.html pixel.html maintenance-mode.html stats.html
var FS embed.FS var FS embed.FS
-71
View File
@@ -100,36 +100,9 @@
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
.tb-changelog[open] summary::before { transform: rotate(90deg); } .tb-changelog[open] summary::before { transform: rotate(90deg); }
.tb-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
text-align: center;
}
.tb-stat-num {
display: block;
font-size: var(--tb-fs-xl);
font-weight: 600;
line-height: 1.3;
color: var(--tb-text);
}
.tb-stat-label {
display: block;
margin-top: 2px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-stats-meta {
margin: 14px 0 0;
font-size: var(--tb-fs-xs);
line-height: 1.8;
color: var(--tb-text-muted);
text-align: center;
}
@media (max-width: 480px) { @media (max-width: 480px) {
.tb-hero { padding: 28px 16px 24px; } .tb-hero { padding: 28px 16px 24px; }
.tb-hero-title { font-size: var(--tb-fs-xl); } .tb-hero-title { font-size: var(--tb-fs-xl); }
.tb-stat-num { font-size: var(--tb-fs-lg); }
} }
</style> </style>
@@ -196,50 +169,6 @@
</ul> </ul>
</details> </details>
<div class="uk-card uk-card-default uk-card-body">
<h2 class="tb-section-title">{{t .LangCode "site.stats"}}</h2>
<div class="tb-stats">
<div>
<span class="tb-stat-num tb-num" data-stat="registered_users">{{if .SiteStats}}{{.SiteStats.RegisteredUsers}}{{else}}-{{end}}</span>
<span class="tb-stat-label">注册用户</span>
</div>
<div>
<span class="tb-stat-num tb-num" data-stat="total_questions">{{if .SiteStats}}{{.SiteStats.TotalQuestions}}{{else}}-{{end}}</span>
<span class="tb-stat-label">累计提问</span>
</div>
<div>
<span class="tb-stat-num tb-num" data-stat="answered_questions">{{if .SiteStats}}{{.SiteStats.AnsweredQuestions}}{{else}}-{{end}}</span>
<span class="tb-stat-label">已获回答</span>
</div>
</div>
<p class="tb-stats-meta">
最后提问:<span data-stat="last_question_at">{{if .SiteStats}}{{.SiteStats.LastQuestionAt}}{{else}}-{{end}}</span><br>
最后回答:<span data-stat="last_answer_at">{{if .SiteStats}}{{.SiteStats.LastAnswerAt}}{{else}}-{{end}}</span><br>
数据更新于:<span data-stat="updated_at">{{if .SiteStats}}{{.SiteStats.UpdatedAt}}{{else}}-{{end}}</span>
</p>
</div>
<script type="text/javascript">
// 统计数字保持服务端 30 分钟缓存,页面每 30 分钟拉一次最新值就地更新(不整页刷新)。
// 与原来写 textContent 整块文本的做法等价,只是改为按字段更新三张数字卡。
document.addEventListener('DOMContentLoaded', function () {
setInterval(function () {
fetch("/api/v1/site-stats")
.then(function (r) { return r.json(); })
.then(function (resp) {
var d = resp.data;
if (!d) return;
["registered_users", "total_questions", "answered_questions",
"last_question_at", "last_answer_at", "updated_at"].forEach(function (k) {
var v = d[k];
if (v === undefined || v === null || v === "") return;
var el = document.querySelector('[data-stat="' + k + '"]');
if (el) el.textContent = v;
});
})
.catch(function () {});
}, 30 * 60 * 1000);
});
</script>
<div id="waline"></div> <div id="waline"></div>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
+561
View File
@@ -0,0 +1,561 @@
{{template "base/header" .}}
<style>
/* 全站数据页:只用令牌与 CSS 画图(折线 SVG、柱状/分布条用百分比高度、环形用 pathLength、
热力图用 grid + 主色透明度)。不引图表库,页面在禁用 JS 时也完整。 */
.tb-dash {
font-variant-numeric: tabular-nums;
}
.tb-dash-head {
padding: 4px 2px 14px;
}
.tb-dash-head h2 {
margin: 0;
font-size: var(--tb-fs-xl);
font-weight: 600;
color: var(--tb-text);
}
.tb-dash-head p {
margin: 8px 0 0;
font-size: var(--tb-fs-md);
line-height: 1.7;
color: var(--tb-text-muted);
}
/* 今日条 */
.tb-today {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px 18px;
padding: 12px 16px;
margin-bottom: 12px;
background: var(--tb-primary-weak);
border-radius: var(--tb-radius-sm);
font-size: var(--tb-fs-md);
color: var(--tb-text-secondary);
}
.tb-today b {
font-size: var(--tb-fs-lg);
color: var(--tb-primary);
font-weight: 700;
}
.tb-today .tb-today-tag {
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
/* 卡片 */
.tb-card {
background: var(--tb-surface);
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius);
padding: 18px 18px 20px;
margin-bottom: 12px;
}
.tb-card-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
margin: 0 0 4px;
font-size: var(--tb-fs-lg);
font-weight: 600;
color: var(--tb-text);
}
.tb-card-note {
margin: 0 0 16px;
font-size: var(--tb-fs-xs);
line-height: 1.6;
color: var(--tb-text-muted);
}
/* KPI 网格 */
.tb-kpis {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 12px;
}
@media (min-width: 560px) {
.tb-kpis { grid-template-columns: repeat(3, 1fr); }
}
.tb-kpi {
padding: 14px 12px;
background: var(--tb-surface);
border: 1px solid var(--tb-border);
border-radius: var(--tb-radius-sm);
}
.tb-kpi-num {
display: block;
font-size: 1.375rem;
line-height: 1.2;
font-weight: 700;
color: var(--tb-text);
}
.tb-kpi-num.tb-kpi-accent { color: var(--tb-primary); }
.tb-kpi-lbl {
display: block;
margin-top: 4px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
/* 洞察 */
.tb-insights {
margin: 0;
padding: 0;
list-style: none;
}
.tb-insights li {
position: relative;
padding: 6px 0 6px 18px;
font-size: var(--tb-fs-md);
line-height: 1.7;
color: var(--tb-text-secondary);
}
.tb-insights li::before {
content: "";
position: absolute;
left: 4px;
top: 14px;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--tb-primary);
opacity: .55;
}
/* 折线图 */
.tb-line-wrap { margin-top: 4px; }
.tb-line {
display: block;
width: 100%;
height: 148px;
}
.tb-line .tb-line-area {
/* 比 --tb-primary-weak(0.10)略深一档:折线下方的面积要能看出「有」,但不能抢走线条本身 */
fill: var(--tb-primary);
fill-opacity: .14;
stroke: none;
}
.tb-line .tb-line-path {
fill: none;
stroke: var(--tb-primary);
stroke-width: 2;
stroke-linejoin: round;
stroke-linecap: round;
/* viewBox 与显示宽度不一致,必须锁定描边像素,否则窄屏上线会被压细或拉粗 */
vector-effect: non-scaling-stroke;
}
.tb-axis {
display: flex;
justify-content: space-between;
margin-top: 6px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
/* 月度柱状 */
.tb-bars {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 5px;
align-items: end;
}
.tb-bar-col {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
}
.tb-bar-track {
display: flex;
align-items: flex-end;
width: 100%;
height: 108px;
}
.tb-bar-track i {
display: block;
width: 100%;
min-height: 2px;
border-radius: 3px 3px 0 0;
background: var(--tb-primary);
opacity: .82;
}
.tb-bar-col:hover .tb-bar-track i { opacity: 1; }
.tb-bar-lbl {
margin-top: 6px;
font-size: 0.6875rem;
color: var(--tb-text-muted);
}
.tb-bar-col.is-current .tb-bar-lbl { color: var(--tb-primary); font-weight: 600; }
/* 构成环 */
.tb-donuts {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.tb-donut {
text-align: center;
}
.tb-donut-ring {
position: relative;
width: 76px;
margin: 0 auto;
}
.tb-donut-ring svg {
display: block;
width: 100%;
height: auto;
}
.tb-donut-ring .tb-ring-track {
fill: none;
stroke: var(--tb-surface-2);
stroke-width: 6;
}
.tb-donut-ring .tb-ring-val {
fill: none;
stroke: var(--tb-primary);
stroke-width: 6;
stroke-linecap: round;
}
.tb-donut-pct {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--tb-fs-md);
font-weight: 700;
color: var(--tb-text);
}
.tb-donut-cap {
margin-top: 8px;
font-size: var(--tb-fs-xs);
line-height: 1.5;
color: var(--tb-text-muted);
}
.tb-donut-cap b {
display: block;
margin-top: 2px;
color: var(--tb-text-secondary);
font-weight: 600;
}
/* 热力图 */
.tb-heat {
/* 24 列在 750px 容器里会把方块撑到 26px 见方,整块图比上面的折线还高。
限宽后每格约 20px,既看得清又不至于把页面拉得太长。 */
max-width: 620px;
margin: 0 auto;
}
.tb-heat-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 3px;
}
.tb-heat-lbl {
flex: 0 0 26px;
font-size: 0.6875rem;
color: var(--tb-text-muted);
text-align: center;
}
.tb-heat-cells {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 2px;
flex: 1 1 auto;
}
.tb-heat-cells i {
display: block;
aspect-ratio: 1 / 1;
border-radius: 2px;
background: var(--tb-surface-2);
}
.tb-heat-cells i.lv1 { background: var(--tb-primary); opacity: .18; }
.tb-heat-cells i.lv2 { background: var(--tb-primary); opacity: .38; }
.tb-heat-cells i.lv3 { background: var(--tb-primary); opacity: .62; }
.tb-heat-cells i.lv4 { background: var(--tb-primary); opacity: 1; }
.tb-heat-hours {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 2px;
margin: 6px 0 0 34px;
font-size: 0.625rem;
color: var(--tb-text-muted);
}
.tb-heat-hours span { text-align: center; }
.tb-heat-legend {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: 10px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-heat-legend i {
display: block;
width: 10px;
height: 10px;
border-radius: 2px;
background: var(--tb-surface-2);
}
.tb-heat-legend i.lv1 { background: var(--tb-primary); opacity: .18; }
.tb-heat-legend i.lv2 { background: var(--tb-primary); opacity: .38; }
.tb-heat-legend i.lv3 { background: var(--tb-primary); opacity: .62; }
.tb-heat-legend i.lv4 { background: var(--tb-primary); opacity: 1; }
/* 分布条 */
.tb-dist + .tb-dist { margin-top: 20px; }
.tb-dist-title {
margin: 0 0 10px;
font-size: var(--tb-fs-md);
font-weight: 600;
color: var(--tb-text-secondary);
}
.tb-dist-row {
/* 标签列用 em 而不是 px:英文桶名("100–199 chars")比中文长一截,
写死 84px 会让它折成两行,把整组分布条的高度带偏。 */
display: grid;
grid-template-columns: 7.5em 1fr 3.6em;
gap: 10px;
align-items: center;
margin-bottom: 6px;
font-size: var(--tb-fs-xs);
color: var(--tb-text-muted);
}
.tb-dist-track {
height: 8px;
border-radius: 4px;
background: var(--tb-surface-2);
overflow: hidden;
}
.tb-dist-track i {
display: block;
height: 100%;
min-width: 2px;
border-radius: 4px;
background: var(--tb-primary);
opacity: .78;
}
.tb-dist-num { text-align: right; }
.tb-dash-foot {
margin: 4px 2px 0;
font-size: var(--tb-fs-xs);
line-height: 1.8;
color: var(--tb-text-muted);
}
.tb-dash-empty {
padding: 40px 16px;
text-align: center;
font-size: var(--tb-fs-md);
color: var(--tb-text-muted);
}
</style>
{{ with .Overview }}
{{ $ov := . }}
<div class="tb-dash">
<div class="tb-dash-head">
<h2>{{t $.LangCode "stats.title"}}</h2>
<p>{{t $.LangCode "stats.lead"}}</p>
</div>
<!-- 今日实时:不参与「每天一次」的缓存,每次打开都是当下数字 -->
<div class="tb-today">
<span>{{t $.LangCode "stats.today.questions"}} <b>{{NumInt .TodayQuestions}}</b></span>
<span>{{t $.LangCode "stats.today.answers"}} <b>{{NumInt .TodayAnswers}}</b></span>
<span class="tb-today-tag">{{t $.LangCode "stats.today.tag"}}</span>
</div>
<!-- 累计概览 -->
<div class="tb-kpis">
<div class="tb-kpi">
<span class="tb-kpi-num">{{NumInt .RegisteredUsers}}</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.users"}}</span>
</div>
<div class="tb-kpi">
<span class="tb-kpi-num">{{NumInt .TotalQuestions}}</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.questions"}}</span>
</div>
<div class="tb-kpi">
<span class="tb-kpi-num">{{NumInt .Answered}}</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.answered"}}</span>
</div>
<div class="tb-kpi">
<span class="tb-kpi-num tb-kpi-accent">{{PctText .Answered .TotalQuestions}}%</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.answerRate"}}</span>
</div>
<div class="tb-kpi">
<span class="tb-kpi-num">{{NumInt .ActiveBoxes}}</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.activeBoxes"}}</span>
</div>
<div class="tb-kpi">
<span class="tb-kpi-num">{{NumInt .TotalMessages}}</span>
<span class="tb-kpi-lbl">{{t $.LangCode "stats.kpi.messages"}}</span>
</div>
</div>
<!-- 文字结论 -->
{{ if .Insights }}
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.insight.title"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.insight.note"}}</p>
<ul class="tb-insights">
{{ range .Insights }}<li>{{ . }}</li>{{ end }}
</ul>
</div>
{{ end }}
<!-- 近 30 天趋势 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.trend.dailyTitle"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.trend.dailyNote"}}</p>
<div class="tb-line-wrap">
<svg class="tb-line" viewBox="0 0 640 148" preserveAspectRatio="none" role="img"
aria-label="{{t $.LangCode "stats.trend.dailyTitle"}}">
<path class="tb-line-area" d="{{ AreaPath .DailyValues 640 148 12 10 }}"></path>
<polyline class="tb-line-path" points="{{ Polyline .DailyValues 640 148 12 10 }}"></polyline>
</svg>
{{ if eq (len .Daily) 30 }}
<div class="tb-axis">
<span>{{ (index .Daily 0).Label }}</span>
<span>{{ (index .Daily 15).Label }}</span>
<span>{{ (index .Daily 29).Label }}</span>
</div>
{{ end }}
</div>
</div>
<!-- 近 12 个月 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.trend.monthlyTitle"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.trend.monthlyNote"}}</p>
<div class="tb-bars">
{{ range $i, $p := .Monthly }}
<div class="tb-bar-col{{ if $p.Current }} is-current{{ end }}" title="{{ $p.Month }} · {{ NumInt $p.N }}">
<div class="tb-bar-track"><i style="height:{{ printf "%.1f" (index $ov.MonthlyPcts $i) }}%"></i></div>
<span class="tb-bar-lbl">{{ $p.Label }}</span>
</div>
{{ end }}
</div>
</div>
<!-- 构成 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.mix.title"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.mix.note"}}</p>
<div class="tb-donuts">
<div class="tb-donut">
<div class="tb-donut-ring">
<svg viewBox="0 0 42 42" role="img" aria-label="{{t $.LangCode "stats.mix.public"}}">
<circle class="tb-ring-track" cx="21" cy="21" r="15.915" pathLength="100"></circle>
<circle class="tb-ring-val" cx="21" cy="21" r="15.915" pathLength="100"
stroke-dasharray="{{ PctText .Public .TotalQuestions }} 100"
transform="rotate(-90 21 21)"></circle>
</svg>
<span class="tb-donut-pct">{{ PctText .Public .TotalQuestions }}%</span>
</div>
<div class="tb-donut-cap">{{t $.LangCode "stats.mix.public"}}<b>{{ NumInt .Public }}</b></div>
</div>
<div class="tb-donut">
<div class="tb-donut-ring">
<svg viewBox="0 0 42 42" role="img" aria-label="{{t $.LangCode "stats.mix.answered"}}">
<circle class="tb-ring-track" cx="21" cy="21" r="15.915" pathLength="100"></circle>
<circle class="tb-ring-val" cx="21" cy="21" r="15.915" pathLength="100"
stroke-dasharray="{{ PctText .Answered .TotalQuestions }} 100"
transform="rotate(-90 21 21)"></circle>
</svg>
<span class="tb-donut-pct">{{ PctText .Answered .TotalQuestions }}%</span>
</div>
<div class="tb-donut-cap">{{t $.LangCode "stats.mix.answered"}}<b>{{ NumInt .Answered }}</b></div>
</div>
<div class="tb-donut">
<div class="tb-donut-ring">
<svg viewBox="0 0 42 42" role="img" aria-label="{{t $.LangCode "stats.mix.login"}}">
<circle class="tb-ring-track" cx="21" cy="21" r="15.915" pathLength="100"></circle>
<circle class="tb-ring-val" cx="21" cy="21" r="15.915" pathLength="100"
stroke-dasharray="{{ PctText .LoggedIn .TotalQuestions }} 100"
transform="rotate(-90 21 21)"></circle>
</svg>
<span class="tb-donut-pct">{{ PctText .LoggedIn .TotalQuestions }}%</span>
</div>
<div class="tb-donut-cap">{{t $.LangCode "stats.mix.login"}}<b>{{ NumInt .LoggedIn }}</b></div>
</div>
</div>
</div>
<!-- 时段热力图 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.heat.title"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.heat.note" .HeatmapDays}}</p>
<div class="tb-heat">
{{ range $i, $row := .HeatmapRows }}
<div class="tb-heat-row">
<span class="tb-heat-lbl">{{ index $ov.WeekdayLabels $i }}</span>
<div class="tb-heat-cells">
{{ range $row }}<i class="lv{{ HeatLevel . $ov.HeatmapMax }}" title="{{ . }}"></i>{{ end }}
</div>
</div>
{{ end }}
<div class="tb-heat-hours">
<span>0</span><span></span><span></span><span></span><span></span><span></span><span>6</span>
<span></span><span></span><span></span><span></span><span></span><span>12</span>
<span></span><span></span><span></span><span></span><span></span><span>18</span>
<span></span><span></span><span></span><span></span><span>23</span>
</div>
<div class="tb-heat-legend">
<span>{{t $.LangCode "stats.heat.less"}}</span>
<i></i><i class="lv1"></i><i class="lv2"></i><i class="lv3"></i><i class="lv4"></i>
<span>{{t $.LangCode "stats.heat.more"}}</span>
</div>
</div>
</div>
<!-- 内容与节奏 -->
<div class="tb-card">
<h3 class="tb-card-title">{{t $.LangCode "stats.content.title"}}</h3>
<p class="tb-card-note">{{t $.LangCode "stats.content.note"}}</p>
<div class="tb-dist">
<h4 class="tb-dist-title">{{t $.LangCode "stats.content.lengthTitle"}}</h4>
{{ range $i, $n := .LengthBins }}
<div class="tb-dist-row">
<span>{{ index $ov.LengthLabels $i }}</span>
<div class="tb-dist-track"><i style="width:{{ printf "%.1f" (index $ov.LengthPcts $i) }}%"></i></div>
<span class="tb-dist-num">{{ NumInt $n }}</span>
</div>
{{ end }}
</div>
<div class="tb-dist">
<h4 class="tb-dist-title">{{t $.LangCode "stats.content.replyTitle"}}</h4>
{{ range $i, $n := .ReplyBins }}
<div class="tb-dist-row">
<span>{{ index $ov.ReplyLabels $i }}</span>
<div class="tb-dist-track"><i style="width:{{ printf "%.1f" (index $ov.ReplyPcts $i) }}%"></i></div>
<span class="tb-dist-num">{{ NumInt $n }}</span>
</div>
{{ end }}
</div>
</div>
<p class="tb-dash-foot">
{{t $.LangCode "stats.foot.dataDate"}} {{ .DataDate }} · {{t $.LangCode "stats.foot.updated"}} {{ .UpdatedAt }}
<br>{{t $.LangCode "stats.foot.prevMonth"}}{{ if .LastQuestionAt }}{{ .LastQuestionAt }}{{ else }}-{{ end }}
· {{t $.LangCode "stats.foot.prevAnswer"}}{{ if .LastAnswerAt }}{{ .LastAnswerAt }}{{ else }}-{{ end }}
<br>{{t $.LangCode "stats.foot.cacheNote"}}
<br>{{t $.LangCode "stats.foot.privacy"}}
</p>
</div>
{{ else }}
<div class="tb-dash-empty">{{t .LangCode "stats.unavailable"}}</div>
{{ end }}
{{template "base/footer" .}}
+93
View File
@@ -181,6 +181,96 @@ func TestGenPreview(t *testing.T) {
writeQuestionOwnerPreviews(t, outDir) writeQuestionOwnerPreviews(t, outDir)
writeAdminListPreviews(t, outDir) writeAdminListPreviews(t, outDir)
writeVotePreviews(t, outDir) writeVotePreviews(t, outDir)
writeStatsPreviews(t, outDir)
}
// writeStatsPreviews 全站数据页(/stats)。
// 四种组合都出:亮/暗确认令牌适配,中/英确认英文长句不会把卡片挤变形。
func writeStatsPreviews(t *testing.T, outDir string) {
t.Helper()
now := time.Now()
daily := make([]stats.DailyPoint, 0, 30)
var peak stats.DailyPoint
for i := 0; i < 30; i++ {
d := now.AddDate(0, 0, -(29 - i))
n := int64(38 + (i*11)%47 + (i%5)*13)
p := stats.DailyPoint{Date: d.Format("2006-01-02"), Label: d.Format("01-02"), N: n}
daily = append(daily, p)
if p.N >= peak.N {
peak = p
}
}
monthly := make([]stats.MonthlyPoint, 0, 12)
for i := 0; i < 12; i++ {
m := now.AddDate(0, -(11 - i), 0)
monthly = append(monthly, stats.MonthlyPoint{
Month: m.Format("2006-01"), Label: m.Format("01"),
N: int64(2100 + i*310 + (i%3)*420), Current: i == 11,
})
}
// 热力图:造出「深夜 + 周末」两个高峰,便于肉眼确认档位分得开。
var heatmap [7][24]int64
for d := 0; d < 7; d++ {
for h := 0; h < 24; h++ {
v := int64(30 + (h%12)*16)
if h >= 21 || h <= 1 {
v += 320
}
if h >= 12 && h <= 14 {
v += 90
}
if d >= 5 {
v += 120
}
heatmap[d][h] = v
}
}
ov := &stats.Overview{
RegisteredUsers: 1284, TotalQuestions: 56321, Answered: 31208,
Public: 41002, Private: 15319, Read: 40122, Unread: 16199,
Anonymous: 22110, LoggedIn: 34211,
ActiveBoxes: 412, AnsweringBoxes: 268, TotalMessages: 8842,
LastQuestionAt: "2026-10-22 23:41:07", LastAnswerAt: "2026-10-22 23:12:33",
AnswerRate: 55.4, FastReplyRate: 38.6, Total30: 2148, AvgDailyLast30: 71.6,
GrowthMoM: 12.3, HasPrevMonth: true,
TodayQuestions: 37, TodayAnswers: 21,
Daily: daily, Monthly: monthly, PeakDay: peak, DailyMax: peak.N, MonthlyMax: 6180,
BusiestWD: 6, BusiestHr: 23,
Heatmap: heatmap, HeatmapMax: 812, HeatmapDays: 180,
LengthBins: [6]int64{8200, 14200, 16800, 9200, 6100, 1821},
ReplyBins: [6]int64{4200, 7100, 8400, 6200, 3400, 1908},
DataDate: now.Format("2006-01-02"),
UpdatedAt: now.Format("2006-01-02") + " 00:07:12",
}
for _, lang := range []string{"zh-CN", "en"} {
data := map[string]interface{}{
"Title": "全站数据",
"LangCode": lang,
"SupportedLangs": []map[string]string{{"Code": "zh-CN", "Name": "简体中文"}, {"Code": "en", "Name": "English"}},
"IsLogged": false,
"IsUserPage": false,
"IsPixel": false,
"VConsole": false,
"CurrentURI": "/stats",
"ExternalURL": "https://box.shiroko.one",
"CaptchaType": "math",
"Limits": conf.Limits,
"ICP": "",
"Overview": stats.NewPage(lang, ov),
}
for _, theme := range []string{"light", "dark"} {
page, err := renderToString(t, "stats", data)
if err != nil {
t.Fatalf("render stats: %v", err)
}
writePreview(t, outDir, "preview-stats-"+lang+"-"+theme+".html", page, theme, "../static")
}
}
} }
// writeQuestionItemPreviews 单题页(/_/{domain}/{qid})。 // writeQuestionItemPreviews 单题页(/_/{domain}/{qid})。
@@ -383,6 +473,9 @@ func writePreview(t *testing.T, dir, name, html, theme, staticPrefix string) {
} }
} }
html = strings.ReplaceAll(html, `href="/favicon.ico"`, `href="`+staticPrefix+`/favicon.ico"`) html = strings.ReplaceAll(html, `href="/favicon.ico"`, `href="`+staticPrefix+`/favicon.ico"`)
// base.css 也要改成本地相对路径:预览页走 file://,"/static/base.css" 是绝对路径取不到,
// 于是 :root 上的设计令牌全部缺失 —— var(--tb-*) 变成无效值,卡片没边框、图表全透明。
html = strings.ReplaceAll(html, `href="/static/`, `href="`+staticPrefix+`/`)
html = strings.Replace(html, `<title>`, `<title>预览 `+time.Now().Format("15:04")+` · `, 1) html = strings.Replace(html, `<title>`, `<title>预览 `+time.Now().Format("15:04")+` · `, 1)
if err := os.WriteFile(filepath.Join(dir, name), []byte(html), 0o644); err != nil { if err := os.WriteFile(filepath.Join(dir, name), []byte(html), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)