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 @@