fix(web): 时间统一 12 小时制(上午/下午),修复后台时间错误

- 后台页面此前直接 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 回归测试
This commit is contained in:
2026-08-20 02:00:10 +08:00
parent 87a85ef7ca
commit 37b4816573
13 changed files with 164 additions and 32 deletions
+30 -11
View File
@@ -35,12 +35,30 @@ type AdminHandler struct {
protocolLogKeepDays int protocolLogKeepDays int
// hub 当前协议连接注册中心(「当前连接」页) // hub 当前协议连接注册中心(「当前连接」页)
hub *connhub.Hub hub *connhub.Hub
// tz Web 展示时区(「今日」统计边界、日志日期筛选用),nil 回退本地时区
tz *time.Location
} }
// NewAdminHandler creates a new AdminHandler with the given stores, attachment // NewAdminHandler creates a new AdminHandler with the given stores, attachment
// storage, TLS directory, Caddy data directory and outbound delivery manager. // 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 { 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} 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 天,与自动封禁档位上限制一致)。 // manualBanDuration 管理员手动封禁时长(180 天,与自动封禁档位上限制一致)。
@@ -119,8 +137,8 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
inboxSize, _ := h.stores.Mails.TotalSizeByFolder("INBOX") inboxSize, _ := h.stores.Mails.TotalSizeByFolder("INBOX")
sentSize, _ := h.stores.Mails.TotalSizeByFolder("Sent") sentSize, _ := h.stores.Mails.TotalSizeByFolder("Sent")
// Today and weekly statistics // Today and weekly statistics(「今日」按 Web 展示时区切日)
todayStart := time.Now().Truncate(24 * time.Hour) todayStart := h.dayStartIn()
weekStart := time.Now().AddDate(0, 0, -7) weekStart := time.Now().AddDate(0, 0, -7)
todayReceived, _ := h.stores.Mails.CountByFolderSince("INBOX", todayStart) todayReceived, _ := h.stores.Mails.CountByFolderSince("INBOX", todayStart)
@@ -860,8 +878,8 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
success = &v success = &v
} }
from := parseDateQuery(c.Query("from")) from := h.parseDateQuery(c.Query("from"))
to := parseDateQuery(c.Query("to")) to := h.parseDateQuery(c.Query("to"))
// 日期选择到天,含当天 // 日期选择到天,含当天
if !to.IsZero() { if !to.IsZero() {
to = to.AddDate(0, 0, 1) to = to.AddDate(0, 0, 1)
@@ -883,7 +901,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
} }
// 统计卡片:今日 + 全部成功/失败数(按协议),int64 → int 供模板 add 使用 // 统计卡片:今日 + 全部成功/失败数(按协议),int64 → int 供模板 add 使用
dayStart := time.Now().Truncate(24 * time.Hour) dayStart := h.dayStartIn()
todayStats, _ := h.stores.ProtocolLogs.CountStats(dayStart) todayStats, _ := h.stores.ProtocolLogs.CountStats(dayStart)
allStats, _ := h.stores.ProtocolLogs.CountStats(time.Time{}) allStats, _ := h.stores.ProtocolLogs.CountStats(time.Time{})
normStats := func(m map[string]map[string]int64) map[string]map[string]int { 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") c.Redirect(http.StatusFound, "/admin/protocol-logs")
} }
// parseDateQuery 解析 YYYY-MM-DD 日期,失败返回零值。 // parseDateQuery 按 Web 展示时区解析 YYYY-MM-DD 日期,失败返回零值。
func parseDateQuery(s string) time.Time { // 解析结果转换为服务器本地时区,保证与库中时间序列化偏移一致。
func (h *AdminHandler) parseDateQuery(s string) time.Time {
if s == "" { if s == "" {
return time.Time{} 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 { if err != nil {
return time.Time{} return time.Time{}
} }
return t return t.In(time.Local)
} }
// ListMails renders the admin mail list page showing all messages across all users. // ListMails renders the admin mail list page showing all messages across all users.
@@ -47,6 +47,8 @@ func testTemplateFuncs() template.FuncMap {
"truncate": func(s string, n int) string { return s }, "truncate": func(s string, n int) string { return s },
"shortDate": func(t time.Time) string { return t.Format("2006-01-02") }, "shortDate": func(t time.Time) string { return t.Format("2006-01-02") },
"localTime": func(t time.Time) time.Time { return t }, "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" }, "avatarStyle": func(s string) string { return "background:#eee;color:#333" },
"urlPath": func(s string) string { return url.PathEscape(s) }, "urlPath": func(s string) string { return url.PathEscape(s) },
"folderLabel": func(s string) string { return s }, "folderLabel": func(s string) string { return s },
+45 -8
View File
@@ -108,8 +108,15 @@ func templateFuncs() template.FuncMap {
"initial": initial, "initial": initial,
// truncate 折叠空白并截断到 n 个字符(用于列表摘要)。 // truncate 折叠空白并截断到 n 个字符(用于列表摘要)。
"truncate": truncate, "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, "shortDate": shortDate,
// time12 完整时间(含秒),先转换为 Web 时区:
// 「2026-08-20 下午 2:35:05」。
"time12": time12,
// time12m 同上但不含秒。
"time12m": time12m,
// localTime 把存储的 UTC 时间转换为 Web 配置时区(默认 Asia/Shanghai)。 // localTime 把存储的 UTC 时间转换为 Web 配置时区(默认 Asia/Shanghai)。
"localTime": localTime, "localTime": localTime,
// avatarStyle 根据字符串哈希生成头像背景/前景色。 // avatarStyle 根据字符串哈希生成头像背景/前景色。
@@ -179,19 +186,49 @@ func truncate(s string, n int) string {
return string(r[:n]) + "…" return string(r[:n]) + "…"
} }
// shortDate formats a time like QQ Mail does: today -> HH:mm, // periodOf 返回 12 小时制的时间段与小时:上午/下午 + 1-12。
// this year -> MM-DD, otherwise -> YYYY-MM-DD. func periodOf(t time.Time) (string, int) {
// 时间先按 Web 配置时区转换(库内为 UTC),"今天"判断也使用该时区。 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 { func shortDate(t time.Time) string {
t = inWebTZ(t) t = inWebTZ(t)
now := time.Now().In(t.Location()) 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() { if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return t.Format("15:04") return clock
} }
if t.Year() == now.Year() { 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)。 // webTZ 是 Web 界面显示时间使用的时区(默认 Asia/Shanghai)。
@@ -323,7 +360,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
func (ws *WebServer) registerRoutes() { func (ws *WebServer) registerRoutes() {
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg) 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) 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 // Apply BanMiddleware globally before public routes
ws.engine.Use(middleware.BanMiddleware(ws.stores)) ws.engine.Use(middleware.BanMiddleware(ws.stores))
+1 -1
View File
@@ -55,7 +55,7 @@
</td> </td>
<td>{{.FailCount}}</td> <td>{{.FailCount}}</td>
<td>{{if .Reason}}{{.Reason}}{{else}}—{{end}}</td> <td>{{if .Reason}}{{.Reason}}{{else}}—{{end}}</td>
<td>{{.ExpiresAt.Format "2006-01-02 15:04:05"}}{{if not .Active}}(已过期){{end}}</td> <td>{{time12 .ExpiresAt}}{{if not .Active}}(已过期){{end}}</td>
<td> <td>
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;" <form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?解封后该 IP 的封禁档位将清零。');"> onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?解封后该 IP 的封禁档位将清零。');">
@@ -77,9 +77,9 @@
<td>{{.Port}}</td> <td>{{.Port}}</td>
<td>{{if .User}}{{.User}}{{else}}—{{end}}</td> <td>{{if .User}}{{.User}}{{else}}—{{end}}</td>
<td>{{if .TLS}}<span class="badge" style="background:#27ae60;color:#fff;">TLS</span>{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">明文</span>{{end}}</td> <td>{{if .TLS}}<span class="badge" style="background:#27ae60;color:#fff;">TLS</span>{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">明文</span>{{end}}</td>
<td>{{.Connected.Format "2006-01-02 15:04:05"}}</td> <td>{{time12 .Connected}}</td>
<td>{{durationSeconds ($.now.Sub .Connected)}}s</td> <td>{{durationSeconds ($.now.Sub .Connected)}}s</td>
<td>{{.LastActive.Format "2006-01-02 15:04:05"}}</td> <td>{{time12 .LastActive}}</td>
<td> <td>
<form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;" <form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;"
onsubmit="return confirm('确定要断开 IP {{.IP}} 的所有连接并加入黑名单(180 天)吗?');"> onsubmit="return confirm('确定要断开 IP {{.IP}} 的所有连接并加入黑名单(180 天)吗?');">
+1 -1
View File
@@ -43,7 +43,7 @@
{{if .message.CcAddr}}<p><strong>抄送:</strong> {{.message.CcAddr}}</p>{{end}} {{if .message.CcAddr}}<p><strong>抄送:</strong> {{.message.CcAddr}}</p>{{end}}
<p><strong>所属用户:</strong> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p> <p><strong>所属用户:</strong> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p>
<p><strong>文件夹:</strong> {{.message.Folder}}</p> <p><strong>文件夹:</strong> {{.message.Folder}}</p>
<p><strong>时间:</strong> {{.message.Date.Format "2006-01-02 15:04:05"}}</p> <p><strong>时间:</strong> {{time12 .message.Date}}</p>
</div> </div>
<div class="mail-body"> <div class="mail-body">
{{if .message.HtmlBody}} {{if .message.HtmlBody}}
+1 -1
View File
@@ -54,7 +54,7 @@
<td>{{.Subject}}</td> <td>{{.Subject}}</td>
<td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td> <td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td>
<td>{{.Folder}}</td> <td>{{.Folder}}</td>
<td>{{.Date.Format "2006-01-02 15:04"}}</td> <td>{{time12m .Date}}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
+2 -2
View File
@@ -82,9 +82,9 @@
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{call $.statusText .Status}}</span>{{end}} {{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{call $.statusText .Status}}</span>{{end}}
</td> </td>
<td>{{.Attempts}}</td> <td>{{.Attempts}}</td>
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{.NextAttemptAt.Format "2006-01-02 15:04"}}{{else}}—{{end}}</td> <td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{time12m .NextAttemptAt}}{{else}}—{{end}}</td>
<td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td> <td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td>
<td>{{.CreatedAt.Format "2006-01-02 15:04"}}</td> <td>{{time12m .CreatedAt}}</td>
<td> <td>
{{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}} {{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}}
<form method="POST" action="/admin/outbound/{{.ID}}/retry" style="display:inline;"> <form method="POST" action="/admin/outbound/{{.ID}}/retry" style="display:inline;">
@@ -92,7 +92,7 @@
<tbody> <tbody>
{{range .logs}} {{range .logs}}
<tr> <tr>
<td style="white-space:nowrap;">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</td> <td style="white-space:nowrap;">{{time12 .CreatedAt}}</td>
<td> <td>
{{if eq .Protocol "smtp"}}<span class="badge" style="background:#3498db;color:#fff;">SMTP</span> {{if eq .Protocol "smtp"}}<span class="badge" style="background:#3498db;color:#fff;">SMTP</span>
{{else if eq .Protocol "imap"}}<span class="badge" style="background:#9b59b6;color:#fff;">IMAP</span> {{else if eq .Protocol "imap"}}<span class="badge" style="background:#9b59b6;color:#fff;">IMAP</span>
+1 -1
View File
@@ -65,7 +65,7 @@
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">解封时间</span> <span class="detail-label">解封时间</span>
<span class="detail-value">{{.entry.ExpiresAt.Format "2006-01-02 15:04:05"}}</span> <span class="detail-value">{{time12 .entry.ExpiresAt}}</span>
</div> </div>
</div> </div>
{{end}} {{end}}
+3 -3
View File
@@ -165,7 +165,7 @@
font-size: 12.5px; color: var(--text-3); font-size: 12.5px; color: var(--text-3);
} }
.cell-date { .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); font-size: 12.5px; color: var(--text-3);
} }
.mail-row.unread .cell-date { color: var(--text-2); } .mail-row.unread .cell-date { color: var(--text-2); }
@@ -388,11 +388,11 @@
.page-info { font-size: 12px; } .page-info { font-size: 12px; }
.mail-row { padding: 0 10px; height: 60px; gap: 8px; } .mail-row { padding: 0 10px; height: 60px; gap: 8px; }
.avatar { width: 32px; height: 32px; font-size: 13px; } .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-wrap { flex: 1; min-width: 0; }
.cell-subject { font-size: 13px; } .cell-subject { font-size: 13px; }
.cell-snippet { display: none; } .cell-snippet { display: none; }
.cell-date { width: 56px; font-size: 11.5px; } .cell-date { width: auto; font-size: 10.5px; }
.row-del { opacity: 1; } .row-del { opacity: 1; }
.list-footer { padding: 8px 10px; } .list-footer { padding: 8px 10px; }
.pager { margin-left: auto; gap: 4px; } .pager { margin-left: auto; gap: 4px; }
+1 -1
View File
@@ -54,7 +54,7 @@
<div class="mail-from-name">{{mailName (decodeHeader .message.FromAddr)}}</div> <div class="mail-from-name">{{mailName (decodeHeader .message.FromAddr)}}</div>
<div class="mail-from-addr" title="{{decodeHeader .message.FromAddr}}">{{mailEmail .message.FromAddr}}</div> <div class="mail-from-addr" title="{{decodeHeader .message.FromAddr}}">{{mailEmail .message.FromAddr}}</div>
</div> </div>
<span class="mail-date">{{(localTime .message.Date).Format "2006-01-02 15:04:05"}}</span> <span class="mail-date">{{time12 .message.Date}}</span>
</div> </div>
{{if .message.CcAddr}} {{if .message.CcAddr}}
<div class="mail-from-addr" style="margin:-8px 0 16px 48px;"> <div class="mail-from-addr" style="margin:-8px 0 16px 48px;">
+74
View File
@@ -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)
}
}