From 37b48165737bf164a1a107e65f81549ed025a922 Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 20 Aug 2026 02:00:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E6=97=B6=E9=97=B4=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=2012=20=E5=B0=8F=E6=97=B6=E5=88=B6=EF=BC=88=E4=B8=8A?= =?UTF-8?q?=E5=8D=88/=E4=B8=8B=E5=8D=88=EF=BC=89=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=90=8E=E5=8F=B0=E6=97=B6=E9=97=B4=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后台页面此前直接 Format 输出库内时间(服务器 UTC),比北京时间慢 8 小时 - 新增模板函数 time12/time12m:先按 Web 时区转换,输出「2026-08-20 下午 2:35:05」 - shortDate 改为 12 小时制且非今天的邮件也显示完整时间(今天「下午 2:35」, 今年「08-20 下午 2:35」,更早「2026-08-20 下午 2:35」) - 统一替换:admin/mails、admin/outbound 用 time12m;protocol-logs、 connections、bans、mail_view、banned、view 用 time12 - 时区修正:协议日志 from/to 筛选与「今日」统计边界按 Web 时区计算 (此前 time.Local 在服务器 UTC 时差 8 小时) - 邮件列表日期列自适应宽度适配更长格式;新增 timefmt_test.go 回归测试 --- internal/web/handlers/admin.go | 41 +++++++--- internal/web/handlers/oauth2_state_test.go | 2 + internal/web/server.go | 53 +++++++++++-- internal/web/templates/admin/bans.html | 2 +- internal/web/templates/admin/connections.html | 4 +- internal/web/templates/admin/mail_view.html | 2 +- internal/web/templates/admin/mails.html | 2 +- internal/web/templates/admin/outbound.html | 4 +- .../web/templates/admin/protocol_logs.html | 2 +- internal/web/templates/banned.html | 2 +- internal/web/templates/base.html | 6 +- internal/web/templates/view.html | 2 +- internal/web/timefmt_test.go | 74 +++++++++++++++++++ 13 files changed, 164 insertions(+), 32 deletions(-) create mode 100644 internal/web/timefmt_test.go diff --git a/internal/web/handlers/admin.go b/internal/web/handlers/admin.go index 0201b44..3d67bca 100644 --- a/internal/web/handlers/admin.go +++ b/internal/web/handlers/admin.go @@ -35,12 +35,30 @@ type AdminHandler struct { protocolLogKeepDays int // hub 当前协议连接注册中心(「当前连接」页) hub *connhub.Hub + // tz Web 展示时区(「今日」统计边界、日志日期筛选用),nil 回退本地时区 + tz *time.Location } // NewAdminHandler creates a new AdminHandler with the given stores, attachment // storage, TLS directory, Caddy data directory and outbound delivery manager. -func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager, protocolLogKeepDays int, hub *connhub.Hub) *AdminHandler { - return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays, hub: hub} +func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager, protocolLogKeepDays int, hub *connhub.Hub, tz *time.Location) *AdminHandler { + return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays, hub: hub, tz: tz} +} + +// displayTZ 返回 Web 展示时区(未配置时回退本地时区)。 +func (h *AdminHandler) displayTZ() *time.Location { + if h.tz != nil { + return h.tz + } + return time.Local +} + +// dayStartIn 返回展示时区的「今日零点」,并转换为服务器本地时区: +// 库中 CreatedAt 按写入时服务器本地时区序列化(RFC3339 文本), +// 边界需同偏移才能保证 SQLite 文本比较正确。 +func (h *AdminHandler) dayStartIn() time.Time { + now := time.Now().In(h.displayTZ()) + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, h.displayTZ()).In(time.Local) } // manualBanDuration 管理员手动封禁时长(180 天,与自动封禁档位上限制一致)。 @@ -119,8 +137,8 @@ func (h *AdminHandler) Dashboard(c *gin.Context) { inboxSize, _ := h.stores.Mails.TotalSizeByFolder("INBOX") sentSize, _ := h.stores.Mails.TotalSizeByFolder("Sent") - // Today and weekly statistics - todayStart := time.Now().Truncate(24 * time.Hour) + // Today and weekly statistics(「今日」按 Web 展示时区切日) + todayStart := h.dayStartIn() weekStart := time.Now().AddDate(0, 0, -7) todayReceived, _ := h.stores.Mails.CountByFolderSince("INBOX", todayStart) @@ -860,8 +878,8 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) { success = &v } - from := parseDateQuery(c.Query("from")) - to := parseDateQuery(c.Query("to")) + from := h.parseDateQuery(c.Query("from")) + to := h.parseDateQuery(c.Query("to")) // 日期选择到天,含当天 if !to.IsZero() { to = to.AddDate(0, 0, 1) @@ -883,7 +901,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) { } // 统计卡片:今日 + 全部成功/失败数(按协议),int64 → int 供模板 add 使用 - dayStart := time.Now().Truncate(24 * time.Hour) + dayStart := h.dayStartIn() todayStats, _ := h.stores.ProtocolLogs.CountStats(dayStart) allStats, _ := h.stores.ProtocolLogs.CountStats(time.Time{}) normStats := func(m map[string]map[string]int64) map[string]map[string]int { @@ -935,16 +953,17 @@ func (h *AdminHandler) CleanupProtocolLogs(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/protocol-logs") } -// parseDateQuery 解析 YYYY-MM-DD 日期,失败返回零值。 -func parseDateQuery(s string) time.Time { +// parseDateQuery 按 Web 展示时区解析 YYYY-MM-DD 日期,失败返回零值。 +// 解析结果转换为服务器本地时区,保证与库中时间序列化偏移一致。 +func (h *AdminHandler) parseDateQuery(s string) time.Time { if s == "" { return time.Time{} } - t, err := time.ParseInLocation("2006-01-02", s, time.Local) + t, err := time.ParseInLocation("2006-01-02", s, h.displayTZ()) if err != nil { return time.Time{} } - return t + return t.In(time.Local) } // ListMails renders the admin mail list page showing all messages across all users. diff --git a/internal/web/handlers/oauth2_state_test.go b/internal/web/handlers/oauth2_state_test.go index 6c56b89..4916f33 100644 --- a/internal/web/handlers/oauth2_state_test.go +++ b/internal/web/handlers/oauth2_state_test.go @@ -47,6 +47,8 @@ func testTemplateFuncs() template.FuncMap { "truncate": func(s string, n int) string { return s }, "shortDate": func(t time.Time) string { return t.Format("2006-01-02") }, "localTime": func(t time.Time) time.Time { return t }, + "time12": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") }, + "time12m": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, "avatarStyle": func(s string) string { return "background:#eee;color:#333" }, "urlPath": func(s string) string { return url.PathEscape(s) }, "folderLabel": func(s string) string { return s }, diff --git a/internal/web/server.go b/internal/web/server.go index 3d23c3f..adfc99c 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -108,8 +108,15 @@ func templateFuncs() template.FuncMap { "initial": initial, // truncate 折叠空白并截断到 n 个字符(用于列表摘要)。 "truncate": truncate, - // shortDate 按 QQ 邮箱习惯格式化:今天显示 HH:mm,今年显示 MM-DD,更早显示 YYYY-MM-DD。 + // shortDate 邮件列表时间:统一 12 小时制(上午/下午)。 + // 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」, + // 更早显示「2026-08-20 下午 2:35」。 "shortDate": shortDate, + // time12 完整时间(含秒),先转换为 Web 时区: + // 「2026-08-20 下午 2:35:05」。 + "time12": time12, + // time12m 同上但不含秒。 + "time12m": time12m, // localTime 把存储的 UTC 时间转换为 Web 配置时区(默认 Asia/Shanghai)。 "localTime": localTime, // avatarStyle 根据字符串哈希生成头像背景/前景色。 @@ -179,19 +186,49 @@ func truncate(s string, n int) string { return string(r[:n]) + "…" } -// shortDate formats a time like QQ Mail does: today -> HH:mm, -// this year -> MM-DD, otherwise -> YYYY-MM-DD. -// 时间先按 Web 配置时区转换(库内为 UTC),"今天"判断也使用该时区。 +// periodOf 返回 12 小时制的时间段与小时:上午/下午 + 1-12。 +func periodOf(t time.Time) (string, int) { + period := "上午" + if t.Hour() >= 12 { + period = "下午" + } + h := t.Hour() % 12 + if h == 0 { + h = 12 + } + return period, h +} + +// shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换): +// 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」; +// 更早 → 「2026-08-20 下午 2:35」。 func shortDate(t time.Time) string { t = inWebTZ(t) now := time.Now().In(t.Location()) + period, h := periodOf(t) + clock := fmt.Sprintf("%s %d:%02d", period, h, t.Minute()) if t.Year() == now.Year() && t.YearDay() == now.YearDay() { - return t.Format("15:04") + return clock } if t.Year() == now.Year() { - return t.Format("01-02") + return fmt.Sprintf("%s %s", t.Format("01-02"), clock) } - return t.Format("2006-01-02") + return fmt.Sprintf("%s %s", t.Format("2006-01-02"), clock) +} + +// time12 完整时间(12 小时制,先按 Web 配置时区转换): +// 「2026-08-20 下午 2:35:05」。 +func time12(t time.Time) string { + t = inWebTZ(t) + period, h := periodOf(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 { + t = inWebTZ(t) + period, h := periodOf(t) + return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute()) } // webTZ 是 Web 界面显示时间使用的时区(默认 Asia/Shanghai)。 @@ -323,7 +360,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag func (ws *WebServer) registerRoutes() { authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg) mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, imap_server.NewMailboxService(ws.stores), ws.pusher) - adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound, ws.cfg.ProtocolLogKeepDays, ws.hub) + adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound, ws.cfg.ProtocolLogKeepDays, ws.hub, webTZ) // Apply BanMiddleware globally before public routes ws.engine.Use(middleware.BanMiddleware(ws.stores)) diff --git a/internal/web/templates/admin/bans.html b/internal/web/templates/admin/bans.html index a1774ed..a339a49 100644 --- a/internal/web/templates/admin/bans.html +++ b/internal/web/templates/admin/bans.html @@ -55,7 +55,7 @@ {{.FailCount}} {{if .Reason}}{{.Reason}}{{else}}—{{end}} - {{.ExpiresAt.Format "2006-01-02 15:04:05"}}{{if not .Active}}(已过期){{end}} + {{time12 .ExpiresAt}}{{if not .Active}}(已过期){{end}}
diff --git a/internal/web/templates/admin/connections.html b/internal/web/templates/admin/connections.html index 14dca67..ec4a154 100644 --- a/internal/web/templates/admin/connections.html +++ b/internal/web/templates/admin/connections.html @@ -77,9 +77,9 @@ {{.Port}} {{if .User}}{{.User}}{{else}}—{{end}} {{if .TLS}}TLS{{else}}明文{{end}} - {{.Connected.Format "2006-01-02 15:04:05"}} + {{time12 .Connected}} {{durationSeconds ($.now.Sub .Connected)}}s - {{.LastActive.Format "2006-01-02 15:04:05"}} + {{time12 .LastActive}} diff --git a/internal/web/templates/admin/mail_view.html b/internal/web/templates/admin/mail_view.html index 0f136f3..be80b85 100644 --- a/internal/web/templates/admin/mail_view.html +++ b/internal/web/templates/admin/mail_view.html @@ -43,7 +43,7 @@ {{if .message.CcAddr}}

抄送: {{.message.CcAddr}}

{{end}}

所属用户: {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}

文件夹: {{.message.Folder}}

-

时间: {{.message.Date.Format "2006-01-02 15:04:05"}}

+

时间: {{time12 .message.Date}}

{{if .message.HtmlBody}} diff --git a/internal/web/templates/admin/mails.html b/internal/web/templates/admin/mails.html index 3161ff6..ba04932 100644 --- a/internal/web/templates/admin/mails.html +++ b/internal/web/templates/admin/mails.html @@ -54,7 +54,7 @@ {{.Subject}} {{if .User.ID}}{{.User.Username}}{{else}}—{{end}} {{.Folder}} - {{.Date.Format "2006-01-02 15:04"}} + {{time12m .Date}} {{end}} diff --git a/internal/web/templates/admin/outbound.html b/internal/web/templates/admin/outbound.html index 4eaf1ae..4697a7a 100644 --- a/internal/web/templates/admin/outbound.html +++ b/internal/web/templates/admin/outbound.html @@ -82,9 +82,9 @@ {{else}}{{call $.statusText .Status}}{{end}} {{.Attempts}} - {{if or (eq .Status "pending") (eq .Status "deferred")}}{{.NextAttemptAt.Format "2006-01-02 15:04"}}{{else}}—{{end}} + {{if or (eq .Status "pending") (eq .Status "deferred")}}{{time12m .NextAttemptAt}}{{else}}—{{end}} {{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}} - {{.CreatedAt.Format "2006-01-02 15:04"}} + {{time12m .CreatedAt}} {{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}} diff --git a/internal/web/templates/admin/protocol_logs.html b/internal/web/templates/admin/protocol_logs.html index b918f4a..5371738 100644 --- a/internal/web/templates/admin/protocol_logs.html +++ b/internal/web/templates/admin/protocol_logs.html @@ -92,7 +92,7 @@ {{range .logs}} - {{.CreatedAt.Format "2006-01-02 15:04:05"}} + {{time12 .CreatedAt}} {{if eq .Protocol "smtp"}}SMTP {{else if eq .Protocol "imap"}}IMAP diff --git a/internal/web/templates/banned.html b/internal/web/templates/banned.html index e700b2d..965d199 100644 --- a/internal/web/templates/banned.html +++ b/internal/web/templates/banned.html @@ -65,7 +65,7 @@
解封时间 - {{.entry.ExpiresAt.Format "2006-01-02 15:04:05"}} + {{time12 .entry.ExpiresAt}}
{{end}} diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index 2389cb9..dc386f8 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -165,7 +165,7 @@ font-size: 12.5px; color: var(--text-3); } .cell-date { - width: 78px; flex-shrink: 0; text-align: right; + flex: 0 0 auto; white-space: nowrap; text-align: right; font-size: 12.5px; color: var(--text-3); } .mail-row.unread .cell-date { color: var(--text-2); } @@ -388,11 +388,11 @@ .page-info { font-size: 12px; } .mail-row { padding: 0 10px; height: 60px; gap: 8px; } .avatar { width: 32px; height: 32px; font-size: 13px; } - .cell-from { width: 88px; font-size: 13px; } + .cell-from { width: 76px; font-size: 13px; } .cell-subject-wrap { flex: 1; min-width: 0; } .cell-subject { font-size: 13px; } .cell-snippet { display: none; } - .cell-date { width: 56px; font-size: 11.5px; } + .cell-date { width: auto; font-size: 10.5px; } .row-del { opacity: 1; } .list-footer { padding: 8px 10px; } .pager { margin-left: auto; gap: 4px; } diff --git a/internal/web/templates/view.html b/internal/web/templates/view.html index 621a82b..7dee49f 100644 --- a/internal/web/templates/view.html +++ b/internal/web/templates/view.html @@ -54,7 +54,7 @@
{{mailName (decodeHeader .message.FromAddr)}}
{{mailEmail .message.FromAddr}}
- {{(localTime .message.Date).Format "2006-01-02 15:04:05"}} + {{time12 .message.Date}} {{if .message.CcAddr}}
diff --git a/internal/web/timefmt_test.go b/internal/web/timefmt_test.go new file mode 100644 index 0000000..21600c6 --- /dev/null +++ b/internal/web/timefmt_test.go @@ -0,0 +1,74 @@ +package web + +// 12 小时制时间格式化(上午/下午)回归测试: +// time12 / time12m / shortDate 需先按 Web 时区转换,再输出中文习惯的 +// 12 小时制(「2026-08-20 下午 2:35:05」),后台页面此前直接 Format +// 输出库内 UTC 时间(比北京时间慢 8 小时)。 + +import ( + "fmt" + "testing" + "time" +) + +// setWebTZForTest 临时把展示时区固定为 UTC+8,测试结束恢复。 +func setWebTZForTest(t *testing.T, loc *time.Location) { + t.Helper() + old := webTZ + webTZ = loc + t.Cleanup(func() { webTZ = old }) +} + +func TestTime12Format(t *testing.T) { + setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600)) + + cases := []struct { + name string + in time.Time + want string + }{ + {"上午", time.Date(2026, 8, 20, 1, 5, 7, 0, time.UTC), "2026-08-20 上午 9:05:07"}, + {"正午", time.Date(2026, 8, 20, 4, 0, 0, 0, time.UTC), "2026-08-20 下午 12:00:00"}, + {"下午", time.Date(2026, 8, 20, 10, 30, 45, 0, time.UTC), "2026-08-20 下午 6:30:45"}, + {"凌晨", time.Date(2026, 8, 20, 16, 0, 0, 0, time.UTC), "2026-08-21 上午 12:00:00"}, + } + for _, tc := range cases { + if got := time12(tc.in); got != tc.want { + t.Errorf("time12(%s) = %q, want %q", tc.name, got, tc.want) + } + } +} + +func TestTime12mNoSeconds(t *testing.T) { + setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600)) + in := time.Date(2026, 8, 20, 10, 30, 45, 0, time.UTC) + if got, want := time12m(in), "2026-08-20 下午 6:30"; got != want { + t.Fatalf("time12m = %q, want %q", got, want) + } +} + +func TestShortDate12Hour(t *testing.T) { + setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600)) + now := time.Now().In(webTZ) + + // 今天 → 「下午 2:35」(无日期) + today := time.Date(now.Year(), now.Month(), now.Day(), 14, 35, 0, 0, webTZ).UTC() + if got, want := shortDate(today), "下午 2:35"; got != want { + t.Fatalf("shortDate(today) = %q, want %q", got, want) + } + + // 今年非今天 → 「06-15 上午 9:05」(避开今天,防止午夜跨日抖动) + day := time.Date(now.Year(), 6, 15, 9, 5, 0, 0, webTZ) + if day.YearDay() == now.YearDay() { + day = day.AddDate(0, 0, 1) + } + if got, want := shortDate(day.UTC()), day.Format("01-02")+" 上午 9:05"; got != want { + t.Fatalf("shortDate(thisYear) = %q, want %q", got, want) + } + + // 往年 → 「YYYY-MM-DD 上午 9:05」 + older := time.Date(now.Year()-1, 12, 1, 9, 5, 0, 0, webTZ).UTC() + if got, want := shortDate(older), fmt.Sprintf("%d-12-01 上午 9:05", now.Year()-1); got != want { + t.Fatalf("shortDate(older) = %q, want %q", got, want) + } +}