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:
xuyueandClaude Opus 5 committed 2026-09-14 05:55:17 -06:00
1 parent 6e63866cc9
commit 4c0c38445c
23 files changed
+491 -106

No files matched your search

+9 -7
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { formatISO } from "date-fns"
import TextEditor from "shared/components/TextEditor.vue"
import { parseTime } from "utils/functions"
import { fromPickerValue, parseTime, toPickerValue } from "utils/functions"
import type { BlankContest } from "utils/types"
import { createContest, editContest, getContest } from "../api"
@@ -27,13 +27,15 @@ watch([waitMins, durationMins], () => {
contest.endTime = formatISO(times[1])
})
// 编辑的时候
// 编辑的时候。这两个 ref 绑给 n-date-picker,值要平移过(见 utils/functions.ts
// 的 toPickerValue)—— 选择器按浏览器本地渲染,不换算的话非东八区的老师看到的是
// 自己时区的钟点,存进去就成了另一个时刻。
const startTime = ref(0)
const endTime = ref(0)
watch([startTime, endTime], (values) => {
contest.startTime = formatISO(values[0])
contest.endTime = formatISO(values[1])
contest.startTime = formatISO(fromPickerValue(values[0]))
contest.endTime = formatISO(fromPickerValue(values[1]))
})
const route = useRoute()
@@ -79,9 +81,9 @@ async function getContestDetail() {
contest.password = data.password
contest.visible = data.visible
// 显示
startTime.value = Date.parse(data.startTime)
endTime.value = Date.parse(data.endTime)
// 显示:交给选择器之前先平移成「北京墙上时间」
startTime.value = toPickerValue(Date.parse(data.startTime))
endTime.value = toPickerValue(Date.parse(data.endTime))
}
async function submit() {
+6 -2
View File
@@ -12,6 +12,7 @@ import {
Tooltip,
} from "chart.js"
import { getTopACTrend } from "admin/api"
import { zonedYear } from "utils/functions"
ChartJS.register(
CategoryScale,
@@ -25,7 +26,10 @@ ChartJS.register(
type ProblemTrend = AcTrend
const currentYear = new Date().getFullYear()
// 年份按东八区取,和后端 ac-trend 的夹逼口径(`localYear()`)对齐。
// 用 `new Date().getFullYear()` 的话,跨年那几个小时里浏览器年份可能比后端认定的
// 年份晚一年,默认的 untilYear 会被后端夹掉、图表悄悄变成另一个区间。
const currentYear = zonedYear()
const yearOptions = Array.from({ length: currentYear - 2022 + 1 }, (_, i) => ({
label: String(2022 + i),
value: 2022 + i,
@@ -37,7 +41,7 @@ const minPerYearOptions = [
]
const sinceYear = ref(2023)
const untilYear = ref(new Date().getFullYear() - 1)
const untilYear = ref(zonedYear() - 1)
const minPerYear = ref(100)
const loading = ref(false)
const data = ref<ProblemTrend[]>([])
+7 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
import { fromPickerValue, toPickerValue } from "utils/functions"
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
const route = useRoute()
@@ -18,11 +19,15 @@ const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
endTime: null,
})
// n-date-picker 按浏览器本地渲染,所以要平移一次再绑定(见 utils/functions.ts
// 的 toPickerValue)。`formData.endTime` 里始终存**真实时刻**,只有喂给选择器那一步换。
const endTimeTimestamp = computed({
get: () =>
formData.value.endTime ? new Date(formData.value.endTime).getTime() : null,
formData.value.endTime
? toPickerValue(formData.value.endTime.getTime())
: null,
set: (val: number | null) => {
formData.value.endTime = val ? new Date(val) : null
formData.value.endTime = val ? new Date(fromPickerValue(val)) : null
},
})