feat(比赛): 比赛页一页看题目和自己的名次;排名竞争感做足、结束有领奖台;老师考试中看全班、考完看成绩;新建比赛一屏做完
Deploy / deploy (push) Canceled after 0s

- 学生比赛页(设计稿 B):左边题目带几人做对、谁最先做对;右边我第几名、5 分钟里升了几名、再做对 1 道能到第几名、我前后的人
- 期中、期末考试中只有自己的进度,写明「排名考完公布」,题目页的排名入口也藏起来
- 排名页:按班筛、名次变化、最先做对的深绿格、自己那行钉住;结束后前三名领奖台 + 我的成绩卡;排名曲线和学生的「导出数据」去掉
- 老师的「全班情况」:卡在一道题、15 分钟没交的放最上面,没进来的人对上看板的请假;考完变「成绩」,点格子看代码、标看过了(代替后台审核页),导出成绩
- 比赛列表:进行中的一大块「接着比」,已结束的写我第几名,前三带奖杯
- 新建比赛:默认 10 分钟后开始,时长点按钮,搜题号把题加进来自动编号、拖动排顺序

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 20:58:59 +08:00
1 parent b94e028ff4
commit 151bbdcb50
19 files changed
+3768 -743

No files matched your search

+5 -10
View File
@@ -7,8 +7,6 @@ import type {
GenerateSqlTestCaseResponse, GenerateSqlTestCaseResponse,
RenameTagResponse, RenameTagResponse,
SqlTestCaseScript, SqlTestCaseScript,
AcmHelperItem,
ContestSubmissionInfo,
AdminAiReport, AdminAiReport,
AdminAiReportList, AdminAiReportList,
StuckProblem, StuckProblem,
@@ -357,6 +355,11 @@ export function addProblemForContest(contestID: string, problemID: number, displ
}) })
} }
/** 比赛题按这个顺序重新编号 1、2、3…(要给全这场比赛的题) */
export function reorderContestProblems(contestID: string, problemIds: number[]) {
return api.put(`admin/contests/${contestID}/problems/order`, { problemIds })
}
export function getWebsite() { export function getWebsite() {
return api.get<WebsiteConfig>("admin/website") return api.get<WebsiteConfig>("admin/website")
} }
@@ -478,14 +481,6 @@ export function makeProblemPublic(id: number, displayId: string) {
} }
// 比赛辅助检查 // 比赛辅助检查
export function getACMHelperList(contestId: number) {
// acInfo 在契约里是 Record<string, unknown>(acm_contest_rank 的 JSONB 原文),
// 组件侧按 ContestSubmissionInfo 读,收窄放在这里
return api.get<Array<Omit<AcmHelperItem, "acInfo"> & { acInfo: ContestSubmissionInfo }>>(
`admin/contests/${contestId}/acm-helper`,
)
}
export function updateACMHelperChecked( export function updateACMHelperChecked(
contest_id: number, contest_id: number,
rank_id: number, rank_id: number,
@@ -16,18 +16,9 @@ function goEdit() {
}) })
} }
function goEditProblems() { // 原来的「审核」页并进了前台比赛页老师的「全班情况 / 成绩」:点格子看代码、标看过了
router.push({ function goClass() {
name: "admin contest problem list", window.open(`/contest/${props.contest.id}/class`, "_blank")
params: { contestID: props.contest.id },
})
}
function goACMHelper() {
router.push({
name: "admin contest helper",
params: { contestID: props.contest.id },
})
} }
async function clone() { async function clone() {
@@ -45,9 +36,10 @@ async function clone() {
</script> </script>
<template> <template>
<n-flex> <n-flex>
<n-button size="small" type="primary" secondary @click="goEditProblems"> 题目 </n-button> <n-button size="small" type="primary" secondary @click="goEdit"> 编辑 </n-button>
<n-button size="small" type="warning" secondary @click="goACMHelper"> 审核 </n-button> <n-button size="small" type="info" secondary @click="goClass">
<n-button size="small" type="info" secondary @click="goEdit"> 编辑 </n-button> {{ contest.status === "-1" ? "成绩" : "全班情况" }}
</n-button>
<n-button size="small" secondary @click="clone"> 复制 </n-button> <n-button size="small" secondary @click="clone"> 复制 </n-button>
</n-flex> </n-flex>
</template> </template>
+629 -121
View File
@@ -1,57 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from "naive-ui"
import PageHeader from "admin/components/PageHeader.vue" import PageHeader from "admin/components/PageHeader.vue"
import { errorMessage } from "utils/api" import { errorCode, errorMessage } from "utils/api"
import { formatISO } from "date-fns"
import TextEditor from "shared/components/TextEditor.vue" import TextEditor from "shared/components/TextEditor.vue"
import { DIFFICULTY } from "utils/constants"
import { fromPickerValue, parseTime, toPickerValue } from "utils/functions" import { fromPickerValue, parseTime, toPickerValue } from "utils/functions"
import type { BlankContest } from "utils/types" import type { AdminProblemRow, BlankContest } from "utils/types"
import { createContest, editContest, getContest } from "../api" import {
addProblemForContest,
createContest,
deleteContestProblem,
editContest,
getContest,
getLibraryProblemList,
getProblemList,
reorderContestProblems,
} from "../api"
interface Props { /**
contestID?: string * 新建 / 编辑比赛,一屏做完(设计稿「比赛重设计」后台那块):左边名字、类型、时间、密码,
} * 右边题目 —— 搜题号把题库的题加进来,编号自动按 1、2、3 排,拖动换顺序。
*
* 开始时间默认 **10 分钟后**(用户定的:建比赛、加题要花时间,不能是「现在」)。
* 新建时题目先攒在页面上,保存时建好比赛再按顺序一道道复制进去;
* 编辑时加、删、排顺序都是当场生效的
*/
const props = defineProps<{ contestID?: string }>()
function getTimes() {
const timestamp = Date.now()
const rounded = timestamp - (timestamp % 60000) // 确保秒数为0
const t1 = rounded + waitMins.value * 60000
const t2 = t1 + durationMins.value * 60000
return [t1, t2]
}
// 创建的时候
const waitMins = ref(5) // 顺延5分钟
const durationMins = ref(10) // 比赛默认时长10分钟
watch([waitMins, durationMins], () => {
const times = getTimes()
contest.startTime = formatISO(times[0])
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(fromPickerValue(values[0]))
contest.endTime = formatISO(fromPickerValue(values[1]))
})
const route = useRoute()
const router = useRouter() const router = useRouter()
const message = useMessage() const message = useMessage()
const props = defineProps<Props>() const dialog = useDialog()
const theme = useThemeVars()
const [ready, toggleReady] = useToggle() const editing = computed(() => !!props.contestID)
const ready = ref(false)
const tags: SelectOption[] = [ const saving = ref(false)
{ label: "练习", value: "练习" },
{ label: "期中", value: "期中" },
{ label: "期末", value: "期末" },
]
const contest = reactive<BlankContest & { id: number }>({ const contest = reactive<BlankContest & { id: number }>({
id: 0, id: 0,
@@ -61,103 +44,628 @@ const contest = reactive<BlankContest & { id: number }>({
startTime: "", startTime: "",
endTime: "", endTime: "",
password: "", password: "",
visible: false, // 不列出来学生就进不去(隐藏比赛对学生是 404),新建默认就列出来
visible: true,
}) })
async function getContestDetail() { // ---------------------------------------------------------------- 时间
const WAIT_CHOICES = [10, 20, 30]
const DURATION_CHOICES = [40, 60, 90]
/** 新建时「几分钟后开始」;null = 自己选了具体时刻 */
const waitMins = ref<number | null>(10)
const durationMins = ref(60)
const customDuration = ref(false)
/** 自己选的开始时刻,已平移给 n-date-picker(见 utils/functions.ts 的 toPickerValue) */
const pickedStart = ref(toPickerValue(Date.now() + 10 * 60_000))
// 「10 分钟后」跟着钟走:老师在这页磨蹭几分钟再保存,开始时间也还是保存时的 10 分钟后
const now = useNow({ interval: 15_000 })
function roundedNow() {
const ms = now.value.getTime()
return ms - (ms % 60_000)
}
const startMs = computed(() =>
waitMins.value !== null
? roundedNow() + waitMins.value * 60_000
: fromPickerValue(pickedStart.value),
)
const endMs = computed(() => startMs.value + durationMins.value * 60_000)
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]
const timeHint = computed(() => {
const start = new Date(startMs.value).toISOString()
const end = new Date(endMs.value).toISOString()
// 东八区的星期几:平移 8 小时后读 UTC
const weekday = WEEKDAYS[new Date(startMs.value + 8 * 3600_000).getUTCDay()]
const sameDay = parseTime(start, "YYYY-MM-DD") === parseTime(end, "YYYY-MM-DD")
return `${parseTime(start, "M月D日")} 周${weekday} ${parseTime(start, "HH:mm")} 开始,${parseTime(end, sameDay ? "HH:mm" : "M月D日 HH:mm")} 结束`
})
function pickWait(minutes: number | null) {
if (minutes === null) pickedStart.value = toPickerValue(startMs.value)
waitMins.value = minutes
}
function pickDuration(minutes: number | null) {
customDuration.value = minutes === null
if (minutes !== null) durationMins.value = minutes
}
// ---------------------------------------------------------------- 题目
type Item = { id: number; displayId: string; title: string; difficulty: string }
/** 新建时是题库里的题(id 是公开题的),编辑时是比赛里的题(id 是比赛题自己的) */
const items = ref<Item[]>([])
/** 新建:题库里已经选进来的公开题 id,用来标「已加入」 */
const pickedPublic = computed(() => new Set(items.value.map((item) => item.id)))
async function loadContestProblems() {
const res = await getProblemList(0, 250, "", undefined, props.contestID)
items.value = res.results
.map((row: AdminProblemRow) => ({
id: row.id,
displayId: row._id,
title: row.title,
difficulty: row.difficulty,
}))
.sort(
(a, b) => a.displayId.length - b.displayId.length || a.displayId.localeCompare(b.displayId),
)
}
const keyword = ref("")
const results = ref<AdminProblemRow[]>([])
const searching = ref(false)
watchDebounced(
keyword,
async (value) => {
if (!value.trim()) {
results.value = []
return
}
searching.value = true
try {
results.value = (await getLibraryProblemList(0, 8, value.trim())).results
} finally {
searching.value = false
}
},
{ debounce: 300 },
)
/** 编辑时比赛题是复制品,没法知道它从哪道公开题来,按题名比一下防重复 */
function added(row: AdminProblemRow) {
return editing.value
? items.value.some((item) => item.title === row.title)
: pickedPublic.value.has(row.id)
}
async function add(row: AdminProblemRow) {
if (!editing.value) {
items.value.push({
id: row.id,
displayId: String(items.value.length + 1),
title: row.title,
difficulty: row.difficulty,
})
return
}
const next = Math.max(0, ...items.value.map((item) => Number(item.displayId) || 0)) + 1
try {
await addProblemForContest(props.contestID!, row.id, String(next))
await loadContestProblems()
} catch (err) {
const code = errorCode(err)
message.error(
code === "contest-ended"
? "比赛已经结束,不能再加题"
: code === "display-id-exists"
? "题号重了,刷新一下再加"
: errorMessage(err),
)
}
}
function remove(item: Item) {
if (!editing.value) {
items.value = items.value.filter((it) => it !== item)
renumber()
return
}
dialog.warning({
title: "从比赛里删掉这道题?",
content: `第 ${item.displayId} 题「${item.title}」。已经有人交过的题删不掉。`,
positiveText: "删掉",
negativeText: "不删",
onPositiveClick: async () => {
try {
await deleteContestProblem(item.id)
await loadContestProblems()
if (items.value.length) await saveOrder()
} catch (err) {
message.error(
errorCode(err) === "problem-has-submissions"
? "已经有人交过这道题,删不掉;不想让学生看到可以在题目里关掉「可见」"
: errorMessage(err),
)
}
},
})
}
function renumber() {
items.value.forEach((item, index) => (item.displayId = String(index + 1)))
}
async function saveOrder() {
renumber()
if (!editing.value) return
try {
await reorderContestProblems(
props.contestID!,
items.value.map((item) => item.id),
)
} catch (err) {
message.error(errorMessage(err))
await loadContestProblems()
}
}
// 拖动排序:原生 drag & drop,够用了
const dragging = ref<number | null>(null)
function onDragStart(index: number) {
dragging.value = index
}
function onDragEnter(index: number) {
const from = dragging.value
if (from === null || from === index) return
const list = [...items.value]
const [moved] = list.splice(from, 1)
list.splice(index, 0, moved!)
items.value = list
dragging.value = index
}
function onDragEnd() {
if (dragging.value === null) return
dragging.value = null
saveOrder()
}
function editProblem(item: Item) {
router.push({
name: "admin contest problem edit",
params: { contestID: props.contestID, problemID: item.id },
})
}
// ---------------------------------------------------------------- 载入 / 保存
async function load() {
keyword.value = ""
if (!props.contestID) { if (!props.contestID) {
const times = getTimes() ready.value = true
contest.startTime = formatISO(times[0])
contest.endTime = formatISO(times[1])
toggleReady(true)
return return
} }
const data = await getContest(props.contestID) const data = await getContest(props.contestID)
toggleReady(true) Object.assign(contest, {
contest.id = data.id id: data.id,
contest.title = data.title title: data.title,
contest.description = data.description description: data.description,
contest.tag = data.tag tag: data.tag,
contest.startTime = data.startTime startTime: data.startTime,
contest.endTime = data.endTime endTime: data.endTime,
contest.password = data.password password: data.password,
contest.visible = data.visible visible: data.visible,
})
// 显示:交给选择器之前先平移成「北京墙上时间」 waitMins.value = null
startTime.value = toPickerValue(Date.parse(data.startTime)) pickedStart.value = toPickerValue(Date.parse(data.startTime))
endTime.value = toPickerValue(Date.parse(data.endTime)) const minutes = Math.round((Date.parse(data.endTime) - Date.parse(data.startTime)) / 60_000)
durationMins.value = minutes
customDuration.value = !DURATION_CHOICES.includes(minutes)
await loadContestProblems()
ready.value = true
} }
async function submit() { async function submit() {
if (contest.description === "<p><br></p>") { if (!contest.title.trim()) {
message.error("比赛还没起名字")
return
}
if (durationMins.value <= 0) {
message.error("时长要大于 0")
return
}
contest.startTime = new Date(startMs.value).toISOString()
contest.endTime = new Date(endMs.value).toISOString()
if (!contest.description || contest.description === "<p><br></p>") {
contest.description = contest.title contest.description = contest.title
} }
const api = { saving.value = true
"admin contest create": createContest,
"admin contest edit": editContest,
}[route.name as string]
try { try {
await api!(contest) if (editing.value) {
if (route.name === "admin contest create") { await editContest(contest)
message.success("成功新建比赛 💐") message.success("改好了")
} else { return
message.success("修改已保存")
} }
router.push({ name: "admin contest list" }) const created = await createContest(contest)
for (const [index, item] of items.value.entries()) {
await addProblemForContest(String(created.id), item.id, String(index + 1))
}
message.success(
items.value.length ? `比赛建好了,${items.value.length} 道题都加进去了` : "比赛建好了",
)
router.replace({ name: "admin contest edit", params: { contestID: created.id } })
} catch (err) { } catch (err) {
message.error(errorMessage(err)) message.error(errorMessage(err))
} finally {
saving.value = false
} }
} }
onMounted(getContestDetail) watch(() => props.contestID, load)
onMounted(load)
</script> </script>
<template> <template>
<PageHeader <PageHeader :title="editing ? '编辑比赛' : '新建比赛'" :back="{ name: 'admin contest list' }">
:title="route.name === 'admin contest create' ? '新建比赛' : '编辑比赛'" <template v-if="editing" #actions>
:back="{ name: 'admin contest list' }" <n-button tag="a" :href="`/contest/${contestID}/class`" target="_blank">
> 到前台看这场比赛
<template v-if="!props.contestID" #extra> </n-button>
<n-tag type="success" round>
开始 {{ parseTime(contest.startTime, "YYYY年M月D日 HH:mm:ss") }}
</n-tag>
<n-tag type="warning" round>
结束 {{ parseTime(contest.endTime, "YYYY年M月D日 HH:mm:ss") }}
</n-tag>
</template> </template>
</PageHeader> </PageHeader>
<n-form inline>
<n-form-item label="标题"> <div v-if="ready" class="cols">
<n-input style="width: 300px" v-model:value="contest.title" /> <section class="card form">
</n-form-item> <div class="field">
<n-form-item label="标签"> <span class="label">名字</span>
<n-select style="width: 100px" :options="tags" v-model:value="contest.tag" /> <n-input v-model:value="contest.title" placeholder="比如 24计算机1班期末考试" />
</n-form-item> </div>
<template v-if="props.contestID">
<n-form-item label="开始"> <div class="field top">
<n-date-picker style="width: 200px" v-model:value="startTime" type="datetime" /> <span class="label">类型</span>
</n-form-item> <div class="stack">
<n-form-item label="结束"> <n-radio-group v-model:value="contest.tag">
<n-date-picker style="width: 200px" v-model:value="endTime" type="datetime" /> <n-radio-button value="练习">练习</n-radio-button>
</n-form-item> <n-radio-button value="期中">期中</n-radio-button>
</template> <n-radio-button value="期末">期末</n-radio-button>
<template v-else> </n-radio-group>
<n-form-item label="几分钟后开始"> <span class="hint">
<n-input-number style="width: 120px" v-model:value="waitMins" /> 练习:学生边做边看排名。期中、期末:考试中学生看不到排名和每题做对人数,考完一起公布。
</n-form-item> </span>
<n-form-item label="比赛时长"> </div>
<n-input-number style="width: 120px" step="5" v-model:value="durationMins" /> </div>
</n-form-item>
</template> <div class="field top">
<n-form-item label="密码"> <span class="label">开始</span>
<n-input style="width: 160px" v-model:value="contest.password" /> <div class="stack">
</n-form-item> <n-flex :size="6">
<n-form-item label="可见"> <template v-if="!editing">
<n-switch v-model:value="contest.visible" /> <button
</n-form-item> v-for="m in WAIT_CHOICES"
</n-form> :key="m"
<TextEditor v-if="ready" title="描述" v-model:value="contest.description" :min-height="200" /> class="chip"
<n-flex style="margin-bottom: 100px" justify="end"> :class="{ on: waitMins === m }"
<n-button type="primary" @click="submit">保存</n-button> @click="pickWait(m)"
</n-flex> >
{{ m }} 分钟后
</button>
<button class="chip" :class="{ on: waitMins === null }" @click="pickWait(null)">
选时间…
</button>
</template>
<n-date-picker
v-if="waitMins === null"
v-model:value="pickedStart"
type="datetime"
format="yyyy-MM-dd HH:mm"
style="width: 200px"
/>
</n-flex>
</div>
</div>
<div class="field top">
<span class="label">时长</span>
<div class="stack">
<n-flex :size="6" align="center">
<button
v-for="m in DURATION_CHOICES"
:key="m"
class="chip"
:class="{ on: !customDuration && durationMins === m }"
@click="pickDuration(m)"
>
{{ m === 60 ? "1 小时" : `${m} 分钟` }}
</button>
<button class="chip" :class="{ on: customDuration }" @click="pickDuration(null)">
自己填
</button>
<n-input-number
v-if="customDuration"
v-model:value="durationMins"
:min="1"
:step="5"
style="width: 130px"
>
<template #suffix>分钟</template>
</n-input-number>
</n-flex>
<span class="hint">{{ timeHint }}</span>
</div>
</div>
<div class="field">
<span class="label">密码</span>
<n-input v-model:value="contest.password" placeholder="不填,谁都能进" />
</div>
<div class="field">
<span class="label">列出来</span>
<n-switch v-model:value="contest.visible" />
<span class="hint">{{
contest.visible ? "学生在比赛列表里看得到" : "藏起来,学生进不去"
}}</span>
</div>
<div class="field top">
<span class="label">说明</span>
<div class="stack grow">
<TextEditor v-model:value="contest.description" title="" mini :min-height="80" />
<span class="hint">选填,学生在「比赛信息」里看得到。</span>
</div>
</div>
<div class="save">
<n-button type="primary" :loading="saving" @click="submit">保存</n-button>
<span class="hint">{{ items.length }} 道题 · {{ timeHint }}</span>
</div>
</section>
<section class="card picker">
<div class="picker-head">
<b>题目</b>
<span class="hint">{{ items.length }} 道 · 拖动排顺序,题号自动按 1、2、3 排</span>
</div>
<div class="search">
<n-input
v-model:value="keyword"
clearable
:loading="searching"
placeholder="输入题号或题目名,从题库里加"
/>
<n-button
v-if="editing"
@click="router.push({ name: 'admin contest problem create', params: { contestID } })"
>
+ 新出一道
</n-button>
<n-tooltip v-else>
<template #trigger>
<n-button disabled>+ 新出一道</n-button>
</template>
先保存比赛,再出只在比赛里用的题
</n-tooltip>
</div>
<div v-if="results.length" class="results">
<div v-for="row in results" :key="row.id" class="result">
<span class="muted num">{{ row._id }}</span>
<span class="ell grow">{{ row.title }}</span>
<span class="muted tiny">{{ DIFFICULTY[row.difficulty] }}</span>
<span v-if="added(row)" class="muted tiny">已加入</span>
<n-button v-else size="small" text type="primary" @click="add(row)">+ 加入</n-button>
</div>
</div>
<div v-if="items.length" class="list">
<div
v-for="(item, index) in items"
:key="item.id"
class="item"
:class="{ dragging: dragging === index }"
draggable="true"
@dragstart="onDragStart(index)"
@dragenter.prevent="onDragEnter(index)"
@dragover.prevent
@dragend="onDragEnd"
>
<span class="grip" aria-hidden="true">⋮⋮</span>
<b class="num">{{ item.displayId }}</b>
<span class="ell grow">{{ item.title }}</span>
<span class="muted tiny">{{
DIFFICULTY[item.difficulty as keyof typeof DIFFICULTY]
}}</span>
<n-button v-if="editing" size="small" text type="primary" @click="editProblem(item)"
>改</n-button
>
<n-button
size="small"
text
:aria-label="`删掉第 ${item.displayId} 题`"
@click="remove(item)"
>✕</n-button
>
</div>
</div>
<div v-else class="empty hint">还没加题。在上面输入题号,比如 2033</div>
<span class="hint">
从题库加的题会复制一份进比赛:比赛里改它不影响原题,学生在题库里也搜不到比赛题。
</span>
</section>
</div>
</template> </template>
<style scoped>
.cols {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
}
.card {
box-sizing: border-box;
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 16px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
}
.form {
flex: 1 1 460px;
max-width: 560px;
}
.picker {
flex: 999 1 480px;
min-width: 0;
gap: 12px;
}
.field {
display: flex;
align-items: center;
gap: 12px;
}
.field.top {
align-items: flex-start;
}
.field.top .label {
line-height: 30px;
}
.label {
width: 52px;
flex-shrink: 0;
font-size: 13px;
color: v-bind("theme.textColor2");
}
.stack {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.grow {
flex-grow: 1;
min-width: 0;
}
.hint {
font-size: 12px;
line-height: 1.6;
color: v-bind("theme.textColor3");
}
.muted {
color: v-bind("theme.textColor3");
}
.tiny {
font-size: 12px;
flex-shrink: 0;
}
.num {
min-width: 28px;
font-variant-numeric: tabular-nums;
}
.ell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip {
height: 28px;
padding: 0 12px;
border-radius: 14px;
border: 1px solid v-bind("theme.borderColor");
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.chip.on {
border-color: v-bind("theme.primaryColor");
color: v-bind("theme.primaryColor");
font-weight: 600;
}
.save {
display: flex;
align-items: center;
gap: 10px;
padding-top: 12px;
border-top: 1px solid v-bind("theme.dividerColor");
}
.picker-head {
display: flex;
align-items: baseline;
gap: 10px;
}
.search {
display: flex;
gap: 8px;
}
.results,
.list {
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
overflow: hidden;
}
.results {
margin-top: -4px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
.result,
.item {
min-height: 38px;
box-sizing: border-box;
padding: 0 12px;
display: flex;
align-items: center;
gap: 10px;
}
.result + .result,
.item + .item {
border-top: 1px solid v-bind("theme.dividerColor");
}
.item {
cursor: grab;
background: v-bind("theme.cardColor");
}
.item.dragging {
opacity: 0.5;
}
.grip {
color: v-bind("theme.textColor3");
letter-spacing: -3px;
}
.empty {
padding: 18px 0;
}
</style>
+14 -9
View File
@@ -4,6 +4,8 @@ import {
type Contest, type Contest,
type ContestAccess, type ContestAccess,
type ContestList, type ContestList,
type ContestClassView,
type ContestScoreboard,
type ActivityRankItem, type ActivityRankItem,
type FormatCodeResponse, type FormatCodeResponse,
type Metrics, type Metrics,
@@ -52,7 +54,6 @@ import { toProblemRow } from "oj/transforms"
import type { import type {
Announcement, Announcement,
AnnouncementListItem, AnnouncementListItem,
ContestRank,
Profile, Profile,
Message, Message,
SubmissionListItem, SubmissionListItem,
@@ -303,9 +304,14 @@ export function getContestList(query: {
limit: number limit: number
keyword: string keyword: string
status: string status: string
/** 练习 / 期中 / 期末,或「考试」= 期中和期末 */
tag: string tag: string
/** 只看我交过题的 */
joined?: boolean
}) { }) {
return api.get<ContestList>("contests", { params: query }) return api.get<ContestList>("contests", {
params: { ...query, joined: query.joined ? "1" : undefined },
})
} }
export function getContest(id: string) { export function getContest(id: string) {
@@ -328,13 +334,12 @@ export async function getContestProblems(contestID: string) {
return res.map(toProblemRow) return res.map(toProblemRow)
} }
export function getContestRank(contestID: string, query: { limit: number; offset: number }) { export function getContestScoreboard(contestID: string) {
// submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文), return api.get<ContestScoreboard>(`contests/${encodeURIComponent(contestID)}/scoreboard`)
// 前端在这里收窄成 ContestSubmissionInfo,见 utils/types 的 ContestRank }
return api.get<{ results: ContestRank[]; total: number }>(
`contests/${encodeURIComponent(contestID)}/rank`, export function getContestClassView(contestID: string) {
{ params: query }, return api.get<ContestClassView>(`contests/${encodeURIComponent(contestID)}/class-view`)
)
} }
export function uploadAvatar(file: File) { export function uploadAvatar(file: File) {
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { isExamTag } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { useTone } from "oj/submission/composables/tone"
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
/**
* 比赛的两个小标:状态(进行中 / 未开始 / 已结束)和类型(练习 / 期中 / 期末)。
* 进行中绿、未开始琥珀、已结束灰;期中期末用浅红底 —— 学生一眼知道「这场是考试,排名考完才有」
*/
const props = defineProps<{ status?: ContestStatus; tag?: string }>()
const theme = useThemeVars()
const tone = useTone()
const style = computed(() => {
if (props.status !== undefined) {
const kind =
props.status === ContestStatus.underway
? "success"
: props.status === ContestStatus.not_started
? "warning"
: "default"
const t = tone(kind)
return { color: t.color, background: t.background, border: "1px solid transparent" }
}
if (props.tag && isExamTag(props.tag)) {
const t = tone("error")
return { color: t.color, background: t.background, border: "1px solid transparent" }
}
return {
color: theme.value.textColor3,
background: "transparent",
border: `1px solid ${theme.value.borderColor}`,
}
})
const text = computed(() =>
props.status !== undefined ? CONTEST_STATUS[props.status]?.name : props.tag,
)
</script>
<template>
<span class="badge" :class="{ strong: status === ContestStatus.underway }" :style="style">{{
text
}}</span>
</template>
<style scoped>
.badge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
box-sizing: border-box;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
flex-shrink: 0;
}
.strong {
font-weight: 600;
}
</style>
@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useContestStore } from "oj/store/contest" import { useContestStore } from "oj/store/contest"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import ContestAccessTag from "shared/components/ContestAccessTag.vue" import ContestAccessTag from "shared/components/ContestAccessTag.vue"
@@ -10,12 +9,7 @@ const contestStore = useContestStore()
<template> <template>
<n-popover v-if="contestStore.contest" placement="bottom-end" :show-arrow="false"> <n-popover v-if="contestStore.contest" placement="bottom-end" :show-arrow="false">
<template #trigger> <template #trigger>
<n-button> <n-button>比赛信息</n-button>
<template #icon>
<Icon icon="streamline-emojis:exclamation-mark"></Icon>
</template>
比赛信息
</n-button>
</template> </template>
<div v-html="contestStore.contest.description"></div> <div v-html="contestStore.contest.description"></div>
<n-descriptions bordered label-placement="left" :column="1"> <n-descriptions bordered label-placement="left" :column="1">
@@ -0,0 +1,132 @@
<script setup lang="ts">
import type { ContestScoreRow } from "@oj2/contract"
import { useThemeVars } from "naive-ui"
import { useTone } from "oj/submission/composables/tone"
import { secondsToDuration } from "utils/functions"
import UserName from "./UserName.vue"
/**
* 题目页右边的小榜:前三名,然后是我和我前后各两个人(中间隔一个「⋯」)。
* 我在前八里、或者没上榜(老师、还没交题),就直接列前八
*/
const props = defineProps<{ rows: ContestScoreRow[]; me?: number }>()
const theme = useThemeVars()
const tone = useTone()
const success = computed(() => tone("success"))
const danger = computed(() => tone("error"))
const shown = computed(() => {
const rows = props.rows
const index = rows.findIndex((row) => row.userId === props.me)
if (index < 8) return { head: rows.slice(0, 8), tail: [] }
return { head: rows.slice(0, 3), tail: rows.slice(index - 2, index + 3) }
})
function move(row: ContestScoreRow) {
return row.rank && row.prevRank ? row.prevRank - row.rank : 0
}
</script>
<template>
<div class="mini">
<template v-for="(group, gi) in [shown.head, shown.tail]" :key="gi">
<div v-if="gi === 1 && group.length" class="gap">⋯</div>
<div v-for="row in group" :key="row.userId" class="line" :class="{ me: row.userId === me }">
<span class="rank">{{ row.rank }}</span>
<span class="move">
<template v-if="move(row) > 0"
><span class="up">↑{{ move(row) }}</span></template
>
<template v-else-if="move(row) < 0"
><span class="down">↓{{ -move(row) }}</span></template
>
</span>
<UserName
class="who"
:username="row.username"
:class-name="row.className"
:strong="row.userId === me"
/>
<span class="solved">{{ row.solved }} 道</span>
<span class="time">{{ secondsToDuration(row.totalTime) }}</span>
</div>
</template>
</div>
</template>
<style scoped>
.mini {
display: flex;
flex-direction: column;
gap: 2px;
}
.line {
height: 30px;
box-sizing: border-box;
padding: 0 12px;
display: flex;
align-items: center;
gap: 8px;
border-radius: 4px;
background: v-bind("theme.cardColor");
}
.line.me {
background: v-bind("success.background");
}
.rank {
width: 24px;
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor2");
}
.me .rank {
color: v-bind("success.color");
}
.move {
width: 28px;
font-size: 11px;
}
.up {
color: v-bind("success.color");
}
.down {
color: v-bind("danger.color");
}
.who {
flex-grow: 1;
min-width: 0;
}
.solved {
width: 44px;
text-align: right;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.time {
width: 62px;
text-align: right;
font-size: 12px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.gap {
height: 14px;
padding-left: 44px;
font-size: 12px;
line-height: 8px;
color: v-bind("theme.textColor3");
}
</style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
/**
* 榜单上的名字:`ks241` 前缀淡一点,后面的名字是重点(和提交列表一个写法)。
* 前缀对不上班级号就整个照常显示
*/
const props = defineProps<{ username: string; className?: string | null; strong?: boolean }>()
const theme = useThemeVars()
const parts = computed(() => {
const prefix = props.className ? `ks${props.className}` : ""
if (prefix && props.username.startsWith(prefix) && props.username.length > prefix.length) {
return [prefix, props.username.slice(prefix.length)]
}
return ["", props.username]
})
</script>
<template>
<span class="user" :title="username"
><span class="prefix">{{ parts[0] }}</span
><span class="name" :class="{ strong }">{{ parts[1] }}</span></span
>
</template>
<style scoped>
.user {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prefix {
color: v-bind("theme.textColor3");
}
.name {
color: v-bind("theme.textColor1");
}
.name.strong {
font-weight: 700;
}
</style>
+233 -36
View File
@@ -1,18 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { useThemeVars } from "naive-ui"
import { CONTEST_STATUS, ContestStatus } from "utils/constants" import { useTone } from "oj/submission/composables/tone"
import { useBreakpoints } from "shared/composables/breakpoints" import { ContestStatus } from "utils/constants"
import { parseTime, secondsToDuration } from "utils/functions"
import { useContestStore } from "../store/contest" import { useContestStore } from "../store/contest"
import ContestBadge from "./components/ContestBadge.vue"
import ContestInfo from "./components/ContestInfo.vue" import ContestInfo from "./components/ContestInfo.vue"
import ContestMenu from "./components/ContestMenu.vue"
/**
* 比赛页的外壳(设计稿「比赛重设计」):一条顶栏 + 下面三个页之一。
* 顶栏左边是状态、名字、类型,右边是倒计时和去别的页的按钮:
* 学生只有「题目 / 我的提交 / 比赛信息」,排名从题目页右边的名次卡进;
* 老师是一排页签「全班情况 · 排名 · 提交 · 题目」,出题人另有「比赛设置」
*/
const props = defineProps<{ const props = defineProps<{
contestID: string contestID: string
}>() }>()
const contestStore = useContestStore() const contestStore = useContestStore()
const route = useRoute()
const message = useMessage() const message = useMessage()
const theme = useThemeVars()
const { isDesktop } = useBreakpoints() const tone = useTone()
const success = computed(() => tone("success"))
const password = ref("") const password = ref("")
@@ -21,7 +30,7 @@ async function check() {
if (error === "too-many-password-attempts") { if (error === "too-many-password-attempts") {
message.error("密码错误次数太多,请 10 分钟后再试") message.error("密码错误次数太多,请 10 分钟后再试")
} else if (!contestStore.access) { } else if (!contestStore.access) {
message.error("密码错误") message.error("密码不对")
} }
} }
@@ -40,45 +49,233 @@ onMounted(() => {
onBeforeUnmount(contestStore.clear) onBeforeUnmount(contestStore.clear)
const status = computed(() => contestStore.contestStatus)
const contest = computed(() => contestStore.contest)
const passwordFormVisible = computed( const passwordFormVisible = computed(
() => contestStore.isPrivate && !contestStore.access && !contestStore.isContestAdmin, () => contestStore.isPrivate && !contestStore.access && !contestStore.isContestAdmin,
) )
/** 学生在开始前进来:题目和榜都还拿不到,整页就是「几点开始、还有多久」 */
const waiting = computed(
() =>
status.value === ContestStatus.not_started &&
!contestStore.isContestAdmin &&
!passwordFormVisible.value,
)
const untilStart = computed(() => {
if (!contest.value) return ""
const seconds = Math.floor((Date.parse(contest.value.startTime) - contestStore.now) / 1000)
if (seconds <= 0) return ""
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} 天`
return secondsToDuration(seconds)
})
const timeRange = computed(() => {
if (!contest.value) return ""
const start = parseTime(contest.value.startTime, "M月D日 HH:mm")
const sameDay =
parseTime(contest.value.startTime, "YYYY-MM-DD") ===
parseTime(contest.value.endTime, "YYYY-MM-DD")
const end = parseTime(contest.value.endTime, sameDay ? "HH:mm" : "M月D日 HH:mm")
return `${start} – ${end}`
})
const routeName = computed(() => String(route.name ?? ""))
const teacherTabs = computed(() => [
{
name: "contest class",
label: status.value === ContestStatus.finished ? "成绩" : "全班情况",
},
{ name: "contest rank", label: "排名" },
{ name: "contest submissions", label: "提交" },
{ name: "contest problems", label: "题目" },
])
const to = (name: string) => ({ name, params: { contestID: props.contestID } })
</script> </script>
<template> <template>
<n-flex vertical size="large" v-if="contestStore.contest"> <div v-if="contest" class="contest-page">
<n-flex align="center" justify="space-between"> <header class="bar">
<n-flex align="center"> <ContestBadge :status="status" />
<n-tag :type="CONTEST_STATUS[contestStore.contestStatus]['type']"> <h2 class="title">{{ contest.title }}</h2>
{{ contestStore.countdown }} <ContestBadge :tag="contest.tag" />
</n-tag> <div class="spacer"></div>
<Icon <span v-if="status === ContestStatus.underway" class="countdown">
v-if="contestStore.isPrivate" <span class="countdown-label">还剩</span>
icon="streamline-ultimate-color:shield-lock" <span class="countdown-num">{{ contestStore.remaining }}</span>
:height="30" </span>
></Icon> <span v-else-if="status === ContestStatus.not_started" class="countdown">
<h2 class="contestTitle">{{ contestStore.contest.title }}</h2> <span class="countdown-label">{{ parseTime(contest.startTime, "M月D日 HH:mm") }} 开始</span>
<span v-if="untilStart" class="countdown-num small">还有 {{ untilStart }}</span>
</span>
<span v-else class="range">{{ timeRange }}</span>
<template v-if="contestStore.canEnter">
<i class="divider"></i>
<nav v-if="contestStore.isTeacher" class="tabs" aria-label="比赛">
<RouterLink
v-for="tab in teacherTabs"
:key="tab.name"
:to="to(tab.name)"
class="tab"
:class="{ on: routeName === tab.name }"
>{{ tab.label }}</RouterLink
>
</nav>
<template v-else>
<RouterLink v-if="routeName !== 'contest problems'" :to="to('contest problems')">
<n-button>‹ 题目</n-button>
</RouterLink>
<RouterLink
v-if="routeName !== 'contest submissions'"
:to="{ ...to('contest submissions'), query: { myself: '1' } }"
>
<n-button>我的提交</n-button>
</RouterLink>
</template>
<RouterLink
v-if="contestStore.isContestAdmin"
:to="{ name: 'admin contest edit', params: { contestID } }"
>
<n-button>比赛设置</n-button>
</RouterLink>
</template>
<ContestInfo v-if="!contestStore.isTeacher" />
</header>
<div v-if="passwordFormVisible" class="gate">
<b>这场比赛要输入密码才能进</b>
<span class="gate-sub">密码问老师</span>
<n-flex :wrap="false">
<n-input
v-model:value="password"
name="ContestPassword"
type="password"
placeholder="比赛密码"
style="width: 220px"
@keyup.enter="password && check()"
/>
<n-button type="primary" :disabled="!password" @click="check">进入</n-button>
</n-flex> </n-flex>
<n-flex align="center"> </div>
<ContestInfo /> <div v-else-if="waiting" class="gate">
<ContestMenu /> <b>比赛还没开始</b>
</n-flex> <span class="gate-sub">
</n-flex> {{ parseTime(contest.startTime, "M月D日 HH:mm") }} 开始,到时候题目会自己出来,不用刷新
<n-form :inline="isDesktop" label-placement="left" v-if="passwordFormVisible"> </span>
<n-form-item label="需要输入密码才能看到题目"> <span v-if="untilStart" class="gate-count">{{ untilStart }}</span>
<n-input name="ContestPassword" type="password" v-model:value="password" /> </div>
</n-form-item> <router-view v-else></router-view>
<n-form-item> </div>
<n-button @click="check" :disabled="!password">确认</n-button>
</n-form-item>
</n-form>
<router-view></router-view>
</n-flex>
</template> </template>
<style scoped> <style scoped>
.contestTitle { .contest-page {
font-weight: 500; display: flex;
flex-direction: column;
margin: -16px -16px 0;
}
.bar {
min-height: 60px;
box-sizing: border-box;
padding: 10px 20px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.title {
margin: 0; margin: 0;
font-size: 18px;
font-weight: 700;
min-width: 0;
}
.spacer {
flex-grow: 1;
}
.countdown {
display: flex;
align-items: baseline;
gap: 6px;
color: v-bind("theme.warningColorPressed");
}
.countdown-label {
font-size: 13px;
}
.countdown-num {
font-size: 24px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.countdown-num.small {
font-size: 16px;
}
.range {
font-size: 13px;
color: v-bind("theme.textColor3");
}
.divider {
width: 1px;
height: 24px;
margin: 0 4px;
background: v-bind("theme.dividerColor");
}
.tabs {
display: flex;
border: 1px solid v-bind("theme.borderColor");
border-radius: 4px;
overflow: hidden;
}
.tab {
height: 32px;
padding: 0 14px;
display: flex;
align-items: center;
font-size: 13px;
color: v-bind("theme.textColor2");
text-decoration: none;
}
.tab + .tab {
border-left: 1px solid v-bind("theme.borderColor");
}
.tab.on {
background: v-bind("success.background");
color: v-bind("success.color");
font-weight: 600;
}
.gate {
padding: 80px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-size: 16px;
}
.gate-sub {
font-size: 14px;
color: v-bind("theme.textColor3");
}
.gate-count {
font-size: 40px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: v-bind("theme.warningColorPressed");
} }
</style> </style>
+397 -175
View File
@@ -1,247 +1,469 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import { NTag } from "naive-ui" import { useThemeVars } from "naive-ui"
import { getContestList } from "oj/api" import { getContestList } from "oj/api"
import { duration, parseTime } from "utils/functions" import { useTone } from "oj/submission/composables/tone"
import { contestLength, parseTime, secondsToDuration } from "utils/functions"
import type { Contest } from "utils/types" import type { Contest } from "utils/types"
import ContestTitle from "shared/components/ContestTitle.vue" import ContestTitle from "shared/components/ContestTitle.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { useAuthModalStore } from "shared/store/authModal" import { useAuthModalStore } from "shared/store/authModal"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { CONTEST_STATUS, ContestStatus, ContestType } from "utils/constants" import { ContestStatus } from "utils/constants"
import ContestBadge from "./components/ContestBadge.vue"
/**
* 比赛列表(设计稿「比赛重设计」):进行中的单独一大块「接着比」,未开始的一行一个,
* 下面是全部比赛,每场写「我」第几名 / 做对几道 —— 进过前三的带奖杯
*/
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const authStore = useAuthModalStore() const authStore = useAuthModalStore()
const theme = useThemeVars()
const tone = useTone()
interface ContestQuery { interface ContestQuery {
keyword: string keyword: string
status: string /** "" 全部 / joined 我参加过的 / 练习 / 考试 */
tag: string scope: string
} }
// 使用分页 composable const { query } = usePagination<ContestQuery>({
const { query, clearQuery } = usePagination<ContestQuery>({
keyword: useRouteQuery("keyword", "").value, keyword: useRouteQuery("keyword", "").value,
status: useRouteQuery("status", "").value, scope: useRouteQuery("scope", "").value,
tag: useRouteQuery("tag", "").value,
}) })
const data = ref<Contest[]>([]) const data = ref<Contest[]>([])
const total = ref(0) const total = ref(0)
const active = ref<Contest[]>([])
const options: SelectOption[] = [ const scopes = computed(() => [
{ label: "全部", value: "" }, { value: "", label: "全部" },
{ label: "未开始", value: "1" }, ...(userStore.isAuthed ? [{ value: "joined", label: "我参加过的" }] : []),
{ label: "进行中", value: "0" }, { value: "练习", label: "练习" },
{ label: "已结束", value: "-1" }, { value: "考试", label: "期中 · 期末" },
] ])
const tags: SelectOption[] = [
{ label: "全部", value: "" },
{ label: "练习", value: "练习" },
{ label: "期中", value: "期中" },
{ label: "期末", value: "期末" },
]
const columns: DataTableColumn<Contest>[] = [
{
title: "状态",
key: "status",
width: 100,
render: (row) =>
h(
NTag,
{ type: CONTEST_STATUS[row.status]["type"] },
() => CONTEST_STATUS[row.status]["name"],
),
},
{
title: "比赛",
key: "title",
minWidth: 360,
render: (row) => h(ContestTitle, { contest: row }),
},
{
title: "标签",
key: "tag",
width: 100,
render: (row) => h(NTag, () => row.tag),
},
{
title: "开始时间",
key: "start_time",
width: 180,
render: (row) => parseTime(row.startTime),
},
{
title: "比赛时长",
key: "duration",
width: 180,
render: (row) => duration(row.startTime, row.endTime),
},
]
async function listContests() { async function listContests() {
const offset = (query.page - 1) * query.limit
const res = await getContestList({ const res = await getContestList({
offset, offset: (query.page - 1) * query.limit,
limit: query.limit, limit: query.limit,
keyword: query.keyword, keyword: query.keyword,
status: query.status, status: "",
tag: query.tag, tag: query.scope === "joined" ? "" : query.scope,
joined: query.scope === "joined",
}) })
data.value = res.results data.value = res.results
total.value = res.total total.value = res.total
} }
function search(value: string) { // 进行中 / 即将开始的单独拎到最上面:表格按开始时间倒序,一场开了一周的练习赛
query.keyword = value // 会被后来建的比赛压到第二页去,学生找不着正在比的那一场
}
function clear() {
clearQuery()
}
onMounted(listContests)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listContests, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.status, query.tag], listContests)
function openContest(row: Contest) {
if (!userStore.isAuthed && row.contestType === ContestType.private) {
authStore.openLoginModal()
} else {
router.push("/contest/" + row.id)
}
}
function rowProps(row: Contest) {
return {
style: "cursor: pointer",
onClick: () => openContest(row),
}
}
// 进行中 / 即将开始的比赛单独拎到最上面:表格按开始时间倒序,一场开了一周的
// 练习赛会被后来建的比赛压到第二页去,学生找不着正在比的那一场
const active = ref<Contest[]>([])
const showActive = computed(
() =>
active.value.length > 0 && query.page === 1 && !query.keyword && !query.status && !query.tag,
)
async function listActive() { async function listActive() {
const base = { offset: 0, limit: 6, keyword: "", tag: "" } const base = { offset: 0, limit: 6, keyword: "", tag: "" }
const [underway, upcoming] = await Promise.all([ const [underway, upcoming] = await Promise.all([
getContestList({ ...base, status: ContestStatus.underway }), getContestList({ ...base, status: ContestStatus.underway }),
getContestList({ ...base, status: ContestStatus.not_started }), getContestList({ ...base, status: ContestStatus.not_started }),
]) ])
active.value = [...underway.results, ...upcoming.results].slice(0, 6) active.value = [...underway.results, ...upcoming.results.reverse()]
} }
onMounted(listActive) const plain = computed(() => query.page === 1 && !query.keyword && !query.scope)
const running = computed(() =>
plain.value ? active.value.filter((c) => c.status === ContestStatus.underway) : [],
)
const upcoming = computed(() =>
plain.value ? active.value.filter((c) => c.status === ContestStatus.not_started) : [],
)
/** 上面单独列过的,下面表里不再重复 */
const rows = computed(() => {
const shown = new Set([...running.value, ...upcoming.value].map((c) => c.id))
return data.value.filter((c) => !shown.has(c.id))
})
function activeTime(row: Contest) { onMounted(() => {
if (row.status === ContestStatus.underway) { listContests()
return `${parseTime(row.endTime, "M月D日 HH:mm")} 结束` listActive()
})
watchDebounced(() => query.keyword, listContests, { debounce: 500, maxWait: 1000 })
watch(() => [query.page, query.limit, query.scope], listContests)
const now = useNow({ interval: 1000 })
function remaining(contest: Contest) {
const seconds = Math.floor((Date.parse(contest.endTime) - now.value.getTime()) / 1000)
const text = secondsToDuration(Math.max(0, seconds))
return text.startsWith("0:") ? text.slice(2) : text
}
function untilStart(contest: Contest) {
const ms = Date.parse(contest.startTime) - now.value.getTime()
const days = Math.floor(ms / 86_400_000)
if (days >= 1) return `还有 ${days} 天`
return `还有 ${secondsToDuration(Math.max(0, Math.floor(ms / 1000)))}`
}
function range(contest: Contest) {
const sameDay =
parseTime(contest.startTime, "YYYY-MM-DD") === parseTime(contest.endTime, "YYYY-MM-DD")
return `${parseTime(contest.startTime, "HH:mm")} – ${parseTime(contest.endTime, sameDay ? "HH:mm" : "M月D日 HH:mm")}`
}
function open(contest: Contest) {
if (!userStore.isAuthed) {
authStore.openLoginModal()
return
} }
return `${parseTime(row.startTime, "M月D日 HH:mm")} 开始` // 老师直接进「全班情况」
router.push(
userStore.isTeacherOrAbove ? `/contest/${contest.id}/class` : `/contest/${contest.id}`,
)
} }
function mineText(contest: Contest) {
const mine = contest.mine
if (!mine) return ""
const solved = `做对 ${mine.solved}/${contest.problemCount ?? "?"}`
return mine.rank ? `第 ${mine.rank} 名 / ${mine.total} · ${solved}` : solved
}
const success = computed(() => tone("success"))
const warning = computed(() => tone("warning"))
</script> </script>
<template> <template>
<n-flex vertical size="large"> <div class="page">
<n-space> <div class="top">
<n-form :show-feedback="false" label-placement="left" inline> <h2>比赛</h2>
<n-form-item label="比赛状态"> <div class="spacer"></div>
<n-select style="width: 120px" :options="options" v-model:value="query.status" /> <button
</n-form-item> v-for="item in scopes"
<n-form-item label="标签"> :key="item.value"
<n-select style="width: 120px" :options="tags" v-model:value="query.tag" /> class="chip"
</n-form-item> :class="{ on: query.scope === item.value }"
</n-form> @click="query.scope = item.value"
<n-form :show-feedback="false" label-placement="left" inline>
<n-form-item>
<n-input
style="width: 180px"
clearable
v-model:value="query.keyword"
placeholder="比赛标题"
/>
</n-form-item>
<n-form-item>
<n-flex :wrap="false">
<n-button @click="search(query.keyword)">搜索</n-button>
<n-button @click="clear" quaternary>重置</n-button>
</n-flex>
</n-form-item>
</n-form>
</n-space>
<div v-if="showActive" class="active-grid">
<div
v-for="contest in active"
:key="contest.id"
class="active-card"
:class="{ underway: contest.status === ContestStatus.underway }"
@click="openContest(contest)"
> >
<n-flex align="center" :size="8"> {{ item.label }}
<n-tag :type="CONTEST_STATUS[contest.status].type" size="small" :bordered="false"> </button>
{{ CONTEST_STATUS[contest.status].name }} <n-input v-model:value="query.keyword" clearable placeholder="比赛名" style="width: 200px" />
</n-tag> </div>
<n-text depth="3" class="active-meta">{{ contest.tag }}</n-text>
</n-flex> <div
<ContestTitle :contest="contest" class="active-title" /> v-for="contest in running"
<n-text depth="3" class="active-meta"> :key="contest.id"
{{ activeTime(contest) }} · 时长 {{ duration(contest.startTime, contest.endTime) }} class="running"
</n-text> role="link"
tabindex="0"
@click="open(contest)"
@keyup.enter="open(contest)"
>
<ContestBadge :status="ContestStatus.underway" />
<div class="running-main">
<div class="running-title">
<ContestTitle :contest="contest" />
<ContestBadge :tag="contest.tag" />
</div>
<span class="muted small">
{{ range(contest) }} · {{ contest.problemCount }} 道题 ·
{{ contest.participantCount }} 人在比
</span>
</div>
<div class="spacer"></div>
<div class="running-right">
<span v-if="contest.mine" class="small sec">
<template v-if="contest.mine.rank"
>你第 <b class="green">{{ contest.mine.rank }}</b> 名 · </template
>做对 {{ contest.mine.solved }} 道
</span>
<span class="countdown"
><span class="small">还剩</span> <b>{{ remaining(contest) }}</b></span
>
</div>
<n-button type="primary" size="large" @click.stop="open(contest)">
{{ contest.mine ? "接着比 ›" : "进去比 ›" }}
</n-button>
</div>
<div
v-for="contest in upcoming"
:key="contest.id"
class="upcoming"
role="link"
tabindex="0"
@click="open(contest)"
@keyup.enter="open(contest)"
>
<ContestBadge :status="ContestStatus.not_started" />
<ContestTitle :contest="contest" class="upcoming-title" />
<ContestBadge :tag="contest.tag" />
<div class="spacer"></div>
<span class="small sec">
{{ parseTime(contest.startTime, "M月D日 HH:mm") }} 开始 · 比
{{ contestLength(contest.startTime, contest.endTime) }}
</span>
<span class="small amber">{{ untilStart(contest) }}</span>
</div>
<div class="table">
<div class="tr th">
<span>{{ plain ? "已结束" : "比赛" }}</span>
<span>类型</span>
<span>日期</span>
<span>时长</span>
<span>我</span>
</div>
<div
v-for="contest in rows"
:key="contest.id"
class="tr"
:class="{ done: contest.status === ContestStatus.finished }"
role="link"
tabindex="0"
@click="open(contest)"
@keyup.enter="open(contest)"
>
<span class="name">
<ContestBadge
v-if="contest.status !== ContestStatus.finished"
:status="contest.status as ContestStatus"
/>
<ContestTitle :contest="contest" />
</span>
<span><ContestBadge :tag="contest.tag" /></span>
<span class="muted small num">{{ parseTime(contest.startTime, "YYYY-MM-DD") }}</span>
<span class="muted small">{{ contestLength(contest.startTime, contest.endTime) }}</span>
<span class="mine small">
<template v-if="contest.mine">
<svg
v-if="contest.mine.rank && contest.mine.rank <= 3"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="cup"
>
<path d="M8 4h8v5a4 4 0 0 1-8 0z" />
<path d="M16 6h3v1a3 3 0 0 1-3 3" />
<path d="M8 6H5v1a3 3 0 0 0 3 3" />
<path d="M12 13v4" />
<path d="M8 20h8" />
</svg>
<span class="sec">{{ mineText(contest) }}</span>
</template>
<span
v-else-if="userStore.isAuthed && contest.status === ContestStatus.finished"
class="faint"
>没参加</span
>
</span>
</div>
<div v-if="!rows.length" class="empty muted">
{{ query.scope === "joined" ? "你还没参加过比赛" : "没有符合的比赛" }}
</div> </div>
</div> </div>
<n-data-table :bordered="false" :columns="columns" :data="data" :row-props="rowProps" /> </div>
</n-flex>
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" /> <Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
</template> </template>
<style scoped> <style scoped>
.active-grid { .page {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); flex-direction: column;
gap: 12px; gap: 12px;
} }
.active-card { .top {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.top h2 {
margin: 0;
font-size: 20px;
}
.spacer {
flex-grow: 1;
}
.muted {
color: v-bind("theme.textColor3");
}
.faint {
color: v-bind("theme.textColor3");
opacity: 0.7;
}
.sec {
color: v-bind("theme.textColor2");
}
.small {
font-size: 13px;
}
.num {
font-variant-numeric: tabular-nums;
}
.green {
color: v-bind("success.color");
}
.amber {
color: v-bind("warning.color");
}
.chip {
height: 28px;
padding: 0 12px;
border-radius: 14px;
border: 1px solid v-bind("theme.borderColor");
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("theme.textColor2");
cursor: pointer;
}
.chip.on {
border-color: transparent;
background: v-bind("success.background");
color: v-bind("success.color");
font-weight: 600;
}
.running {
padding: 16px 20px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px 20px;
border-radius: 6px;
border: 1px solid v-bind("theme.successColor");
background: v-bind("success.background");
cursor: pointer;
}
.running-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px;
min-width: 0;
}
.running-title {
display: flex;
align-items: center;
gap: 8px; gap: 8px;
padding: 14px 16px; font-size: 20px;
border-radius: 8px; font-weight: 700;
border: 1px solid rgba(128, 128, 128, 0.2); }
.running-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.countdown {
color: v-bind("warning.color");
}
.countdown b {
font-size: 22px;
font-variant-numeric: tabular-nums;
}
.upcoming {
padding: 12px 20px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 16px;
border-radius: 6px;
border: 1px solid v-bind("theme.borderColor");
cursor: pointer; cursor: pointer;
transition: border-color 0.2s;
} }
.active-card.underway { .upcoming-title {
border-color: rgba(24, 160, 88, 0.5); font-size: 15px;
background-color: rgba(24, 160, 88, 0.06); font-weight: 600;
} }
.active-card:hover { .table {
border-color: #18a058; border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
overflow: hidden;
} }
.active-title { .tr {
font-size: 16px; display: grid;
font-weight: 500; grid-template-columns: minmax(200px, 1fr) 70px 110px 130px minmax(160px, 260px);
align-items: center;
gap: 14px;
min-height: 38px;
padding: 0 20px;
border-bottom: 1px solid v-bind("theme.dividerColor");
cursor: pointer;
} }
.active-meta { .tr:last-child {
border-bottom: 0;
}
.tr:not(.th):hover {
background: v-bind("theme.hoverColor");
}
.th {
min-height: 32px;
font-size: 12px; font-size: 12px;
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
cursor: default;
}
.name {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.tr.done .name {
color: v-bind("theme.textColor2");
}
.mine {
display: flex;
align-items: center;
gap: 4px;
}
.cup {
color: #b07d00;
flex-shrink: 0;
}
.empty {
padding: 24px 20px;
}
@media (max-width: 760px) {
.tr {
grid-template-columns: 1fr auto;
}
.tr > span:nth-child(3),
.tr > span:nth-child(4) {
display: none;
}
} }
</style> </style>
File diff suppressed because it is too large. Load diff
+540 -47
View File
@@ -1,60 +1,553 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ProblemRow } from "utils/types" import type { ContestScoreRow } from "@oj2/contract"
import ProblemStatus from "oj/problem/components/ProblemStatus.vue" import { useThemeVars } from "naive-ui"
import { useContestStore } from "oj/store/contest" import { useContestStore } from "oj/store/contest"
import { useTone } from "oj/submission/composables/tone"
import { useUserStore } from "shared/store/user"
import { ContestStatus } from "utils/constants"
import { parseTime, secondsToDuration } from "utils/functions"
import RankMini from "../components/RankMini.vue"
/**
* 比赛页「题目」(设计稿「比赛重设计」B,用户选的):一页放下。
* 左边是题目,每题带几个人做对了、谁最先做对 —— 练习就是要比;
* 右边是「我」现在第几名、5 分钟里升了几名、再做对 1 道能到第几名、我前后的人。
* 期中期末进行中右边只剩自己的进度,排名和人数考完公布(后端也不给)
*/
const props = defineProps<{ contestID: string }>() const props = defineProps<{ contestID: string }>()
const router = useRouter()
const contestStore = useContestStore() const contestStore = useContestStore()
const problemsColumns: DataTableColumn<ProblemRow>[] = [ const userStore = useUserStore()
{ const theme = useThemeVars()
title: "状态", const tone = useTone()
key: "status",
width: 100,
render: (row) => h(ProblemStatus, { status: row.status }),
},
{
title: "编号",
key: "_id",
width: 100,
},
{
title: "题目",
key: "title",
minWidth: 200,
},
{
title: "提交数",
key: "submissionCount",
align: "center",
width: 120,
},
{
title: "通过率",
key: "acRate",
align: "center",
width: 120,
},
]
function rowProps(row: ProblemRow) { const running = computed(() => contestStore.contestStatus === ContestStatus.underway)
return { const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
style: "cursor: pointer",
onClick() { const { pause, resume } = useIntervalFn(
router.push(`/contest/${props.contestID}/problem/${row._id}`) () => contestStore.loadScoreboard(props.contestID),
}, 10_000,
} {
immediate: false,
},
)
watch(
() => [contestStore.canEnter, contestStore.contestStatus] as const,
([enter, status]) => {
if (!enter) return
contestStore.loadScoreboard(props.contestID)
if (status === ContestStatus.underway) resume()
else pause()
},
{ immediate: true },
)
const board = computed(() => contestStore.scoreboard)
const rows = computed(() => board.value?.rows ?? [])
const total = computed(() => rows.value.length)
const me = computed<ContestScoreRow | undefined>(() =>
rows.value.find((row) => row.userId === userStore.user?.id),
)
/** 排名页、题目条都按题目 id 找格子;题目列表那边是 contestStore.problems(带我的状态) */
const stats = computed(() => new Map((board.value?.problems ?? []).map((p) => [p.id, p])))
function when(seconds: number) {
const minute = Math.floor(seconds / 60)
return minute === 0 ? "1 分钟内做对" : `第 ${minute} 分钟做对`
} }
const items = computed(() =>
contestStore.problems.map((problem) => {
const cell = me.value?.cells[String(problem.id)]
const stat = stats.value.get(problem.id)
let mine = ""
if (cell?.isAc) mine = when(cell.acTime) + (cell.errors ? `,错 ${cell.errors} 次` : "")
else if (cell) mine = cell.errors ? `交了 ${cell.errors} 次,还没对` : "交过,还没对"
else if (problem.status === "failed") mine = "交过,还没对"
return {
problem,
done: cell?.isAc || problem.status === "passed",
tried: !!cell || problem.status === "failed",
mine,
solved: stat?.solvedUsers ?? 0,
first: stat?.firstSolver ?? null,
}
}),
)
const solvedCount = computed(() => items.value.filter((item) => item.done).length)
/** 现在再做对一道,按此刻算用时,能排到第几 */
const nextRank = computed(() => {
if (!running.value) return null
const elapsed = Math.max(
0,
Math.floor((contestStore.now - Date.parse(contestStore.contest!.startTime)) / 1000),
)
const solved = (me.value?.solved ?? 0) + 1
if (solved > contestStore.problems.length) return null
const time = (me.value?.totalTime ?? 0) + elapsed
const ahead = rows.value.filter(
(row) =>
row.userId !== userStore.user?.id &&
(row.solved > solved || (row.solved === solved && row.totalTime < time)),
).length
const target = ahead + 1
return me.value?.rank && target >= me.value.rank ? null : target
})
const move = computed(() => {
const row = me.value
if (!row?.rank || !row.prevRank || !running.value) return 0
return row.prevRank - row.rank
})
const updatedAgo = computed(() => {
if (!contestStore.scoreboardAt) return ""
const seconds = Math.max(0, Math.floor((contestStore.now - contestStore.scoreboardAt) / 1000))
return seconds < 5 ? "刚刚更新" : `${seconds} 秒前更新`
})
function firstMinute(seconds: number) {
const minute = Math.floor(seconds / 60)
return minute === 0 ? "不到 1 分" : `${minute} 分`
}
const examLeft = computed(() =>
items.value.filter((item) => !item.done).map((item) => `第 ${item.problem._id} 题`),
)
const to = (name: string) => ({ name, params: { contestID: props.contestID } })
const success = computed(() => tone("success"))
const warning = computed(() => tone("warning"))
</script> </script>
<template> <template>
<n-data-table <div class="layout">
striped <section class="problems">
:data="contestStore.problems" <div class="head">
:columns="problemsColumns" <b>题目</b>
:row-props="rowProps" <span class="muted">
/> {{ contestStore.problems.length }} 道<template v-if="me || solvedCount">
· 你做对 {{ solvedCount }} 道</template
>
</span>
<div class="spacer"></div>
<span v-if="running && !contestStore.rankHidden" class="muted small">做对人数随时更新</span>
</div>
<RouterLink
v-for="item in items"
:key="item.problem.id"
:to="`/contest/${contestID}/problem/${item.problem._id}`"
class="row"
>
<span v-if="item.done" class="dot done">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12l5 5 9-10" />
</svg>
</span>
<span v-else-if="item.tried" class="dot tried" title="交过,还没对">!</span>
<span v-else class="dot"></span>
<span class="pid">{{ item.problem._id }}</span>
<span class="name">{{ item.problem.title }}</span>
<span class="mine" :class="{ warn: item.tried && !item.done }">{{ item.mine }}</span>
<template v-if="!contestStore.rankHidden && board">
<span class="count">
<span class="track"
><span
class="fill"
:style="{ width: total ? `${(item.solved / total) * 100}%` : '0' }"
></span
></span>
<span class="count-num">{{ item.solved }} 人做对</span>
</span>
<span class="first">
<template v-if="item.first"
>最先:{{ item.first.username }} · {{ firstMinute(item.first.acTime) }}</template
>
<template v-else>还没人做对</template>
</span>
</template>
</RouterLink>
</section>
<aside class="side">
<!-- 期中期末进行中:只有自己的进度 -->
<template v-if="contestStore.rankHidden">
<div class="card">
<div class="big-line">
<span class="muted small">你做对</span>
<span class="big">{{ solvedCount }}</span>
<span class="muted">/ {{ items.length }} 道</span>
</div>
<div class="segments">
<span v-for="item in items" :key="item.problem.id" :class="{ on: item.done }"></span>
</div>
<div v-if="examLeft.length" class="small sec">还没做:{{ examLeft.join("、") }}</div>
<div v-else class="small sec">全做对了,可以再检查一遍</div>
</div>
<div class="card lock">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="muted"
>
<rect x="5" y="11" width="14" height="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</svg>
<div class="lock-text">
<b>{{ contestStore.contest?.tag }}考试,排名考完公布</b>
<span class="muted small">
{{ parseTime(contestStore.contest!.endTime, "HH:mm") }}
结束后这里会出现排名,也能看到每道题有多少人做对。
</span>
</div>
</div>
</template>
<template v-else-if="board">
<div v-if="me && me.rank" class="card">
<div class="big-line">
<span class="muted small">{{ ended ? "最后" : "你现在" }}</span>
<span class="big">第 {{ me.rank }} 名</span>
<span class="muted">/ {{ total }} 人</span>
<div class="spacer"></div>
<span v-if="move > 0" class="move up">5 分钟里上升 {{ move }} 名</span>
<span v-else-if="move < 0" class="move down">5 分钟里下降 {{ -move }} 名</span>
</div>
<div class="small sec">
做对 <b>{{ me.solved }}</b> 道 · 用时 {{ secondsToDuration(me.totalTime) }}
</div>
<div v-if="nextRank" class="nudge">
再做对 1 道,就能到<b>第 {{ nextRank }} 名</b>
</div>
<div v-else-if="running && me.rank === 1" class="nudge">你现在第一,守住</div>
</div>
<div v-else-if="running && !contestStore.isTeacher" class="card">
<b>你还没交题</b>
<div v-if="nextRank" class="nudge">
做对 1 道,就能到<b>第 {{ nextRank }} 名</b>
</div>
</div>
<div class="mini">
<div class="mini-head">
<b>排名</b>
<span v-if="running" class="muted small">{{ updatedAgo }}</span>
<div class="spacer"></div>
<RouterLink :to="to('contest rank')" class="link">完整排名 ›</RouterLink>
</div>
<RankMini :rows="rows" :me="me?.userId" />
<div v-if="!rows.length" class="muted small empty">还没有人交题</div>
</div>
<div class="muted small rule">
先比做对几道;一样多的,比用时:每道题做对时是第几分钟,加起来;错一次多算 20 分钟。
</div>
</template>
</aside>
</div>
</template> </template>
<style scoped></style> <style scoped>
.layout {
display: flex;
flex-wrap: wrap;
min-height: calc(100vh - 140px);
}
.problems {
flex: 999 1 560px;
min-width: 0;
border-right: 1px solid v-bind("theme.dividerColor");
}
.side {
flex: 1 1 400px;
max-width: 100%;
box-sizing: border-box;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 14px;
background: v-bind("theme.actionColor");
}
@media (min-width: 1100px) {
.side {
flex: 0 0 470px;
}
}
.head {
height: 40px;
box-sizing: border-box;
padding: 0 16px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.actionColor");
}
.spacer {
flex-grow: 1;
}
.muted {
color: v-bind("theme.textColor3");
}
.sec {
color: v-bind("theme.textColor2");
}
.small {
font-size: 13px;
}
.row {
min-height: 34px;
box-sizing: border-box;
padding: 4px 16px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid v-bind("theme.dividerColor");
color: v-bind("theme.textColor1");
text-decoration: none;
}
.row:hover {
background: v-bind("theme.hoverColor");
}
.dot {
width: 20px;
height: 20px;
flex-shrink: 0;
box-sizing: border-box;
border-radius: 50%;
border: 1.5px dashed v-bind("theme.borderColor");
display: inline-flex;
align-items: center;
justify-content: center;
}
.dot.done {
border: 0;
background: v-bind("theme.successColor");
color: #ffffff;
}
.dot.tried {
border: 0;
background: v-bind("warning.background");
color: v-bind("warning.color");
font-size: 13px;
font-weight: 700;
}
.pid {
width: 22px;
text-align: right;
flex-shrink: 0;
font-size: 13px;
color: v-bind("theme.textColor3");
font-variant-numeric: tabular-nums;
}
.name {
flex-grow: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mine {
flex-shrink: 0;
font-size: 12px;
color: v-bind("theme.textColor3");
}
.mine.warn {
color: v-bind("warning.color");
}
.count {
width: 150px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
}
.track {
width: 64px;
height: 6px;
border-radius: 3px;
overflow: hidden;
display: flex;
background: v-bind("theme.dividerColor");
}
.fill {
background: v-bind("theme.successColor");
opacity: 0.6;
}
.count-num {
font-size: 13px;
color: v-bind("theme.textColor2");
font-variant-numeric: tabular-nums;
}
.first {
width: 180px;
flex-shrink: 0;
font-size: 12px;
color: v-bind("theme.textColor3");
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid v-bind("theme.borderColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
}
.big-line {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
}
.big {
font-size: 32px;
font-weight: 800;
line-height: 1;
color: v-bind("success.color");
}
.move {
align-self: center;
height: 22px;
padding: 0 8px;
border-radius: 11px;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
}
.move.up {
background: v-bind("success.background");
color: v-bind("success.color");
}
.move.down {
background: v-bind("theme.actionColor");
color: v-bind("theme.textColor3");
}
.nudge {
padding: 8px 10px;
border-radius: 4px;
font-size: 13px;
background: v-bind("warning.background");
color: v-bind("warning.color");
}
.segments {
display: flex;
gap: 4px;
}
.segments span {
flex-grow: 1;
height: 10px;
border-radius: 2px;
background: v-bind("theme.dividerColor");
}
.segments span.on {
background: v-bind("theme.successColor");
}
.lock {
flex-direction: row;
align-items: flex-start;
gap: 12px;
}
.lock-text {
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.6;
}
.mini {
display: flex;
flex-direction: column;
gap: 2px;
}
.mini-head {
display: flex;
align-items: center;
gap: 8px;
padding: 0 2px 6px;
}
.link {
font-size: 13px;
color: v-bind("theme.primaryColor");
text-decoration: none;
}
.empty {
padding: 8px 2px;
}
.rule {
line-height: 1.6;
font-size: 12px;
}
/* 手机:题目名要留得下,人数和「最先」只在右边的排名里看 */
@media (max-width: 760px) {
.count,
.first {
display: none;
}
.mine {
max-width: 45%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
</style>
+564 -306
View File
@@ -1,334 +1,592 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import type { ContestScoreCell, ContestScoreRow } from "@oj2/contract"
import { NButton, useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { getContestProblems, getContestRank } from "oj/api"
import { secondsToDuration } from "utils/functions"
import { useContestStore } from "oj/store/contest" import { useContestStore } from "oj/store/contest"
import Pagination from "shared/components/Pagination.vue" import { useTone } from "oj/submission/composables/tone"
import { usePagination } from "shared/composables/pagination" import { useUserStore } from "shared/store/user"
import { ContestStatus } from "utils/constants" import { ContestStatus } from "utils/constants"
import type { ContestRank, ProblemRow } from "utils/types" import { secondsToDuration } from "utils/functions"
import AcAndSubmission from "../components/AcAndSubmission.vue" import { classLabel } from "oj/submission/utils"
import LineChart from "../components/LineChart.vue" import UserName from "../components/UserName.vue"
interface Props { /**
contestID: string * 比赛排名(设计稿「比赛重设计」排行榜两块)。竞争感是用户要的(「我需要竞争」):
} * 名次变化箭头、这题最先做对的深绿格、按班筛、自己那行一直看得见;结束后前三名上领奖台。
* 原来的前十名排名曲线不要了(用户定的),学生的「导出数据」也拿掉了 —— 导出在老师的成绩页
*/
const props = defineProps<{ contestID: string }>()
const props = defineProps<Props>()
const route = useRoute()
const router = useRouter() const router = useRouter()
const theme = useThemeVars()
const contestStore = useContestStore() const contestStore = useContestStore()
const userStore = useUserStore()
const theme = useThemeVars()
const tone = useTone()
const total = ref(0) const running = computed(() => contestStore.contestStatus === ContestStatus.underway)
const data = ref<ContestRank[]>([]) const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
const chart = ref<ContestRank[]>([])
const problems = ref<ProblemRow[]>([]) const autoRefresh = ref(true)
const [autoRefresh] = useToggle(true) const { pause, resume } = useIntervalFn(
const { resume, pause } = useIntervalFn( () => contestStore.loadScoreboard(props.contestID),
() => { 10_000,
query.page = 1
listRanks()
},
10000,
{ {
immediate: false, immediate: false,
}, },
) )
// 使用分页 composable
const { query } = usePagination({}, { defaultLimit: 50 })
const columns = ref<DataTableColumn<ContestRank>[]>([
{
title: "编号",
key: "id",
width: 80,
fixed: "left",
align: "center",
render: (_, index) => index + (query.page - 1) * query.limit + 1,
},
{
title: "用户",
key: "username",
width: 120,
fixed: "left",
align: "center",
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.user.username),
},
() => row.user.username,
),
},
{
title: "正确数/总提交",
key: "submission",
width: 140,
align: "center",
render: (row) => h(AcAndSubmission, { rank: row }),
},
{
title: "总时间",
key: "total_time",
width: 120,
align: "center",
render: (row) => secondsToDuration(row.totalTime),
},
])
async function listRanks() {
const res = await getContestRank(props.contestID, {
limit: query.limit,
offset: query.limit * (query.page - 1),
})
total.value = res.total
data.value = res.results
if (query.page === 1) {
chart.value = data.value
}
}
async function addColumns() {
try {
problems.value = await getContestProblems(props.contestID)
problems.value.map((problem) => {
columns.value.push({
align: "center",
title: () =>
h(
NButton,
{
text: true,
type: "primary",
onClick: () => {
const data = router.resolve({
name: "contest problem",
params: {
contestID: route.params.contestID,
problemID: problem._id,
},
})
window.open(data.href, "_blank")
},
},
() => problem.title,
),
render: (row) => {
if (row.submissionInfo[problem.id]) {
const status = row.submissionInfo[problem.id]
let acTime
let errorNumber
if (status.is_ac) {
acTime = h("span", secondsToDuration(status.ac_time))
}
if (status.is_first_ac) {
acTime = [
h(Icon, {
icon: "fluent-emoji:1st-place-medal",
height: 20,
width: 20,
}),
h("span", secondsToDuration(status.ac_time)),
]
}
if (status.error_number) {
errorNumber = h("span", { style: "margin: 0" }, `(-${status.error_number})`)
}
return h("div", { class: "oj-time-with-modal" }, [acTime, errorNumber])
}
},
cellProps: (row) => {
let backgroundColor = ""
let color = theme.value.textColorBase
if (row.submissionInfo[problem.id]) {
const status = row.submissionInfo[problem.id]
if (status.is_first_ac) {
backgroundColor = theme.value.primaryColor
color = theme.value.baseColor
} else if (status.is_ac) {
const success = theme.value.successColor
backgroundColor = success + "50"
color = theme.value.textColorBase
} else {
const error = theme.value.errorColor
backgroundColor = error + "50"
color = theme.value.textColorBase
}
}
return { style: { backgroundColor, color } }
},
key: problem.id,
width: 150,
ellipsis: true,
})
})
} catch (err) {
problems.value = []
}
}
// 导出弹窗
const showExportModal = ref(false)
const exportLoading = ref(false)
const exportForm = reactive({
first: 0,
second: 0,
third: 0,
})
watch( watch(
() => total.value, () => [contestStore.canEnter, running.value, autoRefresh.value] as const,
(val) => { ([enter, run, auto]) => {
if (val > 0) { if (!enter) return
exportForm.first = Math.round(val * 0.1) if (!contestStore.scoreboard) contestStore.loadScoreboard(props.contestID)
exportForm.second = Math.round(val * 0.2) if (run && auto) resume()
exportForm.third = Math.round(val * 0.3) else pause()
}
}, },
{ immediate: true },
)
watch(autoRefresh, (on) => on && contestStore.loadScoreboard(props.contestID))
const board = computed(() => contestStore.scoreboard)
const allRows = computed(() => board.value?.rows ?? [])
const problems = computed(() => board.value?.problems ?? [])
const meId = computed(() => userStore.user?.id)
const me = computed(() => allRows.value.find((row) => row.userId === meId.value))
const classFilter = ref("")
const classes = computed(() => {
const count = new Map<string, number>()
for (const row of allRows.value) {
if (row.className) count.set(row.className, (count.get(row.className) ?? 0) + 1)
}
return [...count.entries()].sort(([a], [b]) => a.localeCompare(b))
})
const rows = computed(() =>
classFilter.value
? allRows.value.filter((row) => row.className === classFilter.value)
: allRows.value,
) )
function openExportModal() { const updatedAgo = computed(() => {
if (total.value > 0) { if (!contestStore.scoreboardAt) return ""
exportForm.first = Math.round(total.value * 0.1) const seconds = Math.max(0, Math.floor((contestStore.now - contestStore.scoreboardAt) / 1000))
exportForm.second = Math.round(total.value * 0.2) return seconds < 5 ? "刚刚" : `${seconds} 秒前`
exportForm.third = Math.round(total.value * 0.3)
}
showExportModal.value = true
}
async function downloadExcel() {
exportLoading.value = true
try {
// 自己翻页凑齐全量:后端 limit 上限 250,而**超出上限不是截断、是静默回落到
// 默认的 10**(routes/helpers.ts 的 queryInteger)。原来这里传 total(或 10000)
// 想一次拉完,参赛超过 250 人时只会拿回 10 行,而下面的等级分档仍按真实总人数算 ——
// 老师拿到的是一份 10 个人、等级全错的名单,还不报错
const PAGE = 250
const allRanks: ContestRank[] = []
for (;;) {
const res = await getContestRank(props.contestID, {
limit: PAGE,
offset: allRanks.length,
})
allRanks.push(...res.results)
// 两个出口都要留:拿不满一页说明到底了;比对 total 是防着最后一页正好整除
if (res.results.length < PAGE || allRanks.length >= res.total) break
}
const rows = allRanks.map((rank, index) => {
const rank1 = index + 1
let level = ""
if (rank1 <= exportForm.first) {
level = "一等奖"
} else if (rank1 <= exportForm.first + exportForm.second) {
level = "二等奖"
} else if (rank1 <= exportForm.first + exportForm.second + exportForm.third) {
level = "三等奖"
} else {
level = "参与奖"
}
return { 用户名: rank.user.username, 等级: level }
})
const csv = "用户名,等级\n" + rows.map((r) => `${r.用户名},${r.等级}`).join("\n")
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${contestStore.contest?.title ?? "contest"}获奖情况.csv`
a.click()
URL.revokeObjectURL(url)
showExportModal.value = false
} finally {
exportLoading.value = false
}
}
// 监听分页参数变化
watch([() => query.page, () => query.limit], listRanks)
// 自动刷新只在比赛进行中有意义(开关本身也只在这一档渲染),所以由「开关 + 比赛状态」
// 一起驱动。原来只 watch(autoRefresh):开关初值就是 true、进页面不产生变化,而
// useIntervalFn 建的时候又传了 immediate: false,于是表从没启动过 —— 开关明明是开着的,
// 排名却一直不刷新,得手动关一次再开。
watchEffect(() => {
const running = contestStore.contestStatus === ContestStatus.underway
if (autoRefresh.value && running) resume()
else pause()
}) })
onMounted(() => { const podium = computed(() => (ended.value ? allRows.value.slice(0, 3) : []))
listRanks() /** 我在自己班里第几 */
addColumns() const classPlace = computed(() => {
const row = me.value
if (!row?.className) return null
return allRows.value.filter((r) => r.className === row.className && r.rank! <= row.rank!).length
}) })
function move(row: ContestScoreRow) {
return running.value && row.rank && row.prevRank ? row.prevRank - row.rank : 0
}
function cellOf(row: ContestScoreRow, problemId: number): ContestScoreCell | undefined {
return row.cells[String(problemId)]
}
function openProblem(displayId: string) {
router.push(`/contest/${props.contestID}/problem/${displayId}`)
}
const success = computed(() => tone("success"))
const meBackground = computed(
() =>
`linear-gradient(${success.value.background}, ${success.value.background}), ${theme.value.cardColor}`,
)
const danger = computed(() => tone("error"))
const columns = computed(
() =>
`34px ${running.value ? "34px " : ""}150px 40px 70px repeat(${problems.value.length}, 58px)`,
)
</script> </script>
<template> <template>
<!-- 排名变化图表 --> <div v-if="contestStore.rankHidden" class="locked">
<LineChart :ranks="chart" :problems="problems" v-if="chart.length > 0" /> <b>{{ contestStore.contest?.tag }}考试,排名考完公布</b>
<span class="muted">考完回来就能看到全部名次和每道题的做对情况</span>
</div>
<div v-else-if="board" class="rank-page">
<div v-if="podium.length" class="podium-row">
<div class="podium">
<div
v-for="place in [2, 1, 3]"
v-show="podium[place - 1]"
:key="place"
class="step"
:class="`p${place}`"
>
<svg
:width="place === 1 ? 26 : 22"
:height="place === 1 ? 26 : 22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="cup"
>
<path d="M8 4h8v5a4 4 0 0 1-8 0z" />
<path d="M16 6h3v1a3 3 0 0 1-3 3" />
<path d="M8 6H5v1a3 3 0 0 0 3 3" />
<path d="M12 13v4" />
<path d="M8 20h8" />
</svg>
<template v-if="podium[place - 1]">
<UserName
class="podium-name"
:username="podium[place - 1]!.username"
:class-name="podium[place - 1]!.className"
strong
/>
<span class="muted small">
{{ classLabel(podium[place - 1]!.className) }} · {{ podium[place - 1]!.solved }} 道 ·
{{ secondsToDuration(podium[place - 1]!.totalTime) }}
</span>
</template>
<div class="block">{{ place }}</div>
</div>
</div>
<div class="spacer"></div>
<div v-if="me" class="mine">
<span class="muted small">你的成绩</span>
<div class="mine-line">
<span class="mine-big">第 {{ me.rank }} 名</span
><span class="muted">/ {{ allRows.length }} 人</span>
</div>
<span class="small sec">
做对 {{ me.solved }} 道 · 用时 {{ secondsToDuration(me.totalTime) }}
<template v-if="classPlace && classes.length > 1">
· {{ classLabel(me.className) }}里第 {{ classPlace }}</template
>
</span>
</div>
</div>
<!-- 排名表格 --> <div class="tools">
<n-data-table striped :single-line="false" :scroll-x="1200" :columns="columns" :data="data" /> <template v-if="classes.length > 1">
<n-space justify="end" align="center"> <button class="chip" :class="{ on: !classFilter }" @click="classFilter = ''">
<n-form 全部 {{ allRows.length }}
label-placement="left" </button>
inline <button
:show-feedback="false" v-for="[name, n] in classes"
v-if="contestStore.contestStatus === ContestStatus.underway" :key="name"
> class="chip"
<n-form-item label="开启自动刷新"> :class="{ on: classFilter === name }"
<n-switch v-model:value="autoRefresh" /> @click="classFilter = name"
</n-form-item> >
</n-form> {{ classLabel(name) }} {{ n }}
<n-button </button>
v-if="contestStore.contestStatus === ContestStatus.finished" </template>
type="primary" <span v-else class="muted small">{{ allRows.length }} 人</span>
@click="openExportModal" <div class="spacer"></div>
> <template v-if="running">
导出数据 <n-switch v-model:value="autoRefresh" size="small" />
</n-button> <span class="small">自动刷新</span>
<Pagination <span v-if="autoRefresh" class="muted small">{{ updatedAgo }}</span>
:total="total" </template>
:limit="query.limit" </div>
:page="query.page"
@update:limit="(limit: number) => (query.limit = limit)"
@update:page="(page: number) => (query.page = page)"
/>
</n-space>
<n-modal v-model:show="showExportModal" preset="dialog" title="导出获奖数据"> <div class="table">
<n-form <div class="tr th" :style="{ gridTemplateColumns: columns }">
label-placement="left" <span class="num">名次</span>
label-width="auto" <span v-if="running"></span>
:show-feedback="false" <span class="pad">学生</span>
style="margin-top: 16px" <span class="num">做对</span>
> <span class="num">用时</span>
<n-form-item label="一等奖人数" style="margin-bottom: 12px"> <button
<n-input-number v-model:value="exportForm.first" :min="0" /> v-for="p in problems"
</n-form-item> :key="p.id"
<n-form-item label="二等奖人数" style="margin-bottom: 12px"> class="ph"
<n-input-number v-model:value="exportForm.second" :min="0" /> :title="p.title"
</n-form-item> @click="openProblem(p._id)"
<n-form-item label="三等奖人数"> >
<n-input-number v-model:value="exportForm.third" :min="0" /> <b>{{ p._id }}</b>
</n-form-item> <span>{{ p.solvedUsers }} 人</span>
</n-form> </button>
<template #action> </div>
<n-button @click="showExportModal = false">取消</n-button> <div
<n-button type="primary" :loading="exportLoading" @click="downloadExcel"> 下载 CSV </n-button> v-for="row in rows"
</template> :key="row.userId"
</n-modal> class="tr"
:class="{ me: row.userId === meId }"
:style="{ gridTemplateColumns: columns }"
>
<span class="num rank">{{ row.rank }}</span>
<span v-if="running" class="move">
<span v-if="move(row) > 0" class="up">↑{{ move(row) }}</span>
<span v-else-if="move(row) < 0" class="down">↓{{ -move(row) }}</span>
</span>
<UserName
class="pad"
:username="row.username"
:class-name="row.className"
:strong="row.userId === meId"
:title="row.realName ?? row.username"
/>
<span class="num solved">{{ row.solved }}</span>
<span class="num time">{{ secondsToDuration(row.totalTime) }}</span>
<span v-for="p in problems" :key="p.id" class="cell-wrap">
<template v-if="cellOf(row, p.id)">
<span
v-if="cellOf(row, p.id)!.isAc"
class="cell ac"
:class="{ first: cellOf(row, p.id)!.firstAc }"
:title="cellOf(row, p.id)!.firstAc ? '这题最先做对' : undefined"
>
{{ Math.floor(cellOf(row, p.id)!.acTime / 60)
}}<small v-if="cellOf(row, p.id)!.errors">-{{ cellOf(row, p.id)!.errors }}</small>
</span>
<span v-else class="cell no">{{
cellOf(row, p.id)!.errors ? `-${cellOf(row, p.id)!.errors}` : "交过"
}}</span>
</template>
</span>
</div>
<div v-if="!rows.length" class="empty muted">还没有人交题</div>
</div>
<div class="legend">
<span><i class="sw first"></i>这题最先做对</span>
<span><i class="sw ac"></i>做对,格子里是第几分钟(-1 是错过 1 次)</span>
<span><i class="sw no"></i>还没对,错了几次</span>
<div class="spacer"></div>
<span>用时 = 每道做对题的分钟数相加,错一次多算 20 分钟</span>
</div>
</div>
</template> </template>
<style>
.oj-time-with-modal { <style scoped>
.locked {
padding: 80px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
font-size: 16px;
}
.rank-page {
display: flex;
flex-direction: column;
}
.muted {
color: v-bind("theme.textColor3");
}
.sec {
color: v-bind("theme.textColor2");
}
.small {
font-size: 13px;
}
.spacer {
flex-grow: 1;
}
.podium-row {
padding: 12px 20px 0;
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 20px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.podium {
display: flex;
align-items: flex-end;
gap: 8px;
}
.step {
width: 210px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.podium-name {
font-size: 15px;
max-width: 200px;
}
.p1 .podium-name {
font-size: 17px;
}
.block {
width: 100%;
box-sizing: border-box;
padding-top: 6px;
border-radius: 6px 6px 0 0;
text-align: center;
font-size: 22px;
font-weight: 800;
}
.p1 .cup,
.p1 .block {
color: #9a6700;
}
.p1 .block {
height: 66px;
background: rgba(240, 180, 40, 0.2);
}
.p2 .cup,
.p2 .block {
color: #5f6b7a;
}
.p2 .block {
height: 46px;
background: rgba(120, 135, 155, 0.18);
}
.p3 .cup,
.p3 .block {
color: #8c5a3c;
}
.p3 .block {
height: 32px;
background: rgba(170, 110, 70, 0.16);
}
.mine {
width: 330px;
margin-bottom: 16px;
padding: 14px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 6px;
border-radius: 6px;
background: v-bind("success.background");
}
.mine-line {
display: flex;
align-items: baseline;
gap: 8px;
}
.mine-big {
font-size: 28px;
font-weight: 800;
line-height: 1;
color: v-bind("success.color");
}
.tools {
min-height: 48px;
box-sizing: border-box;
padding: 8px 20px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
border-bottom: 1px solid v-bind("theme.dividerColor");
}
.chip {
height: 26px;
padding: 0 10px;
border-radius: 13px;
border: 1px solid v-bind("theme.borderColor");
background: transparent;
font: inherit;
font-size: 13px;
color: v-bind("theme.textColor2");
cursor: pointer;
white-space: nowrap;
}
.chip.on {
border-color: transparent;
background: v-bind("success.background");
color: v-bind("success.color");
font-weight: 600;
}
.table {
max-height: calc(100vh - 230px);
overflow: auto;
}
.tr {
display: grid;
align-items: center;
column-gap: 6px;
min-width: max-content;
height: 32px;
padding: 0 20px;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.cardColor");
}
.th {
position: sticky;
top: 0;
z-index: 2;
height: 44px;
font-size: 12px;
color: v-bind("theme.textColor3");
background: v-bind("theme.actionColor");
}
.tr.me {
position: sticky;
bottom: 0;
top: 44px;
z-index: 1;
/* 浅绿是半透明的,钉住时底下的行会透上来,垫一层卡片底色 */
background: v-bind("meBackground");
box-shadow: inset 3px 0 0 v-bind("theme.successColor");
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.pad {
padding-left: 6px;
min-width: 0;
}
.rank {
font-weight: 700;
}
.solved {
font-weight: 700;
}
.time {
font-size: 12px;
color: v-bind("theme.textColor3");
padding-right: 8px;
}
.move {
font-size: 11px;
}
.up {
color: v-bind("success.color");
}
.down {
color: v-bind("danger.color");
}
.ph {
border: 0;
padding: 0;
background: transparent;
font: inherit;
display: flex;
flex-direction: column;
align-items: center;
line-height: 16px;
color: v-bind("theme.textColor3");
cursor: pointer;
}
.ph b {
font-size: 13px;
color: v-bind("theme.textColor2");
}
.ph span {
font-size: 11px;
}
.cell-wrap {
display: flex; display: flex;
} }
.cell {
width: 100%;
height: 26px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.cell small {
font-size: 10px;
opacity: 0.8;
}
.cell.ac {
font-weight: 600;
background: v-bind("success.background");
color: v-bind("success.color");
}
.cell.ac.first {
background: v-bind("theme.successColor");
color: #ffffff;
}
.cell.no {
background: v-bind("danger.background");
color: v-bind("danger.color");
}
.empty {
padding: 24px 20px;
}
.legend {
min-height: 34px;
padding: 6px 20px;
box-sizing: border-box;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
font-size: 12px;
color: v-bind("theme.textColor3");
border-top: 1px solid v-bind("theme.dividerColor");
}
.legend span {
display: flex;
align-items: center;
gap: 6px;
}
.sw {
width: 28px;
height: 16px;
border-radius: 3px;
}
.sw.first {
background: v-bind("theme.successColor");
}
.sw.ac {
background: v-bind("success.background");
}
.sw.no {
background: v-bind("danger.background");
}
</style> </style>
@@ -217,7 +217,10 @@ const contestBar = computed(() => {
// 不在进行中的两种:管理员赛前看题是「还没开始」,不能一律写「已结束」 // 不在进行中的两种:管理员赛前看题是「还没开始」,不能一律写「已结束」
notStarted: contestStore.contestStatus === ContestStatus.not_started, notStarted: contestStore.contestStatus === ContestStatus.not_started,
home: { name: "contest problems", params: { contestID: ctx.value.contestId } }, home: { name: "contest problems", params: { contestID: ctx.value.contestId } },
rank: { name: "contest rank", params: { contestID: ctx.value.contestId } }, // 期中期末进行中排名不公布,链接也不给
rank: contestStore.rankHidden
? null
: { name: "contest rank", params: { contestID: ctx.value.contestId } },
} }
}) })
@@ -359,7 +362,9 @@ onSolvedHere(() => {
<span class="countdown">{{ contestBar.remaining }}</span> <span class="countdown">{{ contestBar.remaining }}</span>
</template> </template>
<span v-else class="meta">{{ contestBar.notStarted ? "还没开始" : "已结束" }}</span> <span v-else class="meta">{{ contestBar.notStarted ? "还没开始" : "已结束" }}</span>
<router-link :to="contestBar.rank" class="contest-rank">排名</router-link> <router-link v-if="contestBar.rank" :to="contestBar.rank" class="contest-rank"
>排名</router-link
>
</div> </div>
</template> </template>
@@ -197,6 +197,7 @@ function segmentWidth(value: number, total: number) {
现在只看得到你自己的提交,别人几次做对、错在哪,结束后一起公布。 现在只看得到你自己的提交,别人几次做对、错在哪,结束后一起公布。
</n-text> </n-text>
<router-link <router-link
v-if="!contestStore.rankHidden"
:to="{ name: 'contest rank', params: { contestID: route.params.contestID } }" :to="{ name: 'contest rank', params: { contestID: route.params.contestID } }"
class="link" class="link"
> >
+49 -1
View File
@@ -3,8 +3,15 @@ import { formatISO, getTime, parseISO } from "date-fns"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { ContestStatus, ContestType } from "utils/constants" import { ContestStatus, ContestType } from "utils/constants"
import { duration, secondsToDuration } from "utils/functions" import { duration, secondsToDuration } from "utils/functions"
import { isExamTag, type ContestScoreboard } from "@oj2/contract"
import type { Contest, ProblemRow } from "utils/types" import type { Contest, ProblemRow } from "utils/types"
import { checkContestPassword, getContest, getContestAccess, getContestProblems } from "../api" import {
checkContestPassword,
getContest,
getContestAccess,
getContestProblems,
getContestScoreboard,
} from "../api"
export const useContestStore = defineStore("contest", () => { export const useContestStore = defineStore("contest", () => {
const userStore = useUserStore() const userStore = useUserStore()
@@ -12,6 +19,10 @@ export const useContestStore = defineStore("contest", () => {
const contest = ref<Contest | null>(null) const contest = ref<Contest | null>(null)
const problems = ref<ProblemRow[]>([]) const problems = ref<ProblemRow[]>([])
const now = ref(0) const now = ref(0)
/** 整张榜(学生比赛页右边的名次、排名页共用),进行中由页面每 10 秒刷一次 */
const scoreboard = ref<ContestScoreboard | null>(null)
/** 上次拉到榜的时刻(本机时间),「8 秒前更新」用 */
const scoreboardAt = ref(0)
let timer = 0 let timer = 0
@@ -54,8 +65,37 @@ export const useContestStore = defineStore("contest", () => {
(userStore.isAuthed && contest.value?.createdBy.id === userStore.user!.id), (userStore.isAuthed && contest.value?.createdBy.id === userStore.user!.id),
) )
/** 老师(出题人、超管、其他老师):看得到全班情况、真名,期中期末进行中也看得到排名 */
const isTeacher = computed(() => isContestAdmin.value || userStore.isTeacherOrAbove)
const isPrivate = computed(() => contest.value!.contestType === ContestType.private) const isPrivate = computed(() => contest.value!.contestType === ContestType.private)
/** 期中、期末进行中,学生看不到排名和每题做对人数(后端同样拦着,这里只管界面) */
const rankHidden = computed(
() =>
!!contest.value &&
isExamTag(contest.value.tag) &&
contestStatus.value === ContestStatus.underway &&
!isTeacher.value,
)
/** 能不能看题目(也就能看榜):开始了、密码对了;老师 / 出题人随时 */
const canEnter = computed(() => {
if (!contest.value) return false
if (isContestAdmin.value) return true
if (contestStatus.value === ContestStatus.not_started) return false
return !isPrivate.value || access.value
})
async function loadScoreboard(contestID: string) {
try {
scoreboard.value = await getContestScoreboard(contestID)
scoreboardAt.value = Date.now()
} catch {
// 没开始 / 没密码时拉不到,界面按 canEnter 自己会挡住
}
}
async function init(contestID: string) { async function init(contestID: string) {
problems.value = [] problems.value = []
const res = await getContest(contestID) const res = await getContest(contestID)
@@ -81,6 +121,7 @@ export const useContestStore = defineStore("contest", () => {
function clear() { function clear() {
contest.value = null contest.value = null
scoreboard.value = null
problems.value = [] problems.value = []
toggleAccess(false) toggleAccess(false)
now.value = 0 now.value = 0
@@ -116,6 +157,13 @@ export const useContestStore = defineStore("contest", () => {
contest, contest,
contestStatus, contestStatus,
isContestAdmin, isContestAdmin,
isTeacher,
rankHidden,
canEnter,
scoreboard,
scoreboardAt,
loadScoreboard,
now,
access, access,
problems, problems,
isPrivate, isPrivate,
+8 -7
View File
@@ -55,6 +55,14 @@ export const ojs: RouteRecordRaw = {
meta: { requiresAuth: true }, meta: { requiresAuth: true },
name: "contest rank", name: "contest rank",
}, },
// 老师的「全班情况」,考完是「成绩」(代替原来后台的「审核」页)
{
path: "class",
component: () => import("oj/contest/pages/class.vue"),
props: true,
meta: { requiresAuth: true },
name: "contest class",
},
], ],
}, },
{ {
@@ -244,13 +252,6 @@ export const admins: RouteRecordRaw = {
props: true, props: true,
meta: { requiresTeacherAdmin: true }, meta: { requiresTeacherAdmin: true },
}, },
{
path: "contest/:contestID/helper",
name: "admin contest helper",
component: () => import("admin/contest/helper.vue"),
props: true,
meta: { requiresTeacherAdmin: true },
},
// 只有super_admin可以访问的路由 // 只有super_admin可以访问的路由
{ {
path: "announcement/list", path: "announcement/list",
+18
View File
@@ -212,6 +212,24 @@ export function readableDuration(seconds: number): string {
return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时` return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时`
} }
/**
* 比赛有多长,给人看的:「40 分钟」「1 小时 30 分钟」「3 天」。
* `duration()` 会拆出「1月6天59分钟」这种,比赛列表里读不下去;一天以上只说几天
*/
export function contestLength(start: Date | string, end: Date | string): string {
const minutes = Math.round(
(normalizeDate(end).getTime() - normalizeDate(start).getTime()) / 60_000,
)
if (!Number.isFinite(minutes) || minutes <= 0) return ""
if (minutes < 60) return `${minutes} 分钟`
if (minutes < 24 * 60) {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return m ? `${h} 小时 ${m} 分钟` : `${h} 小时`
}
return `${Math.round(minutes / (24 * 60))} 天`
}
export function durationToDays(start: Date | string, end: Date | string): string { export function durationToDays(start: Date | string, end: Date | string): string {
const durationObj = getDurationObject(start, end) const durationObj = getDurationObject(start, end)
const units = [ const units = [
-7
View File
@@ -329,12 +329,6 @@ export type BlankContest = Omit<
*/ */
export type { ContestSubmissionInfo } from "@oj2/contract" export type { ContestSubmissionInfo } from "@oj2/contract"
/**
* 榜单行。`submissionInfo` 的收窄(JSONB 原文的 snake_case 形状)已经搬进
* `contestRankItemSchema`,这里不再需要 Omit + 覆盖。
*/
export type ContestRank = import("@oj2/contract").ContestRankItem
export type { WebsiteConfig, OnlineCount } from "@oj2/contract" export type { WebsiteConfig, OnlineCount } from "@oj2/contract"
export type { export type {
@@ -344,7 +338,6 @@ export type {
OrphanTestCase, OrphanTestCase,
AdminUser, AdminUser,
AdminUserList, AdminUserList,
AcmHelperItem,
AdminContestList, AdminContestList,
AdminProblemSetProgress, AdminProblemSetProgress,
AdminProblemSetProblem, AdminProblemSetProblem,