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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user