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:
@@ -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() {
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { zonedYear } from "utils/functions"
|
||||
|
||||
const route = useRoute()
|
||||
const { isMobile } = useBreakpoints()
|
||||
@@ -29,7 +30,9 @@ const hiddenICP = computed(() =>
|
||||
["problem", "contest problem"].includes(route.name as string),
|
||||
)
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
// 版权年份也走东八区:站内不留任何一处按浏览器时区取时间部件的代码,
|
||||
// 免得下一个人照着抄
|
||||
const currentYear = zonedYear()
|
||||
const copyrightText = `© 2022 - ${currentYear} 判题狗 保留所有权利`
|
||||
|
||||
function goICP() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { parseTime } from "utils/functions"
|
||||
import { getNodeTypeConfig } from "./useNodeStyles"
|
||||
import { currentDragNodeType } from "./useDnD"
|
||||
|
||||
@@ -60,7 +61,8 @@ const saveStatusTitle = computed(() => {
|
||||
} else if (props.hasUnsavedChanges) {
|
||||
return "有未保存的更改"
|
||||
} else if (props.lastSaved) {
|
||||
return `已保存 - ${new Date(props.lastSaved).toLocaleTimeString()}`
|
||||
// 和站内其它时间同一口径(东八区),不用 toLocaleTimeString() 跟着浏览器走
|
||||
return `已保存 - ${parseTime(props.lastSaved, "HH:mm:ss")}`
|
||||
} else {
|
||||
return "已保存"
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type Zippable,
|
||||
} from "fflate"
|
||||
import copyTextFallback from "copy-text-to-clipboard"
|
||||
import { normalizeDate } from "@vueuse/core"
|
||||
import { customAlphabet } from "nanoid"
|
||||
|
||||
function calculateACRate(acCount: number, totalCount: number): string {
|
||||
@@ -114,9 +115,118 @@ 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 pad2 = (value: number) => String(value).padStart(2, "0")
|
||||
|
||||
/**
|
||||
* 取一个时刻在东八区的年月日时分秒(都是数字,月/日/时/分/秒已补零成两位数)。
|
||||
* 无效日期返回 null。
|
||||
*
|
||||
* **不要在组件里写 `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)
|
||||
}
|
||||
return {
|
||||
year: raw.year!,
|
||||
month: raw.month!,
|
||||
day: raw.day!,
|
||||
hour: raw.hour!,
|
||||
minute: raw.minute!,
|
||||
second: raw.second!,
|
||||
}
|
||||
}
|
||||
|
||||
/** 东八区的年份。跨年那几个小时里它和 `new Date().getFullYear()` 会差一年 */
|
||||
export function zonedYear(value: Date | string = new Date()) {
|
||||
return zonedParts(value)?.year ?? new Date().getFullYear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按东八区格式化。格式串只认下面这几个 token(站内实际用到的就这些),
|
||||
* 其余字符原样输出,所以 `YYYY年M月D日` 这种中英混排也能用。
|
||||
*
|
||||
* 长度不同的 token 靠正则的**顺序**区分:`YYYY` 必须排在 `M`/`D` 前面,
|
||||
* 否则 `MM` 会被拆成两个 `M`。
|
||||
*/
|
||||
export function parseTime(utc: Date | string, format = "YYYY年M月D日") {
|
||||
const time = useDateFormat(utc, format, { locales: "zh-CN" })
|
||||
return time.value
|
||||
const parts = zonedParts(utc)
|
||||
if (!parts) return ""
|
||||
const table: Record<string, string> = {
|
||||
YYYY: String(parts.year),
|
||||
MM: pad2(parts.month),
|
||||
DD: pad2(parts.day),
|
||||
HH: pad2(parts.hour),
|
||||
mm: pad2(parts.minute),
|
||||
ss: pad2(parts.second),
|
||||
M: String(parts.month),
|
||||
D: String(parts.day),
|
||||
}
|
||||
return format.replace(/YYYY|MM|DD|HH|mm|ss|M|D/g, (token) => table[token]!)
|
||||
}
|
||||
|
||||
/**
|
||||
* Naive 的 `n-date-picker` 没有 `timezone` 属性,它把绑定的时间戳按**浏览器本地**
|
||||
* 渲染。站内的口径是东八区,所以非东八区的机器上要平移一次再交给它。
|
||||
*
|
||||
* 于是这两个函数是一对逆运算:
|
||||
*
|
||||
* toPickerValue(真实时刻) → 绑给 n-date-picker,本地渲染出来正好是北京墙上时间
|
||||
* fromPickerValue(选择器值) → 换回真实时刻,再formatISO/存库
|
||||
*
|
||||
* 北京机器上换算是**恒等**(480 + (-480) = 0),所以不会改变现状;只在别处才起作用。
|
||||
*
|
||||
* 偏移写成常量而不是查 `Intl`:大陆 1991 年起没有夏令时,东八区恒为 UTC+8,
|
||||
* 和 `../api/src/time.ts` 一个道理。`longOffset` 那套要 Chrome 95+,机房老 Chrome 用不了。
|
||||
*
|
||||
* ⚠️ **只有 `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
|
||||
)
|
||||
}
|
||||
|
||||
/** n-date-picker 的绑定值 → 真实时刻(epoch 毫秒) */
|
||||
export function fromPickerValue(value: number) {
|
||||
return (
|
||||
value -
|
||||
(PICKER_OFFSET_MINUTES + new Date(value).getTimezoneOffset()) * 60_000
|
||||
)
|
||||
}
|
||||
|
||||
function getDurationObject(start: Date | string, end: Date | string) {
|
||||
|
||||
Reference in New Issue
Block a user