refactor(时区): 常量收进契约、SQL 统一走 localTime,去掉会话时区与 TZ 兜底
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
- TIME_ZONE / TIME_ZONE_OFFSET_MINUTES 移到 packages/contract/src/time.ts,
前后端共用一份,不再各写一遍靠注释对齐。
- apps/api/src/time.ts 新增 localTime(列),替换散落 7 处的
`at time zone ${TIME_ZONE_SQL}`;ac-trend 的 where 复用同一个 year 表达式。
- /problems/:displayId/yearly-ac 漏写了时区、按 UTC 切年,被会话时区兜底掩盖;
改为按东八区切(只影响每年 12-31 北京 0–8 点的提交归年)。
- 删掉数据库连接的 TimeZone 和 Dockerfile 的 TZ:正确代码不依赖它们,
它们只会在线上掩盖漏写处、让 dev 与线上答案不同。
- time.ts:calendarDayYearsAgo/pad 并入 shiftMonthsByCalendar,startOfCalendarDay
并入 todayStart,localWeekday 改用 getUTCDay,删掉历史叙述注释。
- 前端 zonedParts 改为固定偏移 + getUTC*(与后端、日期选择器同一写法),
去掉 Intl formatToParts;10 万次 299ms → 11ms。zonedYear 去掉按浏览器时区的兜底。
- 两份 CLAUDE.md 同步;n-date-picker 那条过时说明改成现用法。
验证:新旧「近两年起点」21359 个时刻 0 差异、localWeekday 0 差异、
前端固定偏移与 Intl 在 America/New_York 下 47821 个时刻 0 差异;
localTime 在 select/group by/where 复用可用;fix-achievement-hours 预演仍为 148 / 60。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1d8B3f4SXJwDvUY625eQd
This commit is contained in:
@@ -136,15 +136,13 @@ return contract("GET /problems/:id", problemDetailSchema, value)
|
||||
(比如时区没设对的机房机器、或在外地的老师)从别的时区打开,同一张提交记录表就会
|
||||
显示成另一个时间,和榜单、统计、成就里的日期对不上。
|
||||
|
||||
锚点在后端 `../api/src/time.ts`(`Asia/Shanghai`),前端的 `DISPLAY_TIME_ZONE`
|
||||
必须和它一致。实现用 `Intl` 的 IANA 时区而不是自己加 8 小时,`timeZone` 选项
|
||||
Chrome 24+ 就支持,不影响机房老 Chrome。
|
||||
时区常量在契约 `@oj2/contract` 的 `TIME_ZONE_OFFSET_MINUTES`,和后端 `time.ts` 共用。
|
||||
实现是「平移固定偏移 + 读 `getUTC*`」,不用 `Intl` 的时区选项:东八区没有夏令时,
|
||||
纯算术在表格里逐格调用也不费事,老 Chrome 上结果也一致。
|
||||
|
||||
**唯一还没跟上的是 `n-date-picker`**(`admin/contest/detail.vue`、
|
||||
`admin/problemset/edit.vue`):Naive 的日期选择器按浏览器本地时区渲染,没有
|
||||
`timezone` 属性。它在绝对值上往返正确(选的是什么时刻就是什么时刻),只是在非东八区
|
||||
的机器上「输入框里显示的时间」和「列表里显示的时间」会差一个时区。要修得在
|
||||
value ↔ 显示值之间做偏移换算,属于独立改动。
|
||||
**`n-date-picker` 要平移**(`admin/contest/detail.vue`、`admin/problemset/edit.vue`):
|
||||
Naive 的日期选择器按浏览器本地时区渲染、没有 `timezone` 属性,所以绑定值走
|
||||
`toPickerValue()`,取回来走 `fromPickerValue()`。显示时间不要用这对函数。
|
||||
|
||||
### Key Utilities
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ const minPerYearOptions = [
|
||||
]
|
||||
|
||||
const sinceYear = ref(2023)
|
||||
const untilYear = ref(zonedYear() - 1)
|
||||
const untilYear = ref(currentYear - 1)
|
||||
const minPerYear = ref(100)
|
||||
const loading = ref(false)
|
||||
const data = ref<ProblemTrend[]>([])
|
||||
|
||||
@@ -96,10 +96,8 @@ const getColor = (count: number) => {
|
||||
// 一格一周,横向铺开。原来是一格一天、7 行 53 列,中职学生一年也就二三十天有提交,
|
||||
// 365 格里三百多格空着,整张图看着像没用过。
|
||||
//
|
||||
// 服务端给的 timestamp 是**东八区某个周一**的 UTC 零点,所以年月日一律按东八区取
|
||||
// (`zonedParts`),不能用 `getMonth()` / `getDate()` —— 那是浏览器本地部件,
|
||||
// 从别的时区打开会整体错一格。周末直接加 6 天的毫秒数:大陆没有夏令时,
|
||||
// 那正好是东八区的 6 天。
|
||||
// 服务端给的 timestamp 是**东八区某个周一**的 UTC 零点,年月日按东八区取(`zonedParts`),
|
||||
// 不用浏览器本地的 `getMonth()` / `getDate()`。东八区没有夏令时,周日就是加 6 天。
|
||||
const cells = computed(() =>
|
||||
aiStore.heatmapData.map((item, i) => {
|
||||
const start = new Date(item.timestamp)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { toAdminType } from "@oj2/contract"
|
||||
import { TIME_ZONE_OFFSET_MINUTES, toAdminType } from "@oj2/contract"
|
||||
import type { JudgeCaseResult, JudgeInfo } from "@oj2/contract"
|
||||
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
|
||||
import { Submission, User } from "./types"
|
||||
@@ -115,60 +115,35 @@ export function durationFromValue(
|
||||
return { [unit]: count } as Duration
|
||||
}
|
||||
|
||||
/**
|
||||
* 站内所有时间一律按**东八区**展示,不跟浏览器时区走。
|
||||
*
|
||||
* 后端存的是 UTC 绝对时刻,显示口径的锚点在 `apps/api/src/time.ts`
|
||||
* (`Asia/Shanghai`)。前端原来走 `useDateFormat`,那是按**浏览器本地时区**渲染的
|
||||
* —— 机房电脑和学生手机都在东八区,所以平时看不出来;但只要有人从别的时区打开,
|
||||
* 同一张提交记录表就会显示成另一个时间,和榜单、统计、成就里的日期对不上。
|
||||
*
|
||||
* 大陆 1991 年起没有夏令时,但这里仍然走 `Intl` 的 IANA 时区而不是自己加 8 小时:
|
||||
* 万一时区规则变了,`Intl` 跟着 tzdata 走,写死的偏移不会。
|
||||
* `timeZone` 选项 Chrome 24+ 就支持,不影响机房老 Chrome。
|
||||
*/
|
||||
export const DISPLAY_TIME_ZONE = "Asia/Shanghai"
|
||||
|
||||
const zonedFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: DISPLAY_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hourCycle: "h23",
|
||||
})
|
||||
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
|
||||
|
||||
const pad2 = (value: number) => String(value).padStart(2, "0")
|
||||
|
||||
/**
|
||||
* 取一个时刻在东八区的年月日时分秒(都是数字,月/日/时/分/秒已补零成两位数)。
|
||||
* 无效日期返回 null。
|
||||
* 取一个时刻在东八区的年月日时分秒(数字)。无效日期返回 null。
|
||||
*
|
||||
* **不要在组件里写 `getFullYear()` / `getMonth()` / `getDate()`** —— 那些取的是
|
||||
* 浏览器本地部件,和站内的东八区口径不是一回事。要日历部件就用这个。
|
||||
* 站内所有时间一律按**东八区**展示、不跟浏览器时区走,口径是契约里的
|
||||
* `TIME_ZONE`(和后端 `apps/api/src/time.ts` 同一个常量)。平移固定偏移后读 `getUTC*`,
|
||||
* 就是北京的墙上时间。**不要在组件里写 `getFullYear()` / `getMonth()` / `getDate()`**
|
||||
* —— 那是浏览器本地部件。
|
||||
*/
|
||||
export function zonedParts(value: Date | string) {
|
||||
const date = normalizeDate(value)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
const raw: Record<string, number> = {}
|
||||
for (const part of zonedFormatter.formatToParts(date)) {
|
||||
if (part.type !== "literal") raw[part.type] = Number(part.value)
|
||||
}
|
||||
const time = normalizeDate(value).getTime()
|
||||
if (Number.isNaN(time)) return null
|
||||
const wall = new Date(time + OFFSET_MS)
|
||||
return {
|
||||
year: raw.year!,
|
||||
month: raw.month!,
|
||||
day: raw.day!,
|
||||
hour: raw.hour!,
|
||||
minute: raw.minute!,
|
||||
second: raw.second!,
|
||||
year: wall.getUTCFullYear(),
|
||||
month: wall.getUTCMonth() + 1,
|
||||
day: wall.getUTCDate(),
|
||||
hour: wall.getUTCHours(),
|
||||
minute: wall.getUTCMinutes(),
|
||||
second: wall.getUTCSeconds(),
|
||||
}
|
||||
}
|
||||
|
||||
/** 东八区的年份。跨年那几个小时里它和 `new Date().getFullYear()` 会差一年 */
|
||||
export function zonedYear(value: Date | string = new Date()) {
|
||||
return zonedParts(value)?.year ?? new Date().getFullYear()
|
||||
/** 东八区的当前年份。跨年那几个小时里它和 `new Date().getFullYear()` 会差一年 */
|
||||
export function zonedYear() {
|
||||
return zonedParts(new Date())!.year
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,38 +170,20 @@ export function parseTime(utc: Date | string, format = "YYYY年M月D日") {
|
||||
}
|
||||
|
||||
/**
|
||||
* Naive 的 `n-date-picker` 没有 `timezone` 属性,它把绑定的时间戳按**浏览器本地**
|
||||
* 渲染。站内的口径是东八区,所以非东八区的机器上要平移一次再交给它。
|
||||
*
|
||||
* 于是这两个函数是一对逆运算:
|
||||
* Naive 的 `n-date-picker` 没有 `timezone` 属性,按**浏览器本地**渲染绑定的时间戳,
|
||||
* 所以要平移一次再交给它。这两个函数互为逆运算,东八区的机器上是恒等:
|
||||
*
|
||||
* toPickerValue(真实时刻) → 绑给 n-date-picker,本地渲染出来正好是北京墙上时间
|
||||
* fromPickerValue(选择器值) → 换回真实时刻,再formatISO/存库
|
||||
* fromPickerValue(选择器值) → 换回真实时刻,再 formatISO / 存库
|
||||
*
|
||||
* 北京机器上换算是**恒等**(480 + (-480) = 0),所以不会改变现状;只在别处才起作用。
|
||||
*
|
||||
* 偏移写成常量而不是查 `Intl`:大陆 1991 年起没有夏令时,东八区恒为 UTC+8,
|
||||
* 和 `../api/src/time.ts` 一个道理。`longOffset` 那套要 Chrome 95+,机房老 Chrome 用不了。
|
||||
*
|
||||
* ⚠️ **只有 `n-date-picker` 需要这一对。** 要显示时间用 `parseTime`,不要把
|
||||
* 平移过的值喂给它 —— 那会显示成北京时间的「再平移」。
|
||||
* ⚠️ **只有 `n-date-picker` 需要这一对。** 显示时间用 `parseTime`,别把平移过的值喂给它。
|
||||
*/
|
||||
const PICKER_OFFSET_MINUTES = 8 * 60
|
||||
|
||||
/** 真实时刻(epoch 毫秒)→ n-date-picker 的绑定值 */
|
||||
export function toPickerValue(instant: number) {
|
||||
return (
|
||||
instant +
|
||||
(PICKER_OFFSET_MINUTES + new Date(instant).getTimezoneOffset()) * 60_000
|
||||
)
|
||||
return instant + OFFSET_MS + new Date(instant).getTimezoneOffset() * 60_000
|
||||
}
|
||||
|
||||
/** n-date-picker 的绑定值 → 真实时刻(epoch 毫秒) */
|
||||
export function fromPickerValue(value: number) {
|
||||
return (
|
||||
value -
|
||||
(PICKER_OFFSET_MINUTES + new Date(value).getTimezoneOffset()) * 60_000
|
||||
)
|
||||
return value - OFFSET_MS - new Date(value).getTimezoneOffset() * 60_000
|
||||
}
|
||||
|
||||
function getDurationObject(start: Date | string, end: Date | string) {
|
||||
|
||||
Reference in New Issue
Block a user