「上月提问量环比 -75.0%」这句要读者做两次翻译:先把「上月」翻成具体月份, 再把「环比」翻成「比上上月」。两个相对词叠在一起,站长本人看到就以为 是「上月同期 vs 本月同期」—— 而它其实比的是两个完整自然月。 改前:上月提问量环比 -75.0%。 改后:9 月收到 5,620 条提问,比 8 月的 4,890 条多了 14.9%。 口径一个字没改(仍是上月 vs 上上月,本月没过完不参与),只是把月份和条数 写进句子:读者不需要知道「环比」的定义也能判断这句话在说什么,还能顺手 核对百分比(5620/4890 确实是 +14.9%)。 实现: - `monthText` 按语言出月份名 —— CJK 一律「9 月」,英文要 "September" ("month 9" 不是英语的说法)。不带年份:相比的两个月相邻。 - 一个词条拆成 momUp / momDown / momFlat:方向词塞不进同一个模板, 四种语言里「多了 / 少了」的位置和词形都不一样。 - `|pct| < 0.05` 走持平,否则会出现「多了 0.0%」。 测试:`TestInsightMoMNamesBothMonths` 锁住「句子里同时有两个月份名和两个条数」, 覆盖上升/下降/持平;`TestMonthText` 锁语言差异。 顺手修预览数据:`zz_preview_test.go` 的 `GrowthMoM` 原来写死 12.3,和它造的 monthly 对不上。以前句子里只有百分比无所谓,现在会连条数一起印出来, 截图就成了「5,620 比 4,890 多了 12.3%」这种假证据,改成从数据算。
194 lines
6.0 KiB
Go
194 lines
6.0 KiB
Go
// 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"
|
||
"strings"
|
||
"time"
|
||
|
||
"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.weekday.N。
|
||
//
|
||
// 句子里必须用完整星期名(stats.weekday.*),不能用热力图列头那套
|
||
// 单字标签(stats.wd.* = "一"/"Mon"):列头要窄,句子里"三 19 点"
|
||
// 会被读成数字 3,英文那边则直接是 "Wed 19:00"。
|
||
wd := (ov.BusiestWD+6)%7 + 1
|
||
out = append(out, t("stats.insight.busiest",
|
||
i18n.T(lang, fmt.Sprintf("stats.weekday.%d", wd)), ov.BusiestHr))
|
||
}
|
||
|
||
if ov.HasPrevMonth {
|
||
// 句子必须点明是哪两个月、各多少条。
|
||
//
|
||
// 原来写「上月提问量环比 -75.0%」:读者得自己先翻译「上月 = 9 月」、
|
||
// 再翻译「环比 = 比 8 月」,于是很容易读成「上月同期 vs 本月同期」。
|
||
// 两个相对词叠在一起,全靠脑补;把月份和条数写出来就没有歧义了。
|
||
n := len(ov.Monthly)
|
||
cur, prev := ov.Monthly[n-2], ov.Monthly[n-3]
|
||
pct := math.Abs(ov.GrowthMoM)
|
||
// pct 四舍五入后是 0.0 时按持平处理:别写「多了 0.0%」。
|
||
key := "stats.insight.momFlat"
|
||
if pct >= 0.05 {
|
||
if ov.GrowthMoM > 0 {
|
||
key = "stats.insight.momUp"
|
||
} else {
|
||
key = "stats.insight.momDown"
|
||
}
|
||
}
|
||
out = append(out, t(key,
|
||
monthText(lang, cur.Month), numText(cur.N),
|
||
monthText(lang, prev.Month), numText(prev.N),
|
||
pct))
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// monthText 把 "2026-09" 变成句子里能用的月份名。
|
||
//
|
||
// 中文 / 日文用「9 月」,英文要 "September"("month 9" 不是英语的说法)。
|
||
// 不带年份:要比较的两个月相邻,读者知道哪个月是「上个月」,年份只是噪音。
|
||
func monthText(lang, month string) string {
|
||
m, err := time.Parse("2006-01", month)
|
||
if err != nil {
|
||
// 解析不了就把原始 key 原样带出去,总比句子里空一格强。
|
||
return month
|
||
}
|
||
if strings.HasPrefix(lang, i18n.LangEn) {
|
||
return m.Month().String()
|
||
}
|
||
return fmt.Sprintf("%d 月", int(m.Month()))
|
||
}
|
||
|
||
// 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)
|
||
}
|