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> <script lang="ts" setup>
import { Icon } from "@iconify/vue"
import type { AdminContest } from "utils/types" import type { AdminContest } from "utils/types"
import { cloneContest } from "../../api" import { cloneContest } from "../../api"
@@ -21,6 +22,16 @@ function goClass() {
window.open(`/contest/${props.contest.id}/class`, "_blank") 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() { async function clone() {
try { try {
const res = await cloneContest(props.contest.id) const res = await cloneContest(props.contest.id)
@@ -35,12 +46,16 @@ async function clone() {
} }
</script> </script>
<template> <template>
<n-flex> <n-flex :size="4" justify="end" :wrap="false">
<n-button size="small" type="primary" secondary @click="goEdit"> 编辑 </n-button> <n-button size="small" @click="goEdit">编辑</n-button>
<n-button size="small" type="info" secondary @click="goClass"> <n-button size="small" @click="goClass">
{{ contest.status === "-1" ? "成绩" : "全班情况" }} {{ contest.status === "-1" ? "成绩" : "全班情况" }}
</n-button> </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> </n-flex>
</template> </template>
<style scoped></style> <style scoped></style>
+100 -43
View File
@@ -1,16 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AdminContestListItem } from "@oj2/contract"
import PageHeader from "admin/components/PageHeader.vue" 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 ContestTitle from "shared/components/ContestTitle.vue"
import ContestAccessTag from "shared/components/ContestAccessTag.vue"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import { CONTEST_STATUS } from "utils/constants" import { CONTEST_STATUS } from "utils/constants"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
import type { AdminContest } from "utils/types"
import { editContest, getContestList } from "../api" import { editContest, getContestList } from "../api"
import Actions from "./components/Actions.vue" import Actions from "./components/Actions.vue"
const contests = ref<AdminContest[]>([]) /**
* 后台比赛列表(设计稿「比赛重设计」CtAdminList)。一行一场:比赛名(期中 / 期末挂标,
* 要密码的带锁)、开始时间和多长、状态、几道题、几人参加、出卷人、学生看得到;
* 操作「编辑」「成绩 / 全班情况」+ ⋯。按开始时间排 —— 原来按创建时间,复制出来的旧比赛
* 会跑到最前面。ID、标签、类型三列拿掉(标签只在期中 / 期末时挂出来,类型就是那把锁)
*/
const theme = useThemeVars()
const contests = ref<AdminContestListItem[]>([])
const total = ref(0) const total = ref(0)
const query = reactive({ const query = reactive({
limit: 10, limit: 10,
@@ -18,57 +24,79 @@ const query = reactive({
keyword: "", keyword: "",
}) })
function toggleVisible(contest: AdminContest) { function toggleVisible(contest: AdminContestListItem) {
contest.visible = !contest.visible contest.visible = !contest.visible
editContest(contest) 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: "比赛", title: "比赛",
key: "title", key: "title",
minWidth: 200, minWidth: 220,
render: (row) => h(ContestTitle, { contest: row }), render: (row) =>
h("span", { class: "title-cell" }, [
h(ContestTitle, { contest: row }),
EXAM_TAGS.includes(row.tag) ? h("span", { class: "exam" }, row.tag) : null,
]),
}, },
{ {
title: "标签", title: "开始 · 多长",
key: "tag", key: "start_time",
width: 100, width: 220,
}, render: (row) =>
{ h("span", { class: "num" }, [
title: "类型", parseTime(row.startTime, "YYYY-MM-DD HH:mm"),
key: "contest_type", h("span", { class: "muted" }, ` · ${duration(row)}`),
width: 100, ]),
render: (row) => h(ContestAccessTag, { contest: row, size: "small" }),
}, },
{ {
title: "状态", title: "状态",
key: "status", key: "status",
width: 100, width: 80,
render: (row) => render: (row) =>
h( h(
NTag, "span",
{ type: CONTEST_STATUS[row.status]["type"], size: "small" }, { class: row.status === "0" ? "live" : row.status === "-1" ? "muted" : "" },
() => CONTEST_STATUS[row.status]["name"], 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", key: "created_by",
width: 120, width: 96,
ellipsis: { tooltip: true },
render: (row) => row.createdBy.username, render: (row) => row.createdBy.username,
}, },
{ {
title: "创建时间", title: "学生看得到",
key: "create_time",
width: 160,
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm"),
},
{
title: "可见",
key: "visible", key: "visible",
width: 100, width: 96,
render: (row) => render: (row) =>
h(NSwitch, { h(NSwitch, {
value: row.visible, value: row.visible,
@@ -78,10 +106,9 @@ const columns: DataTableColumn<AdminContest>[] = [
}), }),
}, },
{ {
title: "选项", title: "",
key: "actions", key: "actions",
fixed: "right", width: 190,
width: 300,
render: (row) => h(Actions, { contest: row }), render: (row) => h(Actions, { contest: row }),
}, },
] ]
@@ -101,21 +128,51 @@ watchDebounced(() => query.keyword, listContests, {
</script> </script>
<template> <template>
<PageHeader title="比赛列表"> <PageHeader title="比赛">
<template #actions> <template #actions>
<n-button type="primary" @click="$router.push({ name: 'admin contest create' })"> <n-button type="primary" @click="$router.push({ name: 'admin contest create' })">
新建比赛 新建比赛
</n-button> </n-button>
</template> </template>
<template #filters> <template #filters>
<n-input <n-input v-model:value="query.keyword" placeholder="比赛名" clearable style="width: 220px" />
v-model:value="query.keyword"
placeholder="输入标题关键字"
clearable
style="width: 220px"
/>
</template> </template>
</PageHeader> </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" /> <Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template> </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>