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
197 lines
4.7 KiB
Vue
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>
|