Files
OJ2/apps/web/src/admin/problem/TopACTrend.vue
T
xuyueandClaude Opus 5 cc51e305cf
Deploy / deploy (push) Has been cancelled
refactor(时区): 常量收进契约、SQL 统一走 localTime,去掉会话时区与 TZ 兜底
- 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
2026-09-14 06:06:52 -06:00

197 lines
4.7 KiB
Vue

<script setup lang="ts">
import type { AcTrend } from "utils/types"
import { Line } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
Filler,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from "chart.js"
import { getTopACTrend } from "admin/api"
import { zonedYear } from "utils/functions"
ChartJS.register(
CategoryScale,
Filler,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
)
type ProblemTrend = AcTrend
// 年份按东八区取,和后端 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,
}))
const minPerYearOptions = [
{ label: "50", value: 50 },
{ label: "100", value: 100 },
{ label: "200", value: 200 },
]
const sinceYear = ref(2023)
const untilYear = ref(currentYear - 1)
const minPerYear = ref(100)
const loading = ref(false)
const data = ref<ProblemTrend[]>([])
const acLabelPlugin = {
id: "acLabel",
afterDatasetsDraw(chart: any) {
const ctx = chart.ctx
chart.data.datasets.forEach((_: any, i: number) => {
const meta = chart.getDatasetMeta(i)
meta.data.forEach((point: any, j: number) => {
const value = chart.data.datasets[i].data[j]
if (value === null || value === undefined) return
ctx.save()
ctx.font = "bold 11px sans-serif"
ctx.fillStyle = "rgba(99, 179, 237, 1)"
ctx.textAlign = "center"
ctx.textBaseline = "bottom"
ctx.fillText(`${value}%`, point.x, point.y - 6)
ctx.restore()
})
})
},
}
function getChartData(problem: ProblemTrend) {
return {
labels: problem.yearly.map((y) => String(y.year)),
datasets: [
{
label: "AC 率",
data: problem.yearly.map((y) => y.acRate),
fill: true,
tension: 0.3,
backgroundColor: "rgba(99, 179, 237, 0.2)",
borderColor: "rgba(99, 179, 237, 1)",
pointBackgroundColor: "rgba(99, 179, 237, 1)",
pointRadius: 4,
},
],
}
}
function getChartOptions(problem: ProblemTrend) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: `${problem.problemId} · ${problem.problemTitle}`,
font: { size: 14 },
},
tooltip: {
callbacks: {
label: (ctx: any) => {
const entry = problem.yearly[ctx.dataIndex]
return `AC 率: ${entry.acRate}% (${entry.accepted}/${entry.total})`
},
},
},
},
scales: {
y: {
min: 0,
max: 100,
ticks: { callback: (v: any) => `${v}%` },
},
x: {
title: { display: true, text: "年份" },
},
},
}
}
async function fetchData() {
loading.value = true
try {
const res = await getTopACTrend({
sinceYear: sinceYear.value,
untilYear: untilYear.value,
minPerYear: minPerYear.value,
})
data.value = res
} finally {
loading.value = false
}
}
onMounted(fetchData)
</script>
<template>
<h2 style="margin-top: 0">年度趋势</h2>
<n-space align="center" style="margin-bottom: 16px">
<span>年份范围</span>
<n-select
v-model:value="sinceYear"
:options="yearOptions"
style="width: 100px"
@update:value="fetchData"
/>
<span></span>
<n-select
v-model:value="untilYear"
:options="yearOptions"
style="width: 100px"
@update:value="fetchData"
/>
<span>年提交下限</span>
<n-select
v-model:value="minPerYear"
:options="minPerYearOptions"
style="width: 90px"
@update:value="fetchData"
/>
<n-tag type="info" size="small"> {{ data.length }} </n-tag>
</n-space>
<n-spin :show="loading">
<div
v-if="!loading && data.length === 0"
style="text-align: center; padding: 40px"
>
暂无数据
</div>
<div v-else class="grid">
<div v-for="problem in data" :key="problem.problemId" class="chart-card">
<Line
:data="getChartData(problem)"
:options="getChartOptions(problem)"
:plugins="[acLabelPlugin]"
/>
</div>
</div>
</n-spin>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
padding: 8px 0;
}
.chart-card {
height: 260px;
border-radius: 8px;
padding: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
</style>