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:
@@ -221,3 +221,30 @@ Tomcat 的 skin 忘了 css(按钮白字无底色)、WP 登录页底部挂着
|
||||
- 量手机宽度别用 `chrome --window-size=360`:Windows headless=old 有 ~500px 最小窗宽,
|
||||
实测 innerWidth 恒为 500。用 360px 宽 iframe + `--allow-file-access-from-files`,
|
||||
结果由 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/`。
|
||||
|
||||
@@ -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 词条也要一起清,避免留死条目。
|
||||
@@ -3,7 +3,7 @@
|
||||
明细已拆分到 `topics/*.md`(按需 Read 全文):
|
||||
`coop` 协作 / `build` 构建测试 / `embed` 行尾·模板·embed / `review` 审核处置举报 /
|
||||
`gorm` GORM / `style` 前端样式 / `theme` 主题 / `builder` 主题生成器 / `share` 分享卡 /
|
||||
`honeypot` 蜜罐多家族伪装 / `misc` 其它约定。
|
||||
`honeypot` 蜜罐多家族伪装 / `stats` 全站数据页 / `misc` 其它约定。
|
||||
|
||||
## 协作
|
||||
|
||||
|
||||
@@ -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、不显示单条内容、不做箱主排行(会招骚扰)。
|
||||
「收到过提问的箱子」这类计数可以,具体是谁不行。
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/tamakyi/TamaBox/internal/route"
|
||||
"github.com/tamakyi/TamaBox/internal/security/honeypot"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
"github.com/tamakyi/TamaBox/internal/stats"
|
||||
"github.com/tamakyi/TamaBox/internal/tracing"
|
||||
)
|
||||
|
||||
@@ -100,6 +101,16 @@ func runWeb(ctx *cli.Context) error {
|
||||
r := route.New()
|
||||
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 上配置超时:
|
||||
// - ReadHeaderTimeout 防止慢速客户端占用连接
|
||||
// - ReadTimeout / WriteTimeout 防止读写挂死
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -127,6 +127,7 @@ func Init(typ, dsn string) (*gorm.DB, error) {
|
||||
Restrictions = NewRestrictionsStore(db)
|
||||
Reports = NewReportsStore(db)
|
||||
Honeypots = NewHoneypotStore(db)
|
||||
Analytics = NewSiteAnalyticsStore(db)
|
||||
|
||||
// 旧版本把消息/密码明文存在 honeypot_hits 上;结构体已删掉这些字段,
|
||||
// 但 AutoMigrate 不会删除既有列 —— 老库升级上来后历史明文仍在。
|
||||
@@ -217,6 +218,9 @@ func dedupeUniqueFields(db *gorm.DB) error {
|
||||
func ensureFrequentlyUsedIndexes(db *gorm.DB) error {
|
||||
indexes := []string{
|
||||
`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)`,
|
||||
// 「下一条未读」:user_id + is_read 过滤后按 created_at 取最早一条。
|
||||
// 上面那条中间隔着 is_private,帮不上这个排序。
|
||||
|
||||
@@ -951,4 +951,81 @@ var enDict = map[string]string{
|
||||
"theme.name.sakura": "Sakura",
|
||||
"theme.name.shiroko": "Shiroko",
|
||||
"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.",
|
||||
}
|
||||
|
||||
@@ -950,4 +950,81 @@ var jaDict = map[string]string{
|
||||
"theme.name.sakura": "さくら",
|
||||
"theme.name.shiroko": "シロコ",
|
||||
"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 項目はリアルタイムです。",
|
||||
}
|
||||
|
||||
@@ -951,4 +951,81 @@ var zhCNDict = map[string]string{
|
||||
"form.register.recaptcha": "无法加载人机验证,请刷新页面或切换网络后重试",
|
||||
"form.register.agreeRules": "请先阅读并同意社区规则",
|
||||
"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 点的快照,每天只更新一次;「今日」两项为实时数字。",
|
||||
}
|
||||
|
||||
@@ -950,4 +950,81 @@ var zhTWDict = map[string]string{
|
||||
"theme.name.sakura": "櫻花",
|
||||
"theme.name.shiroko": "砂狼白子",
|
||||
"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 點的快照,每天只更新一次;「今日」兩項為即時數字。",
|
||||
}
|
||||
|
||||
@@ -231,6 +231,9 @@ func New() *flamego.Flame {
|
||||
f.Get("/about-me", route.AboutMe)
|
||||
// 社区规则:公开页,处置时的引用依据。
|
||||
f.Get("/rules", route.Rules)
|
||||
// 全站数据页:公开只读,纯聚合数字,不含任何单条内容或来源信息。
|
||||
// 数据按自然日缓存(每天算一次),所以这里不需要额外限流。
|
||||
f.Get("/stats", route.Stats)
|
||||
f.Get("/robots.txt", func(c context.Context) {
|
||||
fs, err := static.FS.Open("robots.txt")
|
||||
if err != nil {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+4
-7
@@ -5,19 +5,16 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// Home 首页。
|
||||
//
|
||||
// 首页不再展示站点统计(原「提问箱信息统计」卡片已下线,完整数据在 /stats),
|
||||
// 因此不必在每次访问首页时计算一次统计快照。
|
||||
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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
<p class="{{ if .IsPixel }}uk-container{{end}} uk-text-right uk-text-muted uk-text-small">
|
||||
<br>
|
||||
<a href="/rules">{{t .LangCode "site.footer.rules"}}</a> |
|
||||
<a href="/stats">{{t .LangCode "site.footer.stats"}}</a> |
|
||||
<!--<a href="/pixel">画板</a> | -->
|
||||
<a href="https://box.shiroko.one/_/tamakyi" target="_blank">{{t .LangCode "site.footer.admin"}}</a>
|
||||
<br>
|
||||
|
||||
+1
-1
@@ -8,5 +8,5 @@ import (
|
||||
"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
|
||||
|
||||
@@ -100,36 +100,9 @@
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.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) {
|
||||
.tb-hero { padding: 28px 16px 24px; }
|
||||
.tb-hero-title { font-size: var(--tb-fs-xl); }
|
||||
.tb-stat-num { font-size: var(--tb-fs-lg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -196,50 +169,6 @@
|
||||
</ul>
|
||||
</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>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
@@ -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" .}}
|
||||
@@ -181,6 +181,96 @@ func TestGenPreview(t *testing.T) {
|
||||
writeQuestionOwnerPreviews(t, outDir)
|
||||
writeAdminListPreviews(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})。
|
||||
@@ -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"`)
|
||||
// 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)
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(html), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
Reference in New Issue
Block a user