feat(web): 多语言界面(中/英/日)与用户语言偏好字段

- 新增 internal/i18n 包:以原始中文字符串为 key 的翻译目录(zh 恒等,
  en 兜底,ja 缺译回退 en),t/tf 模板函数与 Accept-Language 解析
  (按 q 值排序,兜底英语)
- User 模型新增 language 字段(auto|en|zh|ja,默认 auto),
  AutoMigrate 自动加列;UserStore 新增 UpdateLanguage
- 语言解析:登录用户按偏好(auto 时取浏览器 Accept-Language),
  登录/封禁页按浏览器语言;所有 c.HTML 渲染注入 Lang 模板数据
- 全部页面模板(用户页 + 管理后台 12 页)接入翻译:导航、文件夹、
  邮件列表/阅读/写信、设置、登录、封禁页、表单、表格、JS 确认框等;
  html lang 属性随语言切换;日期函数(shortDate/time12/time12m)
  与 folderLabel 改为语言感知(上午/下午 vs AM/PM vs 午前/午後)
- 设置页新增「界面语言」选择(自动/English/中文/日本語),
  /settings 表单支持单独提交语言偏好;管理后台用户表单可预设语言
- 封禁原因等入库中文文案在展示时经 t 翻译(未知 key 原样回退)
- 测试:i18n 单测(解析/回退/译文)、render 测试补 Lang 数据、
  时间格式化测试适配新签名;全量 go test 通过
This commit is contained in:
dsh
2026-08-20 00:21:10 -04:00
parent d1ffd10408
commit c881146a40
38 changed files with 1909 additions and 768 deletions
+49 -27
View File
@@ -18,6 +18,7 @@ import (
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/i18n"
"mail_go/internal/imap_server"
"mail_go/internal/mailutil"
"mail_go/internal/outbound"
@@ -84,6 +85,21 @@ func templateFuncs() template.FuncMap {
"domainName": func(domainID uint, domains []interface{}) string {
return fmt.Sprintf("Domain #%d", domainID)
},
// t / tf 多语言翻译(见 internal/i18n):t 返回译文,
// tf 对译文做 fmt.Sprintf(带占位符的消息)。
"t": i18n.T,
"tf": i18n.TF,
// htmlLang 返回 <html lang="..."> 属性值(zh → zh-CN)。
"htmlLang": func(lang string) string {
switch i18n.Normalize(lang) {
case i18n.LangZh:
return "zh-CN"
case i18n.LangJa:
return "ja"
default:
return "en"
}
},
// jsonify 把任意值序列化为安全的 JS 字面量(JSON 字符串),
// 用于在 <script> 上下文中注入数据。encoding/json 默认转义
// < > &\u003c 等),无法逃出 </script>,杜绝 script 注入。
@@ -108,12 +124,11 @@ func templateFuncs() template.FuncMap {
"initial": initial,
// truncate 折叠空白并截断到 n 个字符(用于列表摘要)。
"truncate": truncate,
// shortDate 邮件列表时间:统一 12 小时制(上午/下午)
// shortDate 邮件列表时间:按界面语言输出 12 小时制
// 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」,
// 更早显示「2026-08-20 下午 2:35」。
"shortDate": shortDate,
// time12 完整时间(含秒),先转换为 Web 时区
// 「2026-08-20 下午 2:35:05」。
// time12 完整时间(含秒),先转换为 Web 时区
"time12": time12,
// time12m 同上但不含秒。
"time12m": time12m,
@@ -123,24 +138,30 @@ func templateFuncs() template.FuncMap {
"avatarStyle": avatarStyle,
// urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。
"urlPath": url.PathEscape,
// folderLabel 返回文件夹的界面显示名(系统文件夹中文名,自定义原名)。
"folderLabel": func(name string) string {
switch name {
case "INBOX":
return "收件箱"
case "Sent":
return "已发送"
case "Drafts":
return "草稿箱"
case "Trash":
return "已删除"
default:
return name
}
// folderLabel 返回文件夹的界面显示名(系统文件夹按语言翻译,自定义原名)。
"folderLabel": func(lang, name string) string {
return folderLabel(lang, name)
},
}
}
// folderLabel 返回文件夹的界面显示名:系统文件夹按界面语言翻译,
// 自定义文件夹原样返回。
func folderLabel(lang, name string) string {
switch name {
case "INBOX":
return i18n.T(lang, "收件箱")
case "Sent":
return i18n.T(lang, "已发送")
case "Drafts":
return i18n.T(lang, "草稿箱")
case "Trash":
return i18n.T(lang, "已删除")
default:
return name
}
}
// mailName extracts the display name from an RFC 5322 address.
func mailName(s string) string {
s = strings.TrimSpace(s)
@@ -186,11 +207,12 @@ func truncate(s string, n int) string {
return string(r[:n]) + "…"
}
// periodOf 返回 12 小时制的时间段与小时:上午/下午 + 1-12。
func periodOf(t time.Time) (string, int) {
period := "上午"
// periodOf 返回 12 小时制的时间段与小时:按界面语言输出
// 上午/下午(zh)、AM/PM(en)、午前/午後(ja),小时 1-12。
func periodOf(lang string, t time.Time) (string, int) {
period := i18n.T(lang, "上午")
if t.Hour() >= 12 {
period = "下午"
period = i18n.T(lang, "下午")
}
h := t.Hour() % 12
if h == 0 {
@@ -202,10 +224,10 @@ func periodOf(t time.Time) (string, int) {
// shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换):
// 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」;
// 更早 → 「2026-08-20 下午 2:35」。
func shortDate(t time.Time) string {
func shortDate(lang string, t time.Time) string {
t = inWebTZ(t)
now := time.Now().In(t.Location())
period, h := periodOf(t)
period, h := periodOf(lang, t)
clock := fmt.Sprintf("%s %d:%02d", period, h, t.Minute())
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return clock
@@ -218,16 +240,16 @@ func shortDate(t time.Time) string {
// time12 完整时间(12 小时制,先按 Web 配置时区转换):
// 「2026-08-20 下午 2:35:05」。
func time12(t time.Time) string {
func time12(lang string, t time.Time) string {
t = inWebTZ(t)
period, h := periodOf(t)
period, h := periodOf(lang, t)
return fmt.Sprintf("%s %s %d:%02d:%02d", t.Format("2006-01-02"), period, h, t.Minute(), t.Second())
}
// time12m 完整时间(12 小时制,不含秒)。
func time12m(t time.Time) string {
func time12m(lang string, t time.Time) string {
t = inWebTZ(t)
period, h := periodOf(t)
period, h := periodOf(lang, t)
return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute())
}