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:
@@ -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