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
+736
View File
@@ -0,0 +1,736 @@
package i18n
// en 英文目录:key 为界面中的原始中文字符串。
// 英语是兜底语言:ja 缺译时回退这里,未知语言也回退这里。
var en = map[string]string{
// ---------- 12 小时制时段(日期函数) ----------
"上午": "AM",
"下午": "PM",
// ---------- 系统文件夹名(folderLabel ----------
"收件箱": "Inbox",
"已发送": "Sent",
"草稿箱": "Drafts",
"已删除": "Trash",
// ---------- 顶栏 / 侧栏(base.html ----------
"邮箱": "Mail",
"搜索邮件(发件人 / 主题)": "Search mail (sender / subject)",
"管理后台": "Admin",
"设置": "Settings",
"退出登录": "Log out",
"写信": "Compose",
// ---------- 邮件列表(folder.html ----------
"全选/取消全选": "Select all / deselect all",
"全选": "Select all",
"刷新": "Refresh",
"删除": "Delete",
"彻底删除": "Delete permanently",
"确定要清空「已删除」文件夹吗?此操作不可恢复。": "Empty the Trash folder? This cannot be undone.",
"清空文件夹": "Empty folder",
"共 %d 封": "%d messages",
"暂无邮件": "No messages",
"收件人:%s": "To: %s",
"致:": "To: ",
"(无主题)": "(no subject)",
"[HTML 邮件]": "[HTML email]",
"确定要恢复这封邮件吗?": "Restore this message?",
"恢复到收件箱": "Restore to inbox",
"确定要删除这封邮件吗?": "Delete this message?",
"第 %d / %d 页": "Page %d of %d",
"上一页": "Previous",
"下一页": "Next",
"确定要删除选中的 %s 封邮件吗?": "Delete the %s selected message(s)?",
// ---------- 邮件阅读(view.html ----------
"查看邮件": "View message",
"返回": "Back",
"回复": "Reply",
"恢复": "Restore",
"确定要彻底删除这封邮件吗?此操作不可恢复。": "Permanently delete this message? This cannot be undone.",
"删除邮件": "Delete message",
"抄送:": "Cc: ",
// ---------- 写信(compose.html ----------
"发送": "Send",
"附件": "Attach",
"取消": "Cancel",
"撰写新邮件": "New message",
"收件人": "To",
"输入收件人邮箱地址": "Recipient email address",
"抄送": "Cc",
"多个地址用逗号分隔(可选)": "Separate multiple addresses with commas (optional)",
"主题": "Subject",
"输入邮件主题": "Email subject",
"请输入邮件内容...": "Enter your message...",
"附件配额": "Attachment quota",
"移除": "Remove",
// ---------- 设置(settings.html ----------
"⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。": "⚠️ First login / password has been reset. Please change your password before using the mailbox.",
"账号信息": "Account info",
"已用 %s / 配额 %s": "Used %s / Quota %s",
"管理员": "Admin",
"修改密码": "Change password",
"当前密码": "Current password",
"请输入当前密码": "Enter current password",
"新密码": "New password",
"请输入新密码": "Enter new password",
"确认新密码": "Confirm new password",
"请再次输入新密码": "Re-enter new password",
"界面语言": "Interface language",
"自动(跟随浏览器)": "Auto (browser)",
"保存": "Save",
"语言偏好已保存": "Language preference saved",
"语言偏好保存失败": "Failed to save language preference",
"帮助": "Help",
"客户端收发信(IMAP / SMTP)配置:": "IMAP / SMTP client settings:",
"IMAP 服务器:%s 端口 %d / SSL %d": "IMAP server: %s port %d / SSL %d",
"SMTP 服务器:%s 端口 %d(提交)/ SSL %d": "SMTP server: %s port %d (submission) / SSL %d",
// ---------- 登录(login.html ----------
"登录": "Log in",
"登录您的邮箱账户": "Log in to your mailbox",
"邮箱地址": "Email address",
"密码": "Password",
"请输入密码": "Enter password",
"登 录": "Log in",
"或": "or",
"LDAP 用户名": "LDAP username",
"LDAP 密码": "LDAP password",
"LDAP 登录": "LDAP sign in",
// ---------- 封禁页(banned.html ----------
"访问被禁止": "Access denied",
"您的 IP 已被暂时禁止访问": "Your IP has been temporarily blocked",
"由于登录失败次数过多,您的 IP 地址已被暂时封禁。": "Your IP address has been temporarily banned due to too many failed login attempts.",
"IP 地址": "IP address",
"封禁档位": "Ban level",
"第 %d 次封禁": "Ban #%d",
"原因": "Reason",
"失败次数": "Failed attempts",
"%d 次": "%d times",
"解封时间": "Unban time",
"请在解封时间后重试,或联系管理员。": "Please retry after the unban time, or contact the administrator.",
"返回登录页": "Back to login",
// ---------- 管理后台侧栏(所有 admin 页面) ----------
"返回邮箱": "Back to mailbox",
"控制面板": "Dashboard",
"域名管理": "Domains",
"用户管理": "Users",
"所有邮件": "All mail",
"外发队列": "Outbound queue",
"协议日志": "Protocol logs",
"当前连接": "Connections",
"IP黑名单": "IP blacklist",
// ---------- 控制面板(dashboard.html ----------
"域名数": "Domains",
"用户数": "Users",
"邮件总数": "Total mail",
"被封IP": "Banned IPs",
"邮件分布": "Mail distribution",
"文件夹": "Folder",
"邮件数": "Messages",
"占用空间": "Size",
"合计": "Total",
"收发统计": "Send/receive stats",
"时间段": "Period",
"收件": "Received",
"发件": "Sent",
"今日": "Today",
"近 7 天": "Last 7 days",
"快捷操作": "Quick actions",
"新增域名": "Add domain",
"新增用户": "Add user",
"查看所有邮件": "View all mail",
// ---------- 用户管理(users.html / user_form.html ----------
"用户列表": "Users",
"暂无用户": "No users",
"用户名": "Username",
"域名ID": "Domain ID",
"配额": "Quota",
"已用": "Used",
"状态": "Status",
"操作": "Actions",
"编辑": "Edit",
"确定要删除用户 %s 吗?": "Delete user %s?",
"编辑用户": "Edit user",
"新密码(留空则不修改)": "New password (leave blank to keep)",
"域名": "Domain",
"请选择域名": "Select a domain",
"配额 (GB)": "Quota (GB)",
"启用账户": "Active account",
"保存更改": "Save changes",
"创建用户": "Create user",
// ---------- 域名管理(domains.html / domain_form.html / dns_hint.html ----------
"域名列表": "Domains",
"暂无域名": "No domains",
"确定要删除域名 %s 吗?删除域名将导致关联用户无法收发邮件!": "Delete domain %s? Users of this domain will no longer be able to send or receive mail!",
"编辑域名": "Edit domain",
"SMTP 端口": "SMTP port",
"IMAP 端口": "IMAP port",
"POP3 端口": "POP3 port",
"启用 TLS": "Enable TLS",
"勾选后端口将自动切换为 SSL/TLS 标准端口": "When checked, ports switch to standard SSL/TLS ports automatically",
"TLS 私钥 PEM": "TLS private key (PEM)",
"TLS 公钥证书 PEM": "TLS certificate (PEM)",
"私钥不会回显;留空则保留现有私钥。": "The private key is not shown; leave blank to keep the current key.",
"✅ TLS 证书已配置;上传新证书后自动热加载生效。": "✅ Certificate configured; uploading a new certificate hot-reloads automatically.",
"⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。": "⚠️ No certificate configured. To enable TLS you must provide both the private key and the certificate.",
"从 Caddy 获取证书": "Fetch certificate from Caddy",
"若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。": "If this domain is served by local Caddy with HTTPS (automatic certificates), import its certificate and private key with one click; TLS is enabled and hot-reloaded, no restart needed.",
"🔒 从 Caddy 获取证书": "🔒 Fetch certificate from Caddy",
"获取中…": "Fetching...",
"获取失败": "Fetch failed",
"请求失败": "Request failed",
"重新生成 DKIM 密钥": "Regenerate DKIM key",
"✅ DKIM 密钥已配置": "✅ DKIM key configured",
"⚠️ DKIM 密钥未配置": "⚠️ DKIM key not configured",
"创建域名": "Create domain",
"DNS配置": "DNS configuration",
"DNS 配置提示 — %s": "DNS configuration hint — %s",
"请在您的 DNS 服务商处添加以下记录:": "Add the following records at your DNS provider:",
"MX 记录": "MX record",
"SPF 记录 (TXT)": "SPF record (TXT)",
"DKIM 记录 (TXT)": "DKIM record (TXT)",
"DMARC 记录": "DMARC record",
"⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。": "⚠️ DKIM key not generated yet. Edit the domain to regenerate it.",
"返回域名列表": "Back to domains",
// ---------- 所有邮件(mails.html / mail_view.html ----------
"发件人": "From",
"所属用户": "Owner",
"日期": "Date",
"全部": "All",
"返回邮件列表": "Back to list",
"发件人:": "From:",
"收件人:": "To:",
"抄送:": "Cc:",
"所属用户:": "Owner:",
"文件夹:": "Folder:",
"时间:": "Time:",
"附件列表": "Attachments",
// ---------- 外发队列(outbound.html ----------
"队列为空": "Queue is empty",
"待发送": "Pending",
"发送中": "Sending",
"已送达": "Delivered",
"等待重试": "Waiting to retry",
"失败": "Failed",
"已取消": "Canceled",
"尝试次数": "Attempts",
"下次重试": "Next retry",
"最后响应 / 错误": "Last response / error",
"创建时间": "Created",
"重试": "Retry",
"确认取消该投递任务?": "Cancel this delivery task?",
// ---------- 协议日志(protocol_logs.html ----------
"协议日志(SMTP / IMAP / POP3": "Protocol logs (SMTP / IMAP / POP3)",
"清理旧日志": "Clean old logs",
"确认清理 %d 天前的协议日志?": "Delete protocol logs older than %d days?",
"今日 SMTP 失败": "SMTP failures today",
"今日 IMAP 失败": "IMAP failures today",
"今日 POP3 失败": "POP3 failures today",
"历史失败(全部)": "Historical failures (all)",
"全部协议": "All protocols",
"全部状态": "All statuses",
"成功": "Success",
"来源 IP(模糊)": "Source IP (fuzzy)",
"用户名(模糊)": "Username (fuzzy)",
"至": "to",
"筛选": "Filter",
"重置": "Reset",
"时间": "Time",
"端口": "Port",
"失败原因": "Failure reason",
"操作摘要": "Summary",
"消息数": "Messages",
"时长": "Duration",
"暂无记录": "No records",
"第 %d / %d 页(共 %d 条)": "Page %d of %d (%d entries)",
// ---------- 当前连接(connections.html ----------
"当前连接(SMTP / IMAP / POP3": "Active connections (SMTP / IMAP / POP3)",
"每 5 秒自动刷新": "Auto-refreshes every 5 seconds",
"当前连接总数": "Total active connections",
"协议": "Protocol",
"来源 IP": "Source IP",
"TLS": "TLS",
"明文": "Plain",
"连接时间": "Connected",
"最后活跃": "Last active",
"断开并封禁": "Disconnect & ban",
"当前没有活动连接": "No active connections",
"确定要断开 IP %s 的所有连接并加入黑名单(180 天)吗?": "Disconnect all connections of IP %s and add to blacklist (180 days)?",
// ---------- IP 黑名单(bans.html ----------
"IP 黑名单": "IP blacklist",
"阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)": "Staged bans: banning starts at the 4th trigger; 30 min → 3 h → 3 mo → 6 mo (max)",
"封禁次数": "Ban count",
"到期时间": "Expires",
"封禁中": "Banned",
"已过期": "Expired",
"仅计数": "Counting only",
"(已过期)": " (expired)",
"解封": "Unban",
"确定要解封 IP %s 吗?解封后该 IP 的封禁档位将清零。": "Unban IP %s? Its ban level will be reset.",
"暂无封禁记录": "No ban records",
// ---------- 登录/认证错误(auth.go ----------
"请输入邮箱和密码": "Please enter your email and password",
"用户名或密码错误,还剩 %d 次尝试机会": "Incorrect username or password. %d attempts remaining",
"会话保存失败,请重试": "Failed to save session, please retry",
"请输入LDAP用户名和密码": "Please enter your LDAP username and password",
"LDAP 认证失败,还剩 %d 次尝试机会": "LDAP authentication failed. %d attempts remaining",
"LDAP 账号未接入本系统,请联系管理员": "LDAP account is not linked to this system. Contact the administrator",
"用户已被禁用": "User is disabled",
"OAuth2 state 校验失败,请重新发起登录": "OAuth2 state validation failed, please log in again",
"OAuth2 授权码缺失": "Missing OAuth2 authorization code",
"OAuth2 认证失败,请重试或联系管理员": "OAuth2 authentication failed. Please retry or contact the administrator",
"OAuth2 账号未接入本系统,请联系管理员": "OAuth2 account is not linked to this system. Contact the administrator",
"OAuth2 未启用": "OAuth2 is not enabled",
"OAuth2 登录暂不可用,请稍后重试": "OAuth2 login is temporarily unavailable, please try again later",
"登录失败次数过多": "Too many failed login attempts",
"LDAP 登录失败次数过多": "Too many LDAP login failures",
// ---------- 邮件操作错误(mail.go ----------
"请输入收件人": "Please enter a recipient",
"配额检查失败,请稍后重试": "Quota check failed, please try again later",
"附件超出配额限制。已用 %s / 总配额 %s": "Attachments exceed the quota. Used %s / total %s",
"外部投递未启用": "Outbound delivery is not enabled",
"外部收件人过多:最多 %d 个": "Too many external recipients: maximum %d",
"外发邮件入队失败 (%s): %v": "Failed to enqueue outbound mail (%s): %v",
"投递邮件失败: %v": "Failed to deliver mail: %v",
"保存邮件失败: %v": "Failed to save mail: %v",
"当前密码不正确": "Current password is incorrect",
"新密码不能为空": "New password cannot be empty",
"两次输入的密码不一致": "The two passwords do not match",
"密码加密失败": "Failed to encrypt password",
"密码更新失败": "Failed to update password",
"密码修改成功": "Password changed successfully",
"邮箱不存在": "Mailbox does not exist",
"加载邮件列表失败: %v": "Failed to load messages: %v",
"无效的邮件ID": "Invalid message ID",
"邮件不存在": "Message does not exist",
"禁止访问": "Access denied",
"无效的附件ID": "Invalid attachment ID",
"附件不存在": "Attachment does not exist",
"读取附件失败": "Failed to read attachment",
"删除失败": "Delete failed",
"恢复失败": "Restore failed",
"清空文件夹失败: %v": "Failed to empty folder: %v",
// ---------- 管理操作错误(admin.go ----------
"无效的连接ID": "Invalid connection ID",
"连接不存在或已断开": "Connection does not exist or is disconnected",
"管理员手动封禁(连接断开)": "Banned manually by admin (connection disconnected)",
"封禁失败: %v": "Ban failed: %v",
"加载域名列表失败: %v": "Failed to load domains: %v",
"请输入域名": "Please enter a domain name",
"创建域名失败: %v": "Failed to create domain: %v",
"无效的域名ID": "Invalid domain ID",
"域名不存在": "Domain does not exist",
"✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。": "✅ Certificate fetched from Caddy and saved to the domain TLS directory; TLS enabled and hot-reloaded, no restart needed.",
"启用 TLS 时必须填写 TLS 私钥和公钥证书": "TLS private key and certificate are required to enable TLS",
"TLS 私钥和公钥证书必须同时填写": "TLS private key and certificate must be provided together",
"TLS 证书或私钥无效: %v": "Invalid TLS certificate or private key: %v",
"创建 TLS 证书目录失败: %v": "Failed to create TLS directory: %v",
"保存 TLS 公钥证书失败: %v": "Failed to save TLS certificate: %v",
"保存 TLS 私钥失败: %v": "Failed to save TLS private key: %v",
"从 Caddy 获取证书失败: %v": "Failed to fetch certificate from Caddy: %v",
"更新域名记录失败: %v": "Failed to update domain record: %v",
"更新域名失败: %v": "Failed to update domain: %v",
"删除域名失败: %v": "Failed to delete domain: %v",
"加载用户列表失败: %v": "Failed to load users: %v",
"请填写所有必填字段": "Please fill in all required fields",
"创建用户失败: %v": "Failed to create user: %v",
"无效的用户ID": "Invalid user ID",
"不能删除自己的账户": "You cannot delete your own account",
"删除用户失败: %v": "Failed to delete user: %v",
"用户不存在": "User does not exist",
"更新用户失败: %v": "Failed to update user: %v",
"加载黑名单失败: %v": "Failed to load blacklist: %v",
"无效的记录ID": "Invalid record ID",
"解封失败: %v": "Unban failed: %v",
"加载协议日志失败: %v": "Failed to load protocol logs: %v",
"加载外发队列失败: %v": "Failed to load outbound queue: %v",
"外发服务不可用": "Outbound service is unavailable",
"重试失败: %v": "Retry failed: %v",
"取消失败: %v": "Cancel failed: %v",
"无效的队列ID": "Invalid queue ID",
"禁止访问:需要管理员权限": "Access denied: administrator privileges required",
}
// ja 日文目录:key 为界面中的原始中文字符串;缺译回退英文目录。
var ja = map[string]string{
// ---------- 12 小时制时段(日期函数) ----------
"上午": "午前",
"下午": "午後",
// ---------- 系统文件夹名(folderLabel ----------
"收件箱": "受信トレイ",
"已发送": "送信済み",
"草稿箱": "下書き",
"已删除": "ゴミ箱",
// ---------- 顶栏 / 侧栏(base.html ----------
"邮箱": "メール",
"搜索邮件(发件人 / 主题)": "メールを検索(送信者 / 件名)",
"管理后台": "管理画面",
"设置": "設定",
"退出登录": "ログアウト",
"写信": "作成",
// ---------- 邮件列表(folder.html ----------
"全选/取消全选": "すべて選択 / 選択解除",
"全选": "すべて選択",
"刷新": "更新",
"删除": "削除",
"彻底删除": "完全に削除",
"确定要清空「已删除」文件夹吗?此操作不可恢复。": "「ゴミ箱」フォルダを空にしますか?この操作は元に戻せません。",
"清空文件夹": "フォルダを空にする",
"共 %d 封": "%d 件",
"暂无邮件": "メールはありません",
"收件人:%s": "宛先:%s",
"致:": "宛先:",
"(无主题)": "(件名なし)",
"[HTML 邮件]": "[HTML メール]",
"确定要恢复这封邮件吗?": "このメールを復元しますか?",
"恢复到收件箱": "受信トレイへ復元",
"确定要删除这封邮件吗?": "このメールを削除しますか?",
"第 %d / %d 页": "%d / %d ページ",
"上一页": "前へ",
"下一页": "次へ",
"确定要删除选中的 %s 封邮件吗?": "選択した %s 件のメールを削除しますか?",
// ---------- 邮件阅读(view.html ----------
"查看邮件": "メールを表示",
"返回": "戻る",
"回复": "返信",
"恢复": "復元",
"确定要彻底删除这封邮件吗?此操作不可恢复。": "このメールを完全に削除しますか?この操作は元に戻せません。",
"删除邮件": "メールを削除",
"抄送:": "Cc",
// ---------- 写信(compose.html ----------
"发送": "送信",
"附件": "添付",
"取消": "キャンセル",
"撰写新邮件": "新しいメールを作成",
"收件人": "宛先",
"输入收件人邮箱地址": "宛先メールアドレスを入力",
"抄送": "Cc",
"多个地址用逗号分隔(可选)": "複数のアドレスはカンマ区切り(任意)",
"主题": "件名",
"输入邮件主题": "メールの件名を入力",
"请输入邮件内容...": "メール本文を入力してください...",
"附件配额": "添付ファイル容量",
"移除": "削除",
// ---------- 设置(settings.html ----------
"⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。": "⚠️ 初回ログインまたはパスワードリセット後です。メール機能を利用する前にパスワードを変更してください。",
"账号信息": "アカウント情報",
"已用 %s / 配额 %s": "使用 %s / 容量 %s",
"管理员": "管理者",
"修改密码": "パスワード変更",
"当前密码": "現在のパスワード",
"请输入当前密码": "現在のパスワードを入力",
"新密码": "新しいパスワード",
"请输入新密码": "新しいパスワードを入力",
"确认新密码": "新しいパスワード(確認)",
"请再次输入新密码": "新しいパスワードを再入力",
"界面语言": "表示言語",
"自动(跟随浏览器)": "自動(ブラウザに従う)",
"保存": "保存",
"语言偏好已保存": "言語設定を保存しました",
"语言偏好保存失败": "言語設定の保存に失敗しました",
"帮助": "ヘルプ",
"客户端收发信(IMAP / SMTP)配置:": "クライアント設定(IMAP / SMTP):",
"IMAP 服务器:%s 端口 %d / SSL %d": "IMAP サーバー:%s ポート %d / SSL %d",
"SMTP 服务器:%s 端口 %d(提交)/ SSL %d": "SMTP サーバー:%s ポート %d(サブミッション)/ SSL %d",
// ---------- 登录(login.html ----------
"登录": "ログイン",
"登录您的邮箱账户": "メールアカウントにログイン",
"邮箱地址": "メールアドレス",
"密码": "パスワード",
"请输入密码": "パスワードを入力",
"登 录": "ログイン",
"或": "または",
"LDAP 用户名": "LDAP ユーザー名",
"LDAP 密码": "LDAP パスワード",
"LDAP 登录": "LDAP ログイン",
// ---------- 封禁页(banned.html ----------
"访问被禁止": "アクセスが禁止されています",
"您的 IP 已被暂时禁止访问": "お使いの IP は一時的にアクセス禁止されています",
"由于登录失败次数过多,您的 IP 地址已被暂时封禁。": "ログイン失敗が多すぎるため、IP アドレスが一時的にブロックされています。",
"IP 地址": "IP アドレス",
"封禁档位": "ブロック段階",
"第 %d 次封禁": "%d 回目のブロック",
"原因": "理由",
"失败次数": "失敗回数",
"%d 次": "%d 回",
"解封时间": "ブロック解除時刻",
"请在解封时间后重试,或联系管理员。": "ブロック解除後にもう一度お試しいただくか、管理者に連絡してください。",
"返回登录页": "ログインページに戻る",
// ---------- 管理后台侧栏(所有 admin 页面) ----------
"返回邮箱": "メールボックスに戻る",
"控制面板": "ダッシュボード",
"域名管理": "ドメイン管理",
"用户管理": "ユーザー管理",
"所有邮件": "すべてのメール",
"外发队列": "送信キュー",
"协议日志": "プロトコルログ",
"当前连接": "現在の接続",
"IP黑名单": "IP ブラックリスト",
// ---------- 控制面板(dashboard.html ----------
"域名数": "ドメイン数",
"用户数": "ユーザー数",
"邮件总数": "メール総数",
"被封IP": "ブロック済み IP",
"邮件分布": "メール分布",
"文件夹": "フォルダ",
"邮件数": "メール数",
"占用空间": "使用容量",
"合计": "合計",
"收发统计": "送受信統計",
"时间段": "期間",
"收件": "受信",
"发件": "送信",
"今日": "今日",
"近 7 天": "過去 7 日間",
"快捷操作": "クイック操作",
"新增域名": "ドメインを追加",
"新增用户": "ユーザーを追加",
"查看所有邮件": "すべてのメールを表示",
// ---------- 用户管理(users.html / user_form.html ----------
"用户列表": "ユーザー一覧",
"暂无用户": "ユーザーはいません",
"用户名": "ユーザー名",
"域名ID": "ドメイン ID",
"配额": "容量",
"已用": "使用量",
"状态": "状態",
"操作": "操作",
"编辑": "編集",
"确定要删除用户 %s 吗?": "ユーザー %s を削除しますか?",
"编辑用户": "ユーザーを編集",
"新密码(留空则不修改)": "新しいパスワード(空欄なら変更なし)",
"域名": "ドメイン",
"请选择域名": "ドメインを選択",
"配额 (GB)": "容量 (GB)",
"启用账户": "アカウントを有効にする",
"保存更改": "変更を保存",
"创建用户": "ユーザーを作成",
// ---------- 域名管理(domains.html / domain_form.html / dns_hint.html ----------
"域名列表": "ドメイン一覧",
"暂无域名": "ドメインはありません",
"确定要删除域名 %s 吗?删除域名将导致关联用户无法收发邮件!": "ドメイン %s を削除しますか?関連ユーザーはメールの送受信ができなくなります!",
"编辑域名": "ドメインを編集",
"SMTP 端口": "SMTP ポート",
"IMAP 端口": "IMAP ポート",
"POP3 端口": "POP3 ポート",
"启用 TLS": "TLS を有効にする",
"勾选后端口将自动切换为 SSL/TLS 标准端口": "チェックするとポートは標準の SSL/TLS ポートに自動的に切り替わります",
"TLS 私钥 PEM": "TLS 秘密鍵 (PEM)",
"TLS 公钥证书 PEM": "TLS 公開鍵証明書 (PEM)",
"私钥不会回显;留空则保留现有私钥。": "秘密鍵は表示されません。空欄の場合は現在の鍵を保持します。",
"✅ TLS 证书已配置;上传新证书后自动热加载生效。": "✅ 証明書は設定済みです。新しい証明書をアップロードすると自動的にホットリロードされます。",
"⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。": "⚠️ 証明書が未設定です。TLS を有効にするには秘密鍵と証明書の両方が必要です。",
"从 Caddy 获取证书": "Caddy から証明書を取得",
"若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。": "このドメインがローカル Caddy で HTTPS 運用されている場合(自動証明書)、証明書と秘密鍵をワンクリックでインポートし、TLS を自動で有効化します。ホットリロードされるため再起動は不要です。",
"🔒 从 Caddy 获取证书": "🔒 Caddy から証明書を取得",
"获取中…": "取得中...",
"获取失败": "取得失敗",
"请求失败": "リクエスト失敗",
"重新生成 DKIM 密钥": "DKIM キーを再生成",
"✅ DKIM 密钥已配置": "✅ DKIM キー設定済み",
"⚠️ DKIM 密钥未配置": "⚠️ DKIM キー未設定",
"创建域名": "ドメインを作成",
"DNS配置": "DNS 設定",
"DNS 配置提示 — %s": "DNS 設定ヒント — %s",
"请在您的 DNS 服务商处添加以下记录:": "DNS プロバイダーで次のレコードを追加してください:",
"MX 记录": "MX レコード",
"SPF 记录 (TXT)": "SPF レコード (TXT)",
"DKIM 记录 (TXT)": "DKIM レコード (TXT)",
"DMARC 记录": "DMARC レコード",
"⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。": "⚠️ DKIM キーが未生成です。ドメインを編集して再生成してください。",
"返回域名列表": "ドメイン一覧に戻る",
// ---------- 所有邮件(mails.html / mail_view.html ----------
"发件人": "送信者",
"所属用户": "所有者",
"日期": "日付",
"全部": "すべて",
"返回邮件列表": "一覧に戻る",
"发件人:": "送信者:",
"收件人:": "宛先:",
"抄送:": "Cc",
"所属用户:": "所有者:",
"文件夹:": "フォルダ:",
"时间:": "時刻:",
"附件列表": "添付ファイル",
// ---------- 外发队列(outbound.html ----------
"队列为空": "キューは空です",
"待发送": "待機中",
"发送中": "送信中",
"已送达": "送信済み",
"等待重试": "再試行待ち",
"失败": "失敗",
"已取消": "キャンセル済み",
"尝试次数": "試行回数",
"下次重试": "次回再試行",
"最后响应 / 错误": "最終レスポンス / エラー",
"创建时间": "作成日時",
"重试": "再試行",
"确认取消该投递任务?": "この配信タスクをキャンセルしますか?",
// ---------- 协议日志(protocol_logs.html ----------
"协议日志(SMTP / IMAP / POP3": "プロトコルログ(SMTP / IMAP / POP3",
"清理旧日志": "古いログを削除",
"确认清理 %d 天前的协议日志?": "%d 日前より前のプロトコルログを削除しますか?",
"今日 SMTP 失败": "今日の SMTP 失敗",
"今日 IMAP 失败": "今日の IMAP 失敗",
"今日 POP3 失败": "今日の POP3 失敗",
"历史失败(全部)": "過去の失敗(すべて)",
"全部协议": "すべてのプロトコル",
"全部状态": "すべての状態",
"成功": "成功",
"来源 IP(模糊)": "送信元 IP(あいまい)",
"用户名(模糊)": "ユーザー名(あいまい)",
"至": "〜",
"筛选": "フィルター",
"重置": "リセット",
"时间": "時刻",
"端口": "ポート",
"失败原因": "失敗理由",
"操作摘要": "操作概要",
"消息数": "メッセージ数",
"时长": "所要時間",
"暂无记录": "記録はありません",
"第 %d / %d 页(共 %d 条)": "%d / %d ページ(全 %d 件)",
// ---------- 当前连接(connections.html ----------
"当前连接(SMTP / IMAP / POP3": "現在の接続(SMTP / IMAP / POP3",
"每 5 秒自动刷新": "5 秒ごとに自動更新",
"当前连接总数": "現在の接続数",
"协议": "プロトコル",
"来源 IP": "送信元 IP",
"TLS": "TLS",
"明文": "平文",
"连接时间": "接続時刻",
"最后活跃": "最終アクティブ",
"断开并封禁": "切断してブロック",
"当前没有活动连接": "アクティブな接続はありません",
"确定要断开 IP %s 的所有连接并加入黑名单(180 天)吗?": "IP %s のすべての接続を切断してブラックリストに追加しますか(180 日)?",
// ---------- IP 黑名单(bans.html ----------
"IP 黑名单": "IP ブラックリスト",
"阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)": "段階的ブロック:4 回目の発動からブロック開始、30分 → 3時間 → 3か月 → 半年(上限)",
"封禁次数": "ブロック回数",
"到期时间": "解除時刻",
"封禁中": "ブロック中",
"已过期": "期限切れ",
"仅计数": "カウントのみ",
"(已过期)": "(期限切れ)",
"解封": "解除",
"确定要解封 IP %s 吗?解封后该 IP 的封禁档位将清零。": "IP %s のブロックを解除しますか?ブロック段階はリセットされます。",
"暂无封禁记录": "ブロック記録はありません",
// ---------- 登录/认证错误(auth.go ----------
"请输入邮箱和密码": "メールアドレスとパスワードを入力してください",
"用户名或密码错误,还剩 %d 次尝试机会": "ユーザー名またはパスワードが違います。あと %d 回試行できます",
"会话保存失败,请重试": "セッションの保存に失敗しました。もう一度お試しください",
"请输入LDAP用户名和密码": "LDAP ユーザー名とパスワードを入力してください",
"LDAP 认证失败,还剩 %d 次尝试机会": "LDAP 認証に失敗しました。あと %d 回試行できます",
"LDAP 账号未接入本系统,请联系管理员": "LDAP アカウントはこのシステムに接続されていません。管理者に連絡してください",
"用户已被禁用": "ユーザーは無効です",
"OAuth2 state 校验失败,请重新发起登录": "OAuth2 の state 検証に失敗しました。もう一度ログインしてください",
"OAuth2 授权码缺失": "OAuth2 認可コードがありません",
"OAuth2 认证失败,请重试或联系管理员": "OAuth2 認証に失敗しました。再試行するか管理者に連絡してください",
"OAuth2 账号未接入本系统,请联系管理员": "OAuth2 アカウントはこのシステムに接続されていません。管理者に連絡してください",
"OAuth2 未启用": "OAuth2 は有効ではありません",
"OAuth2 登录暂不可用,请稍后重试": "OAuth2 ログインは一時的に利用できません。後でもう一度お試しください",
"登录失败次数过多": "ログイン失敗が多すぎます",
"LDAP 登录失败次数过多": "LDAP ログイン失敗が多すぎます",
// ---------- 邮件操作错误(mail.go ----------
"请输入收件人": "宛先を入力してください",
"配额检查失败,请稍后重试": "容量チェックに失敗しました。後でもう一度お試しください",
"附件超出配额限制。已用 %s / 总配额 %s": "添付ファイルが容量を超えています。使用 %s / 合計 %s",
"外部投递未启用": "外部送信は有効ではありません",
"外部收件人过多:最多 %d 个": "外部宛先が多すぎます:最大 %d 件",
"外发邮件入队失败 (%s): %v": "外部メールのキュー登録に失敗しました (%s): %v",
"投递邮件失败: %v": "メールの配信に失敗しました: %v",
"保存邮件失败: %v": "メールの保存に失敗しました: %v",
"当前密码不正确": "現在のパスワードが正しくありません",
"新密码不能为空": "新しいパスワードは必須です",
"两次输入的密码不一致": "パスワードが一致しません",
"密码加密失败": "パスワードの暗号化に失敗しました",
"密码更新失败": "パスワードの更新に失敗しました",
"密码修改成功": "パスワードを変更しました",
"邮箱不存在": "メールボックスが存在しません",
"加载邮件列表失败: %v": "メール一覧の読み込みに失敗しました: %v",
"无效的邮件ID": "無効なメール ID",
"邮件不存在": "メールが存在しません",
"禁止访问": "アクセスが拒否されました",
"无效的附件ID": "無効な添付ファイル ID",
"附件不存在": "添付ファイルが存在しません",
"读取附件失败": "添付ファイルの読み込みに失敗しました",
"删除失败": "削除に失敗しました",
"恢复失败": "復元に失敗しました",
"清空文件夹失败: %v": "フォルダを空にできませんでした: %v",
// ---------- 管理操作错误(admin.go ----------
"无效的连接ID": "無効な接続 ID",
"连接不存在或已断开": "接続が存在しないか切断されています",
"管理员手动封禁(连接断开)": "管理者による手動ブロック(接続切断)",
"封禁失败: %v": "ブロックに失敗しました: %v",
"加载域名列表失败: %v": "ドメイン一覧の読み込みに失敗しました: %v",
"请输入域名": "ドメイン名を入力してください",
"创建域名失败: %v": "ドメインの作成に失敗しました: %v",
"无效的域名ID": "無効なドメイン ID",
"域名不存在": "ドメインが存在しません",
"✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。": "✅ Caddy から証明書を取得し、ドメインの TLS ディレクトリに保存しました。TLS が有効になり、ホットリロード済みです。再起動は不要です。",
"启用 TLS 时必须填写 TLS 私钥和公钥证书": "TLS を有効にするには秘密鍵と証明書が必要です",
"TLS 私钥和公钥证书必须同时填写": "秘密鍵と証明書は両方必要です",
"TLS 证书或私钥无效: %v": "TLS 証明書または秘密鍵が無効です: %v",
"创建 TLS 证书目录失败: %v": "TLS ディレクトリの作成に失敗しました: %v",
"保存 TLS 公钥证书失败: %v": "TLS 証明書の保存に失敗しました: %v",
"保存 TLS 私钥失败: %v": "TLS 秘密鍵の保存に失敗しました: %v",
"从 Caddy 获取证书失败: %v": "Caddy からの証明書取得に失敗しました: %v",
"更新域名记录失败: %v": "ドメイン情報の更新に失敗しました: %v",
"更新域名失败: %v": "ドメインの更新に失敗しました: %v",
"删除域名失败: %v": "ドメインの削除に失敗しました: %v",
"加载用户列表失败: %v": "ユーザー一覧の読み込みに失敗しました: %v",
"请填写所有必填字段": "必須項目をすべて入力してください",
"创建用户失败: %v": "ユーザーの作成に失敗しました: %v",
"无效的用户ID": "無効なユーザー ID",
"不能删除自己的账户": "自分のアカウントは削除できません",
"删除用户失败: %v": "ユーザーの削除に失敗しました: %v",
"用户不存在": "ユーザーが存在しません",
"更新用户失败: %v": "ユーザーの更新に失敗しました: %v",
"加载黑名单失败: %v": "ブラックリストの読み込みに失敗しました: %v",
"无效的记录ID": "無効なレコード ID",
"解封失败: %v": "ブロック解除に失敗しました: %v",
"加载协议日志失败: %v": "プロトコルログの読み込みに失敗しました: %v",
"加载外发队列失败: %v": "送信キューの読み込みに失敗しました: %v",
"外发服务不可用": "送信サービスを利用できません",
"重试失败: %v": "再試行に失敗しました: %v",
"取消失败: %v": "キャンセルに失敗しました: %v",
"无效的队列ID": "無効なキュー ID",
"禁止访问:需要管理员权限": "アクセスが拒否されました:管理者権限が必要です",
}
+139
View File
@@ -0,0 +1,139 @@
// Package i18n provides lightweight UI localization for the MailGo web
// frontend. Supported languages: zh (Chinese), en (English, fallback) and
// ja (Japanese). Users may also pick "auto" so the effective language is
// derived from their browser's Accept-Language header.
//
// Translation keys are the original Chinese UI strings themselves: the zh
// catalog is the identity map, and the en/ja catalogs map those keys to
// their translations. Missing translations fall back to English, then to
// the key itself.
package i18n
import (
"fmt"
"sort"
"strconv"
"strings"
)
// Language preference values stored on the user record.
const (
// LangAuto 跟随浏览器语言(Accept-Language)。
LangAuto = "auto"
// LangEn 英语:兜底语言。
LangEn = "en"
// LangZh 中文。
LangZh = "zh"
// LangJa 日文。
LangJa = "ja"
)
// Supported 判断 lang 是否为可显式选择的界面语言(不含 auto)。
func Supported(lang string) bool {
switch lang {
case LangEn, LangZh, LangJa:
return true
default:
return false
}
}
// Normalize 把浏览器语言标签规整为受支持的语言代码:
// zh-CN / zh-TW / zh-Hans → zhja-JP → jaen-US / en-GB → en
// 其余返回空串(无法识别)。
func Normalize(tag string) string {
tag = strings.ToLower(strings.TrimSpace(tag))
if i := strings.IndexByte(tag, '-'); i >= 0 {
tag = tag[:i]
}
if i := strings.IndexByte(tag, '_'); i >= 0 {
tag = tag[:i]
}
switch tag {
case LangZh, "zh-hans", "zh-hant":
return LangZh
case LangJa:
return LangJa
case LangEn:
return LangEn
default:
return ""
}
}
// FromAcceptLanguage 从 Accept-Language 请求头解析首选语言。
// 按 q 值(缺省 1.0)降序取第一个受支持的语言;都不支持时回退英语。
func FromAcceptLanguage(header string) string {
type entry struct {
lang string
q float64
}
var entries []entry
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
tag := part
q := 1.0
if i := strings.IndexByte(part, ';'); i >= 0 {
tag = strings.TrimSpace(part[:i])
for _, param := range strings.Split(part[i+1:], ";") {
param = strings.TrimSpace(param)
if strings.HasPrefix(param, "q=") {
if v, err := strconv.ParseFloat(param[2:], 64); err == nil {
q = v
}
}
}
}
if lang := Normalize(tag); lang != "" {
entries = append(entries, entry{lang: lang, q: q})
}
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].q > entries[j].q })
for _, e := range entries {
if e.q > 0 {
return e.lang
}
}
return LangEn
}
// Resolve 计算有效界面语言:用户偏好为显式语言时直接采用;
// auto(或空/未知值)时按浏览器 Accept-Language 解析;最终兜底英语。
func Resolve(pref, acceptLanguage string) string {
if Supported(pref) {
return pref
}
return FromAcceptLanguage(acceptLanguage)
}
// T 返回 key 在 lang 下的译文。zh 直接返回 key;
// ja 优先查日文表,缺译回退英文表;其余语言(含未知)查英文表,
// 仍缺失时原样返回 key(保证界面永不出现空文本)。
func T(lang, key string) string {
switch Normalize(lang) {
case LangZh:
return key
case LangJa:
if s, ok := ja[key]; ok {
return s
}
if s, ok := en[key]; ok {
return s
}
return key
default:
if s, ok := en[key]; ok {
return s
}
return key
}
}
// TF 是 T 的格式化版本:对译文执行 fmt.Sprintf。
// 用于带占位符的消息(如「还剩 %d 次尝试机会」)。
func TF(lang, key string, args ...interface{}) string {
return fmt.Sprintf(T(lang, key), args...)
}
+114
View File
@@ -0,0 +1,114 @@
package i18n
import "testing"
func TestNormalize(t *testing.T) {
cases := map[string]string{
"zh": "zh",
"zh-CN": "zh",
"zh-TW": "zh",
"zh-Hans": "zh",
"ja": "ja",
"ja-JP": "ja",
"en": "en",
"en-US": "en",
"en_GB": "en",
"fr": "",
"": "",
}
for in, want := range cases {
if got := Normalize(in); got != want {
t.Errorf("Normalize(%q) = %q, want %q", in, got, want)
}
}
}
func TestFromAcceptLanguage(t *testing.T) {
cases := []struct {
header string
want string
}{
{"zh-CN,zh;q=0.9,en;q=0.8", "zh"},
{"ja-JP,ja;q=0.9,en;q=0.8", "ja"},
{"en-US,en;q=0.9", "en"},
{"en;q=0.5,zh;q=0.9", "zh"}, // q 值优先
{"fr-FR,de-DE", "en"}, // 全部不支持 → 兜底英语
{"", "en"},
}
for _, tc := range cases {
if got := FromAcceptLanguage(tc.header); got != tc.want {
t.Errorf("FromAcceptLanguage(%q) = %q, want %q", tc.header, got, tc.want)
}
}
}
func TestResolve(t *testing.T) {
cases := []struct {
pref, header, want string
}{
{"auto", "ja-JP,ja;q=0.9,en;q=0.8", "ja"},
{"auto", "en-US,en;q=0.9", "en"},
{"auto", "fr-FR", "en"},
{"zh", "en-US,en;q=0.9", "zh"}, // 显式偏好优先于浏览器
{"ja", "en-US", "ja"},
{"en", "zh-CN,zh;q=0.9", "en"},
{"", "zh-CN", "zh"},
{"fr", "zh-CN", "zh"}, // 非法偏好按 auto 处理
}
for _, tc := range cases {
if got := Resolve(tc.pref, tc.header); got != tc.want {
t.Errorf("Resolve(%q, %q) = %q, want %q", tc.pref, tc.header, got, tc.want)
}
}
}
func TestT(t *testing.T) {
key := "收件箱"
// zh 恒等于 key 本身
if got := T("zh", key); got != key {
t.Errorf("T(zh) = %q, want %q", got, key)
}
// en
if got, want := T("en", key), "Inbox"; got != want {
t.Errorf("T(en) = %q, want %q", got, want)
}
// ja
if got, want := T("ja", key), "受信トレイ"; got != want {
t.Errorf("T(ja) = %q, want %q", got, want)
}
// 未知语言 → 英文兜底
if got := T("fr", key); got != "Inbox" {
t.Errorf("T(fr) = %q, want Inbox", got)
}
// 无此键 → 原样返回 key
if got := T("en", "不存在的键"); got != "不存在的键" {
t.Errorf("T(missing) = %q, want key itself", got)
}
}
func TestTF(t *testing.T) {
key := "用户名或密码错误,还剩 %d 次尝试机会"
if got, want := TF("zh", key, 3), "用户名或密码错误,还剩 3 次尝试机会"; got != want {
t.Errorf("TF(zh) = %q, want %q", got, want)
}
if got, want := TF("en", key, 2), "Incorrect username or password. 2 attempts remaining"; got != want {
t.Errorf("TF(en) = %q, want %q", got, want)
}
if got, want := TF("ja", key, 1), "ユーザー名またはパスワードが違います。あと 1 回試行できます"; got != want {
t.Errorf("TF(ja) = %q, want %q", got, want)
}
}
// TestCatalogCoverage 校验 en/ja 目录中不包含空译文。
func TestCatalogCoverage(t *testing.T) {
for k, v := range en {
if v == "" {
t.Errorf("en 目录空译文: %q", k)
}
}
for k, v := range ja {
if v == "" {
t.Errorf("ja 目录空译文: %q", k)
}
}
}