feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
xuyue committed 2026-08-06 21:18:16 -06:00
1 parent 3c975e85ee
commit ae1fb329b5
258 files changed
+40490 -91

No files matched your search

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { ContestRank } from "utils/types"
interface Props {
rank: ContestRank
}
const props = defineProps<Props>()
const router = useRouter()
function goto() {
router.push({
name: "contest submissions",
query: { username: props.rank.user.username },
})
}
</script>
<template>
{{ rank.accepted_number }} /
<n-button text type="primary" @click="goto">
{{ rank.submission_number }}
</n-button>
</template>
<style scoped></style>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useContestStore } from "oj/store/contest"
import { parseTime } from "utils/functions"
import ContestType from "shared/components/ContestType.vue"
const contestStore = useContestStore()
</script>
<template>
<n-popover
v-if="contestStore.contest"
placement="bottom-end"
:show-arrow="false"
>
<template #trigger>
<n-button>
<template #icon>
<Icon icon="streamline-emojis:exclamation-mark"></Icon>
</template>
比赛信息
</n-button>
</template>
<div v-html="contestStore.contest.description"></div>
<n-descriptions bordered label-placement="left" :column="1">
<n-descriptions-item label="开始时间">
{{
parseTime(contestStore.contest.start_time, "YYYY年M月D日 HH:mm:ss")
}}
</n-descriptions-item>
<n-descriptions-item label="结束时间">
{{ parseTime(contestStore.contest.end_time, "YYYY年M月D日 HH:mm:ss") }}
</n-descriptions-item>
<n-descriptions-item label="比赛类型">
<ContestType :contest="contestStore.contest" />
</n-descriptions-item>
<n-descriptions-item label="发起人">
{{ contestStore.contest.created_by.username }}
</n-descriptions-item>
</n-descriptions>
</n-popover>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useContestStore } from "oj/store/contest"
import { useBreakpoints } from "shared/composables/breakpoints"
import { ContestStatus } from "utils/constants"
const route = useRoute()
const router = useRouter()
const contestStore = useContestStore()
const { isDesktop } = useBreakpoints()
const contestMenuVisible = computed(() => {
if (contestStore.isContestAdmin) return true
if (!contestStore.isPrivate) {
return contestStore.contestStatus !== ContestStatus.not_started
}
return contestStore.access
})
function goto(name: string) {
router.push({ name: "contest " + name })
}
function getCurrentType(name: string): "primary" | "default" {
if (route.name === "contest " + name) return "primary"
return "default"
}
const options: DropdownOption[] = [
{ label: "比赛题目", key: "problems" },
{ label: "提交信息", key: "submissions" },
{ label: "比赛排名", key: "rank" },
]
</script>
<template>
<div v-if="contestMenuVisible">
<n-flex v-if="isDesktop">
<n-button :type="getCurrentType('problems')" @click="goto('problems')">
比赛题目
</n-button>
<n-button
:type="getCurrentType('submissions')"
@click="goto('submissions')"
>
提交信息
</n-button>
<n-button :type="getCurrentType('rank')" @click="goto('rank')">
比赛排名
</n-button>
</n-flex>
<n-dropdown v-else :options="options" @select="goto">
<n-button>菜单</n-button>
</n-dropdown>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,231 @@
<template>
<div class="chart" v-if="showChart">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { Line } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import type { ContestRank } from "utils/types"
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
)
interface Props {
ranks: ContestRank[]
problems: Array<{ id: number; title: string }>
}
const props = defineProps<Props>()
const PENALTY_SECONDS = 20 * 60
const showChart = computed(() => {
const hasRanks = props.ranks.length > 0
const hasProblems = props.problems.length >= 3
return hasProblems && hasRanks
})
const colorPalette = [
"#3B82F6",
"#EF4444",
"#10B981",
"#F59E0B",
"#8B5CF6",
"#EC4899",
"#06B6D4",
"#84CC16",
"#F97316",
"#6366F1",
]
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return `${h}h${m}m`
return `${m}m`
}
interface AcEvent {
time: number
userIndex: number
problemId: string
}
const chartData = computed(() => {
if (!props.ranks || props.ranks.length === 0) {
return { labels: [], datasets: [] }
}
const topUsers = props.ranks.slice(0, 10)
// 收集所有AC事件并按时间排序
const events: AcEvent[] = []
topUsers.forEach((rank, userIndex) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
if (info.is_ac) {
events.push({ time: info.ac_time, userIndex, problemId })
}
})
})
events.sort((a, b) => a.time - b.time)
if (events.length === 0) {
return { labels: [], datasets: [] }
}
// 在每个时间点计算所有人的排名
// 状态: 每个用户当前已AC题数和罚时
const userState = topUsers.map(() => ({
solved: 0,
penalty: 0,
}))
// 用于记录每个用户每道题的错误次数
const userErrors: Map<string, number>[] = topUsers.map(() => new Map())
topUsers.forEach((rank, i) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
if (info.error_number > 0) {
userErrors[i].set(problemId, info.error_number)
}
})
})
function calcRanks(): number[] {
const indexed = userState.map((s, i) => ({ ...s, i }))
indexed.sort((a, b) => {
if (b.solved !== a.solved) return b.solved - a.solved
return a.penalty - b.penalty
})
const ranks = new Array(topUsers.length).fill(0)
indexed.forEach((item, pos) => {
ranks[item.i] = pos + 1
})
return ranks
}
// 时间轴上的数据点: [时间标签, 各用户排名]
const timePoints: number[] = [0]
const rankSnapshots: number[][] = [calcRanks()]
// 按时间处理事件(合并同一时刻的事件)
let i = 0
while (i < events.length) {
const currentTime = events[i].time
// 处理同一时刻的所有事件
while (i < events.length && events[i].time === currentTime) {
const ev = events[i]
userState[ev.userIndex].solved++
const errors = userErrors[ev.userIndex].get(ev.problemId) || 0
userState[ev.userIndex].penalty =
userState[ev.userIndex].penalty + ev.time + errors * PENALTY_SECONDS
i++
}
timePoints.push(currentTime)
rankSnapshots.push(calcRanks())
}
const labels = timePoints.map((t) => formatTime(t))
const datasets = topUsers.map((rank, userIndex) => {
const color = colorPalette[userIndex % colorPalette.length]
const finalRank = rankSnapshots[rankSnapshots.length - 1][userIndex]
return {
label: `#${finalRank} ${rank.user.username}`,
data: rankSnapshots.map((snapshot) => snapshot[userIndex]),
borderColor: color,
backgroundColor: color,
tension: 0.3,
fill: false,
pointRadius: 3,
pointHoverRadius: 6,
pointBackgroundColor: color,
pointBorderColor: "#fff",
pointBorderWidth: 1,
borderWidth: 2.5,
}
})
return { labels, datasets }
})
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index" as const,
intersect: false,
},
plugins: {
legend: {
display: true,
position: "top" as const,
maxHeight: 80,
labels: {
boxWidth: 14,
boxHeight: 3,
padding: 10,
font: { size: 12 },
},
},
tooltip: {
mode: "index" as const,
intersect: false,
itemSort: (a: any, b: any) => a.parsed.y - b.parsed.y,
callbacks: {
title: (context: any) => `比赛进行: ${context[0].label}`,
label: (context: any) => {
const rank = context.parsed.y
const name = context.dataset.label
return ` 第${rank}名 — ${name}`
},
},
},
},
scales: {
x: {
title: {
display: true,
text: "比赛时间",
},
},
y: {
title: {
display: true,
text: "排名",
},
reverse: true,
min: 1,
max: 10,
ticks: {
stepSize: 1,
callback: (value: any) => `第${value}名`,
},
},
},
}))
</script>
<style scoped>
.chart {
height: 420px;
width: 100%;
margin-bottom: 24px;
}
</style>