首页底部那张「站点统计」卡片下线,完整数据移到公开只读页 /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 对齐
262 lines
9.1 KiB
Go
262 lines
9.1 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 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,
|
||
"Polyline": Polyline,
|
||
"AreaPath": AreaPath,
|
||
"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")
|
||
}
|