fix(时区): 日历口径收回东八区,读出的时刻统一成 ISO 并保留微秒

旧栈 Django 按 Asia/Shanghai 算日历,OJ2 重写时这个锚点丢了:容器和数据库会话
都是 UTC,于是「今日提交」在北京时间 0–8 点是空的,「凌晨/早起提交次数」整体偏
8 小时,热力图、AC 趋势年份、近两年活跃人数也各按进程时区切。

- 新增 apps/api/src/time.ts 作为唯一锚点(固定 +8 偏移,不依赖进程 TZ / tzdata),
  todayStart、成就小时/日期键、热力图、月份平移、年份夹逼全部改走它;
  SQL 里按日历切的一律显式 at time zone。
- db/index.ts:连接会话时区设为东八区(兜底);给 timestamptz(1184) 挂 parser,
  读出统一成 ISO 8601 UTC,撤掉为拿 PG 文本形状写的 ::text。parser 保留微秒 ——
  生产库 12.3 万条提交几乎全带微秒,截成毫秒会让翻页分界行和班级 AC 排名的
  <= min(create_time) 把自己排除(翻页每页丢一条、排名少 1)。
- 前端 parseTime/zonedParts/zonedYear 按 Asia/Shanghai 渲染,n-date-picker 做
  toPickerValue/fromPickerValue 平移,站内不再按浏览器时区取时间部件。
- Dockerfile 设 TZ=Asia/Shanghai 作为第二道兜底。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
2026-09-14 05:55:17 -06:00
parent 6e63866cc9
commit 4c0c38445c
23 changed files with 491 additions and 106 deletions

View File

@@ -2,6 +2,7 @@
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useRarityColor } from "shared/composables/rarity"
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
import { parseTime } from "utils/functions"
import type { Achievement } from "utils/types"
const props = defineProps<{ achievement: Achievement }>()
@@ -48,7 +49,9 @@ const unlockDate = computed(() => {
const { unlockTime, backfilled } = props.achievement
// 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳
if (backfilled || !unlockTime) return "已获得"
return `${new Date(unlockTime).toLocaleDateString()} 获得`
// 走 parseTime 而不是 toLocaleDateString():后者按浏览器时区渲染,
// 站内所有日期都是东八区口径
return `${parseTime(unlockTime)} 获得`
})
</script>

View File

@@ -55,7 +55,7 @@
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
import { parseTime, zonedParts } from "utils/functions"
import { useChartTheme } from "shared/composables/chartTheme"
const aiStore = useAIStore()
@@ -94,18 +94,21 @@ const getColor = (count: number) => {
}
// 一格一周横向铺开。原来是一格一天、7 行 53 列,中职学生一年也就二三十天有提交,
// 365 格里三百多格空着,整张图看着像没用过
// 365 格里三百多格空着,整张图看着像没用过
//
// 服务端给的 timestamp 是**东八区某个周一**的 UTC 零点,所以年月日一律按东八区取
// `zonedParts`),不能用 `getMonth()` / `getDate()` —— 那是浏览器本地部件,
// 从别的时区打开会整体错一格。周末直接加 6 天的毫秒数:大陆没有夏令时,
// 那正好是东八区的 6 天。
const cells = computed(() =>
aiStore.heatmapData.map((item, i) => {
const start = new Date(item.timestamp)
const endOfWeek = new Date(
start.getFullYear(),
start.getMonth(),
start.getDate() + 6,
)
const parts = zonedParts(start)
return {
start,
end: endOfWeek,
end: new Date(start.getTime() + 6 * 86_400_000),
month: parts?.month ?? 1,
day: parts?.day ?? 1,
count: item.value,
color: getColor(item.value),
x: i * CELL_TOTAL,
@@ -117,11 +120,11 @@ const monthLabels = computed(() => {
const labels: { text: string; x: number }[] = []
let lastMonth = -1
cells.value.forEach((cell, i) => {
const month = cell.start.getMonth()
const month = cell.month
if (month !== lastMonth) {
// 第一格所在的月往往只露出小半个月,标签会和下一个月挤在一起,跳过
if (i > 0 || cell.start.getDate() <= 7) {
labels.push({ text: `${month + 1}`, x: cell.x })
if (i > 0 || cell.day <= 7) {
labels.push({ text: `${month}`, x: cell.x })
}
lastMonth = month
}
@@ -129,9 +132,7 @@ const monthLabels = computed(() => {
return labels
})
const svgWidth = computed(
() => cells.value.length * CELL_TOTAL + RIGHT_PADDING,
)
const svgWidth = computed(() => cells.value.length * CELL_TOTAL + RIGHT_PADDING)
const svgHeight = computed(() => MONTH_HEIGHT + CELL_HEIGHT)
interface Cell {