feat(后台比赛): 比赛列表一行看全——开始时间和多长、几道题、几人参加、学生看得到;期中期末挂标,操作只剩「编辑」「成绩」和 ⋯
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 14:07:24 +08:00
1 parent 591c7bcab9
commit 32f7bcaf8b
2 files changed
+119 -47

No files matched your search

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import type { AdminContest } from "utils/types"
import { cloneContest } from "../../api"
@@ -21,6 +22,16 @@ function goClass() {
window.open(`/contest/${props.contest.id}/class`, "_blank")
}
const menuOptions: DropdownOption[] = [
{ label: "复制一份", key: "clone" },
{ label: "在前台看", key: "view" },
]
function onMenu(key: string) {
if (key === "clone") clone()
else if (key === "view") window.open(`/contest/${props.contest.id}`, "_blank")
}
async function clone() {
try {
const res = await cloneContest(props.contest.id)
@@ -35,12 +46,16 @@ async function clone() {
}
</script>
<template>
<n-flex>
<n-button size="small" type="primary" secondary @click="goEdit"> 编辑 </n-button>
<n-button size="small" type="info" secondary @click="goClass">
<n-flex :size="4" justify="end" :wrap="false">
<n-button size="small" @click="goEdit">编辑</n-button>
<n-button size="small" @click="goClass">
{{ contest.status === "-1" ? "成绩" : "全班情况" }}
</n-button>
<n-button size="small" secondary @click="clone"> 复制 </n-button>
<n-dropdown trigger="click" placement="bottom-end" :options="menuOptions" @select="onMenu">
<n-button size="small" quaternary aria-label="更多">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</n-flex>
</template>
<style scoped></style>
+100 -43
View File
@@ -1,16 +1,22 @@
<script setup lang="ts">
import type { AdminContestListItem } from "@oj2/contract"
import PageHeader from "admin/components/PageHeader.vue"
import { NSwitch, NTag } from "naive-ui"
import { NSwitch, useThemeVars } from "naive-ui"
import ContestTitle from "shared/components/ContestTitle.vue"
import ContestAccessTag from "shared/components/ContestAccessTag.vue"
import Pagination from "shared/components/Pagination.vue"
import { CONTEST_STATUS } from "utils/constants"
import { parseTime } from "utils/functions"
import type { AdminContest } from "utils/types"
import { editContest, getContestList } from "../api"
import Actions from "./components/Actions.vue"
const contests = ref<AdminContest[]>([])
/**
* 后台比赛列表(设计稿「比赛重设计」CtAdminList)。一行一场:比赛名(期中 / 期末挂标,
* 要密码的带锁)、开始时间和多长、状态、几道题、几人参加、出卷人、学生看得到;
* 操作「编辑」「成绩 / 全班情况」+ ⋯。按开始时间排 —— 原来按创建时间,复制出来的旧比赛
* 会跑到最前面。ID、标签、类型三列拿掉(标签只在期中 / 期末时挂出来,类型就是那把锁)
*/
const theme = useThemeVars()
const contests = ref<AdminContestListItem[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
@@ -18,57 +24,79 @@ const query = reactive({
keyword: "",
})
function toggleVisible(contest: AdminContest) {
function toggleVisible(contest: AdminContestListItem) {
contest.visible = !contest.visible
editContest(contest)
}
const columns: DataTableColumn<AdminContest>[] = [
{ title: "ID", key: "id", width: 60 },
/** 多长:一节课的写分钟 / 小时,挂好几天的练习写天 */
function duration(row: AdminContestListItem) {
const minutes = Math.round((Date.parse(row.endTime) - Date.parse(row.startTime)) / 60_000)
if (minutes < 60) return `${minutes} 分钟`
if (minutes < 48 * 60) {
const hours = Math.floor(minutes / 60)
const rest = minutes % 60
return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时`
}
return `${Math.round(minutes / 1440)} 天`
}
const EXAM_TAGS = ["期中", "期末"]
const columns: DataTableColumn<AdminContestListItem>[] = [
{
title: "比赛",
key: "title",
minWidth: 200,
render: (row) => h(ContestTitle, { contest: row }),
minWidth: 220,
render: (row) =>
h("span", { class: "title-cell" }, [
h(ContestTitle, { contest: row }),
EXAM_TAGS.includes(row.tag) ? h("span", { class: "exam" }, row.tag) : null,
]),
},
{
title: "标签",
key: "tag",
width: 100,
},
{
title: "类型",
key: "contest_type",
width: 100,
render: (row) => h(ContestAccessTag, { contest: row, size: "small" }),
title: "开始 · 多长",
key: "start_time",
width: 220,
render: (row) =>
h("span", { class: "num" }, [
parseTime(row.startTime, "YYYY-MM-DD HH:mm"),
h("span", { class: "muted" }, ` · ${duration(row)}`),
]),
},
{
title: "状态",
key: "status",
width: 100,
width: 80,
render: (row) =>
h(
NTag,
{ type: CONTEST_STATUS[row.status]["type"], size: "small" },
() => CONTEST_STATUS[row.status]["name"],
"span",
{ class: row.status === "0" ? "live" : row.status === "-1" ? "muted" : "" },
row.status === "-1"
? "结束了"
: row.status === "0"
? "进行中"
: CONTEST_STATUS[row.status].name,
),
},
{ title: "题", key: "problemCount", width: 56 },
{
title: "创建者",
title: "参加",
key: "participantCount",
width: 64,
render: (row) => (row.participantCount ? String(row.participantCount) : "—"),
},
{
title: "出卷",
key: "created_by",
width: 120,
width: 96,
ellipsis: { tooltip: true },
render: (row) => row.createdBy.username,
},
{
title: "创建时间",
key: "create_time",
width: 160,
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm"),
},
{
title: "可见",
title: "学生看得到",
key: "visible",
width: 100,
width: 96,
render: (row) =>
h(NSwitch, {
value: row.visible,
@@ -78,10 +106,9 @@ const columns: DataTableColumn<AdminContest>[] = [
}),
},
{
title: "选项",
title: "",
key: "actions",
fixed: "right",
width: 300,
width: 190,
render: (row) => h(Actions, { contest: row }),
},
]
@@ -101,21 +128,51 @@ watchDebounced(() => query.keyword, listContests, {
</script>
<template>
<PageHeader title="比赛列表">
<PageHeader title="比赛">
<template #actions>
<n-button type="primary" @click="$router.push({ name: 'admin contest create' })">
新建比赛
</n-button>
</template>
<template #filters>
<n-input
v-model:value="query.keyword"
placeholder="输入标题关键字"
clearable
style="width: 220px"
/>
<n-input v-model:value="query.keyword" placeholder="比赛名" clearable style="width: 220px" />
</template>
</PageHeader>
<n-data-table :columns="columns" :data="contests" />
<n-data-table class="contest-table" :columns="columns" :data="contests" />
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template>
<style scoped>
.contest-table :deep(.title-cell) {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.contest-table :deep(.exam) {
display: inline-flex;
align-items: center;
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
font-weight: 400;
color: v-bind("theme.errorColor");
background: v-bind("theme.actionColor");
}
.contest-table :deep(.num) {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.contest-table :deep(.muted) {
color: v-bind("theme.textColor3");
}
.contest-table :deep(.live) {
color: v-bind("theme.successColor");
font-weight: 600;
}
</style>