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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user