Files
tamakyi cbb8908275 feat(stats): 近 30 天折线补纵轴刻度
原来这条折线只有横轴日期,纵轴没有任何参照:读者看到峰谷不知道自己
在看 3 次还是 300 次的起伏。现在补上三条刻度(上限 / 一半 / 0)加对应
的横向虚线。

纵轴上限取 niceMax(峰值),不是峰值本身。峰值正好压在顶格里时,读者
分不清那到底是确切的数还是被裁掉的数;向上取整到 1/2/1.5/3/4/6/8×10^n
之后顶部留白固定,三条刻度又都是整数和好读的数(1.5 倍只在量级≥100
时参与,否则算出来是 7.5 这种半格)。上限恒为偶数,中间刻度必然是整数。

刻度文字不放进 SVG:viewBox 用 preserveAspectRatio="none" 横向拉伸填满
卡片,里面的文字会跟着被拉扁,窄屏尤其明显。改成左边一列 HTML 标签 +
横向虚线仍在 SVG 里(描边锁 non-scaling-stroke)。列宽靠一份看不见的
「最宽刻度」占位撑开,不写死 em。

同时把 Polyline/AreaPath 收敛成单个 NewLineChart:坐标、面积、刻度必须
共用同一个上限,分两次算就会各按自己看到的峰值归一化,出现刻度和线错开
的图。面积路径改为沿基线闭合,不再漏进轴标签的留白区。

新增 chart_test.go 锁住三条不变式:中间刻度必为整数、上限不离开峰值一倍、
刻度/折线/面积共用上限;另有 0..2000 的穷举检查。
2026-10-24 01:57:36 +08:00

261 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
"encoding/json"
"fmt"
"html"
"html/template"
"strconv"
"strings"
"sync"
"time"
"gorm.io/datatypes"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/i18n"
"github.com/tamakyi/TamaBox/internal/security/moderation"
"github.com/tamakyi/TamaBox/internal/theme"
)
var (
funcMap []template.FuncMap
funcMapOnce sync.Once
)
func FuncMap() []template.FuncMap {
funcMapOnce.Do(func() {
funcMap = []template.FuncMap{map[string]interface{}{
"ICP": func() string {
return conf.App.ICP
},
"CommitSHA": func() string {
return conf.BuildCommit
},
"CommitSHAShort": func() string {
if len(conf.BuildCommit) > 7 {
return conf.BuildCommit[:7]
}
return conf.BuildCommit
},
"Date": func(t time.Time, format string) string {
replacer := strings.NewReplacer(datePatterns...)
format = replacer.Replace(format)
return t.Format(format)
},
"DateCompact": func(t time.Time) string {
return dateCompact(t, time.Now())
},
"QuestionFormat": func(input string) template.HTML {
input = html.EscapeString(input)
input = strings.ReplaceAll(input, "\n", "</br>")
return template.HTML(input)
},
"AnswerFormat": func(input string) template.HTML {
input = html.EscapeString(input)
input = strings.ReplaceAll(input, "\n", "</br>")
return template.HTML(input)
},
// MarkdownFormat 将回答按 Markdown 渲染为安全 HTML(含白名单过滤,防 XSS)。
"MarkdownFormat": MarkdownToHTML,
// MarkdownPlainText 将 Markdown 渲染为纯文本(去标记符号、保留文字与换行、压缩空行),
// 供列表页等不希望展示大字号/彩色排版的场景使用。
"MarkdownPlainText": PlainText,
"SentryDSN": func() string {
return conf.App.SentryDSN
},
// announcement 返回当前应展示的公告(已转义的安全 HTML),窗口外返回空串。
// 时间窗判定放在服务端:不依赖访客时钟,也不会先渲染出来再被脚本抹掉。
// 按访客语言取文案,未翻译的语言回退到 content。
"announcement": func(lang string) template.HTML {
content, ok := conf.Announcement.Active(time.Now(), lang)
if !ok {
return ""
}
return AnnouncementHTML(content)
},
"ParsePublicURLs": func(input datatypes.JSON) string {
urls := make(map[string]string)
if err := json.Unmarshal(input, &urls); err != nil {
return ""
}
for _, url := range urls {
return url
}
return ""
},
"ImageBucketCDNHost": func() string {
return conf.Upload.ImageBucketCDNHost
},
"sub": func(a, b int) int {
return a - b
},
// dict 将键值对打包为 map,供 {{template "partial" (dict "Key" val ...)}} 向部分模板传参。
// 参数必须成对且键为字符串,否则返回错误(html/template 会将该错误作为执行错误抛出)。
"dict": func(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict: odd number of arguments: %d", len(values))
}
d := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict: key at index %d is not a string", i)
}
d[key] = values[i+1]
}
return d, nil
},
"mod": func(a, b int) int {
if b == 0 {
return 0
}
return a % b
},
// loop 生成 1..n 的整数切片,用于模板中按数量 range(如评分 1-10)。
"loop": func(n int) []int {
if n <= 0 {
return []int{}
}
out := make([]int, n)
for i := 1; i <= n; i++ {
out[i-1] = i
}
return out
},
// hasDay 判断逗号分隔的周几列表(0=周日…6=周六,与 time.Weekday 一致)是否包含 wd。
// 用于功能管理页渲染循环定时的开放日复选框。
"hasDay": func(days string, wd int) bool {
for _, part := range strings.Split(days, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.Atoi(part)
if err == nil && n == wd {
return true
}
}
return false
},
// parseOptions 解析投票表单字段的 JSON 选项数组(select/radio/checkbox)。
"parseOptions": func(raw string) []string {
if raw == "" {
return []string{}
}
var out []string
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return []string{}
}
return out
},
// percentOf 计算百分比(模板内无法做除法)。
"percentOf": func(part, total int64) int {
if total <= 0 {
return 0
}
return int(part * 100 / total)
},
// add 加法(模板序号 1-based 等场景)。
"add": func(a, b int) int {
return a + b
},
// fieldTypeLabel 返回表单字段类型的本地化名称(参与端题型标签)。
"fieldTypeLabel": func(lang, fieldType string) string {
key := map[string]string{
"text": "vote.fieldTypeText",
"textarea": "vote.fieldTypeTextarea",
"select": "vote.fieldTypeSelect",
"radio": "vote.fieldTypeRadio",
"checkbox": "vote.fieldTypeCheckbox",
"number": "vote.fieldTypeNumber",
"rating": "vote.fieldTypeRating",
"rank": "vote.fieldTypeRank",
}[fieldType]
if key == "" {
return ""
}
return i18n.T(lang, key)
},
// toJSON 将值序列化为 JSON 并标记为安全的 JS 字面量。
// 必须用于 <script> 上下文中的 JSON 注入:template.HTML 会在 script 上下文中
// 被 html/template 按 JS 规则转义(引号被转义导致 JS 语法错误/值变成字符串),
// template.JS 则原样输出。
"toJSON": func(v interface{}) template.JS {
b, err := json.Marshal(v)
if err != nil {
return template.JS("[]")
}
return template.JS(b)
},
"Safe": Safe,
// 兼容小写别名:模板中历史遗留 {{safe ...}} 调用
"safe": Safe,
// i18n 模板函数:
// 模板用法:{{t .LangCode "key"}} 或 {{t .LangCode "key" .Arg}}
// 语言名:{{langName "zh-CN"}} → 简体中文
"t": func(lang, key string, args ...interface{}) string {
return i18n.T(lang, key, args...)
},
"langName": func(lang string) string {
return i18n.Name(lang)
},
// themeName 返回主题显示名:i18n 没有 theme.name.<slug> 时回退成 slug 本身。
// 别在模板里直接 {{t .LangCode (printf "theme.name.%s" .Slug)}} ——
// i18n.T 缺词条返回 key,设置页就会显示 "theme.name.test01"。
"themeName": func(lang, slug string) string {
return theme.DisplayName(lang, slug)
},
// safeCSS 把受控的 CSS 值标成安全,用于主题色块的 inline background。
// ⚠️ html/template 会过滤 style 属性里的值,linear-gradient(...) 直接被替换成
// ZgotmplZ —— 设置页每个主题的色块都会变成灰底(线上就是这样)。
// 值只来自 internal/theme.swatchOf(由两个已校验的色值拼成),不接受用户输入。
"safeCSS": func(s string) template.CSS {
return template.CSS(s)
},
// moderationStrictness 返回账号「最终生效」的审核严格度档位(lax / standard / strict)。
//
// 设置页下拉框必须用它回显,不能直接用 LoggedUser.ModerationStrictness:
// 存量账号该字段为空值(表示"跟随站点默认"),三个 <option> 的 eq 判定全部为假,
// 浏览器会默认选中第一个选项(宽松),而实际生效的是站点默认档位(通常为严格)——
// 界面会告诉箱主"你是宽松",与真实拦截行为相反。
// 走 moderation.StrictnessFor 解析,保证回显与 internal/security/censor/policy.go
// 的裁决口径是同一份事实来源(含站点默认与非法值回退)。
"moderationStrictness": func(raw string) string {
return string(moderation.StrictnessFor(raw))
},
// 全站数据页的图表辅助:数字格式化 + 折线几何(见 chart.go)。
// 站点不引前端图表库,柱状 / 环形 / 热力图都用 CSS 画,只有折线需要坐标。
"NumInt": NumInt,
"PctText": PctText,
"LineChart": NewLineChart,
"HeatLevel": HeatLevel,
}}
})
return funcMap
}
func Safe(raw string) template.HTML {
return template.HTML(raw)
}
// dateCompact 手机端列表用的紧凑时间:与 now 同一年的只给「月-日 时:分」。
// 手机一行的宽度放不下「Y-m-d H:i:s」(单它就要 127px),但只砍年份会让
// 「去年的今天」和「今年今天」渲染成同一串,所以在跨年时自动补回年份。
// 注意:这是「逐条补年份」,不是「按年分组」—— 列表带游标分页、可切排序与筛选,
// 分组标题会跨页重复/错位,收益不如补年份直接。
func dateCompact(t, now time.Time) string {
if t.Year() == now.Year() {
return t.Format("01-02 15:04")
}
return t.Format("2006-01-02 15:04")
}