Files
tamabox/internal/template/template.go
T
tamakyi c680671b7e feat(user/questions): 手机端时间跨年自动补年份,避免省略年份后产生歧义
DateCompact:与当前同一年的渲染「月-日 时:分」,跨年补回完整「年-月-日 时:分」。
只砍年份会让「去年今天」和「今年今天」显示成同一串,逐条补年份比按年分组便宜得多
(列表带游标分页 + 可切排序/筛选,组标题会跨页重复或错位)。
桌面端不受影响,仍显示 Y-m-d H:i:s。
测试覆盖年份边界(同年 1/1 与 12/31、跨年);预览数据里放了一条 2025 年的提问用于肉眼核对。
2026-10-23 21:04:50 +08:00

254 lines
8.8 KiB
Go
Raw 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))
},
}}
})
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")
}