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

This commit is contained in:
2026-08-06 21:18:16 -06:00
parent 3c975e85ee
commit ae1fb329b5
258 changed files with 40490 additions and 91 deletions

View File

@@ -0,0 +1,68 @@
<script lang="ts" setup>
import type { Contest } from "utils/types"
import { cloneContest } from "../../api"
interface Props {
contest: Contest
}
const props = defineProps<Props>()
const router = useRouter()
const message = useMessage()
function goEdit() {
router.push({
name: "admin contest edit",
params: { contestID: props.contest.id },
})
}
function goEditProblems() {
router.push({
name: "admin contest problem list",
params: { contestID: props.contest.id },
})
}
function goACMHelper() {
router.push({
name: "admin contest helper",
params: { contestID: props.contest.id },
})
}
async function clone() {
try {
const res = await cloneContest(props.contest.id)
message.success("复制成功")
router.push({
name: "admin contest edit",
params: { contestID: res.data.id },
})
} catch {
message.error("复制失败")
}
}
const isACM = computed(() => props.contest.rule_type === "ACM")
</script>
<template>
<n-flex>
<n-button size="small" type="primary" secondary @click="goEditProblems">
题目
</n-button>
<n-button
v-if="isACM"
size="small"
type="warning"
secondary
@click="goACMHelper"
>
审核
</n-button>
<n-button size="small" type="info" secondary @click="goEdit">
编辑
</n-button>
<n-button size="small" secondary @click="clone"> 复制 </n-button>
</n-flex>
</template>
<style scoped></style>

View File

@@ -0,0 +1,196 @@
<script setup lang="ts">
import { formatISO } from "date-fns"
import TextEditor from "shared/components/TextEditor.vue"
import { parseTime } from "utils/functions"
import type { BlankContest } from "utils/types"
import { createContest, editContest, getContest } from "../api"
interface Props {
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.start_time = formatISO(times[0])
contest.end_time = formatISO(times[1])
})
// 编辑的时候
const startTime = ref(0)
const endTime = ref(0)
watch([startTime, endTime], (values) => {
contest.start_time = formatISO(values[0])
contest.end_time = formatISO(values[1])
})
const route = useRoute()
const router = useRouter()
const message = useMessage()
const props = defineProps<Props>()
const [ready, toggleReady] = useToggle()
const tags: SelectOption[] = [
{ label: "练习", value: "练习" },
{ label: "期中", value: "期中" },
{ label: "期末", value: "期末" },
]
const contest = reactive<BlankContest & { id: number }>({
id: 0,
title: "",
description: "",
tag: "练习",
start_time: "",
end_time: "",
password: "",
visible: false,
allowed_ip_ranges: [],
})
async function getContestDetail() {
if (!props.contestID) {
const times = getTimes()
contest.start_time = formatISO(times[0])
contest.end_time = formatISO(times[1])
toggleReady(true)
return
}
const { data } = await getContest(props.contestID)
toggleReady(true)
contest.id = data.id
contest.title = data.title
contest.description = data.description
contest.tag = data.tag
contest.start_time = data.start_time
contest.end_time = data.end_time
contest.password = data.password
contest.visible = data.visible
contest.allowed_ip_ranges = []
// 显示
startTime.value = Date.parse(data.start_time)
endTime.value = Date.parse(data.end_time)
}
async function submit() {
if (contest.description === "<p><br></p>") {
contest.description = contest.title
}
const api = {
"admin contest create": createContest,
"admin contest edit": editContest,
}[route.name as string]
try {
await api!(contest)
if (route.name === "admin contest create") {
message.success("成功新建比赛 💐")
} else {
message.success("修改已保存")
}
router.push({ name: "admin contest list" })
} catch (err: any) {
message.error(err.data)
}
}
onMounted(getContestDetail)
</script>
<template>
<n-flex class="titleWrapper" align="center">
<h2 class="title">
{{ route.name === "admin contest create" ? "新建比赛" : "编辑比赛" }}
</h2>
<template v-if="!props.contestID">
<n-alert type="success">
<template #header>
开始时间 {{ parseTime(contest.start_time, "YYYY年M月D日 HH:mm:ss") }}
</template>
</n-alert>
<n-alert type="warning">
<template #header>
结束时间 {{ parseTime(contest.end_time, "YYYY年M月D日 HH:mm:ss") }}
</template>
</n-alert>
</template>
</n-flex>
<n-form inline>
<n-form-item label="标题">
<n-input style="width: 300px" v-model:value="contest.title" />
</n-form-item>
<n-form-item label="标签">
<n-select
style="width: 100px"
:options="tags"
v-model:value="contest.tag"
/>
</n-form-item>
<template v-if="props.contestID">
<n-form-item label="开始">
<n-date-picker
style="width: 200px"
v-model:value="startTime"
type="datetime"
/>
</n-form-item>
<n-form-item label="结束">
<n-date-picker
style="width: 200px"
v-model:value="endTime"
type="datetime"
/>
</n-form-item>
</template>
<template v-else>
<n-form-item label="几分钟后开始">
<n-input-number style="width: 120px" v-model:value="waitMins" />
</n-form-item>
<n-form-item label="比赛时长">
<n-input-number
style="width: 120px"
step="5"
v-model:value="durationMins"
/>
</n-form-item>
</template>
<n-form-item label="密码">
<n-input style="width: 160px" v-model:value="contest.password" />
</n-form-item>
<n-form-item label="可见">
<n-switch v-model:value="contest.visible" />
</n-form-item>
</n-form>
<TextEditor
v-if="ready"
title="描述"
v-model:value="contest.description"
:min-height="200"
/>
<n-flex style="margin-bottom: 100px" justify="end">
<n-button type="primary" @click="submit">保存</n-button>
</n-flex>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -0,0 +1,326 @@
<script setup lang="ts">
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
import { parseTime } from "utils/functions"
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
import { getSubmission, getSubmissions } from "oj/api"
import SubmissionDetail from "oj/submission/detail.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
interface Props {
contestID: string
}
interface HelperItem {
id: number
username: string
real_name: string
problem_id: string
problem_display_id: string
ac_info: {
is_ac: boolean
ac_time: number
error_number: number
checked?: boolean
}
checked: boolean
}
const props = defineProps<Props>()
const message = useMessage()
const { isDesktop } = useBreakpoints()
const submissions = ref<HelperItem[]>([])
const contestStartTime = ref<Date | null>(null)
const query = reactive({
username: "",
problemId: "",
checked: "all",
})
// 检查状态选项
const checkedOptions = [
{ label: "全部", value: "all" },
{ label: "已检查", value: "checked" },
{ label: "未检查", value: "unchecked" },
]
// 代码查看模态框
const [codePanel, toggleCodePanel] = useToggle(false)
const currentSubmission = ref<any>(null)
// 格式化 AC 时间ac_time 是相对于比赛开始的秒数)
function formatACTime(relativeSeconds: number) {
if (!contestStartTime.value) return "-"
const acTime = new Date(
contestStartTime.value.getTime() + relativeSeconds * 1000,
)
return parseTime(acTime, "YYYY-MM-DD HH:mm:ss")
}
// 切换检查状态
async function toggleChecked(item: HelperItem) {
const newChecked = !item.checked
try {
await updateACMHelperChecked(
Number(props.contestID),
item.id,
item.problem_id,
newChecked,
)
// 更新本地状态
item.checked = newChecked
item.ac_info.checked = newChecked
// 强制触发响应式更新
submissions.value = [...submissions.value]
message.success(newChecked ? "已标记为已检查" : "已取消标记")
} catch (err: any) {
message.error(err.data || "操作失败")
}
}
// 批量标记为已检查
async function markAllAsChecked() {
const unchecked = filteredSubmissions.value.filter((item) => !item.checked)
if (unchecked.length === 0) {
message.info("没有需要标记的提交")
return
}
const loadingMsg = message.loading("正在标记...", { duration: 0 })
try {
for (const item of unchecked) {
await updateACMHelperChecked(
Number(props.contestID),
item.id,
item.problem_id,
true,
)
item.checked = true
item.ac_info.checked = true
}
// 强制触发响应式更新
submissions.value = [...submissions.value]
loadingMsg.destroy()
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
} catch (err: any) {
loadingMsg.destroy()
message.error(err.data || "批量操作失败")
}
}
// 过滤后的提交列表
const filteredSubmissions = computed(() => {
return submissions.value.filter((item) => {
if (query.username && !item.username.includes(query.username)) return false
if (query.problemId && !item.problem_display_id.includes(query.problemId))
return false
if (query.checked === "checked" && !item.checked) return false
if (query.checked === "unchecked" && item.checked) return false
return true
})
})
// 统计信息
const stats = computed(() => {
const total = submissions.value.length
const checked = submissions.value.filter((item) => item.checked).length
const unchecked = total - checked
return { total, checked, unchecked }
})
// 查看代码 - 获取该用户在该题目的 AC 提交
async function viewSubmission(item: HelperItem) {
try {
// 查询该用户在该竞赛该题目的 AC 提交
const res = await getSubmissions({
username: item.username,
problem_id: item.problem_display_id,
contest_id: props.contestID,
result: "0", // ACCEPTED
language: "",
page: 1,
offset: 0,
limit: 1,
})
if (res.data.results.length === 0) {
message.warning("未找到该用户的 AC 提交")
return
}
// 获取提交详情
const submissionListItem = res.data.results[0]
const detailRes = await getSubmission(submissionListItem.id)
// 手动添加 contest 字段ACM模式下后端不返回此字段
currentSubmission.value = {
...detailRes.data,
contest: Number(props.contestID),
problem_display_id: item.problem_display_id,
}
toggleCodePanel(true)
} catch (err: any) {
message.error(err.data || "加载提交失败")
}
}
// 加载数据
async function loadData() {
try {
// 先获取比赛信息,获取开始时间
const contestRes = await getContest(props.contestID)
contestStartTime.value = new Date(contestRes.data.start_time)
// 再获取 AC 提交列表
const { data } = await getACMHelperList(Number(props.contestID))
submissions.value = data
} catch (err: any) {
message.error(err.data || "加载失败")
}
}
const columns: DataTableColumn<HelperItem>[] = [
{
title: "用户名",
key: "username",
width: 150,
},
{
title: "题目",
key: "problem_display_id",
width: 100,
render: (row) => h(NTag, { type: "info" }, () => row.problem_display_id),
},
{
title: "AC时间",
key: "ac_time",
width: 180,
render: (row) => formatACTime(row.ac_info.ac_time),
},
{
title: "错误次数",
key: "error_number",
width: 100,
render: (row) =>
h(
NTag,
{
type: row.ac_info.error_number > 0 ? "warning" : "success",
size: "small",
},
() => row.ac_info.error_number,
),
},
{
title: "已检查",
key: "checked",
width: 100,
render: (row) =>
h(NCheckbox, {
checked: row.checked,
onUpdateChecked: () => toggleChecked(row),
}),
},
{
title: "操作",
key: "actions",
width: 100,
render: (row) =>
h(
NButton,
{
size: "small",
type: "primary",
secondary: true,
onClick: () => viewSubmission(row),
},
() => "查看代码",
),
},
]
onMounted(loadData)
</script>
<template>
<n-flex vertical>
<n-flex justify="space-between" align="center">
<n-flex align="center">
<h2 style="margin: 0">比赛辅助检查</h2>
<n-tag type="info" size="large"> 总计: {{ stats.total }} </n-tag>
<n-tag type="success" size="large"> 已检查: {{ stats.checked }} </n-tag>
<n-tag type="warning" size="large">
未检查: {{ stats.unchecked }}
</n-tag>
</n-flex>
<n-button
type="primary"
:disabled="stats.unchecked === 0"
@click="markAllAsChecked"
>
标记全部为已检查
</n-button>
</n-flex>
<n-alert type="info" style="margin-bottom: 16px">
<template #header>使用说明</template>
此工具用于赛后人工审核代码检查是否存在抄袭作弊等行为请逐个查看通过AC的提交代码检查完成后勾选"已检查"
</n-alert>
<n-flex align="center" style="margin-bottom: 16px">
<n-input
v-model:value="query.username"
placeholder="筛选用户名"
style="width: 150px"
clearable
/>
<n-input
v-model:value="query.problemId"
placeholder="筛选题目"
style="width: 150px"
clearable
/>
<n-select
v-model:value="query.checked"
:options="checkedOptions"
style="width: 120px"
/>
</n-flex>
<n-data-table
:columns="columns"
:data="filteredSubmissions"
:pagination="{ pageSize: 20 }"
:bordered="false"
/>
<n-modal
v-model:show="codePanel"
preset="card"
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
title="代码详情"
>
<SubmissionDetail
v-if="currentSubmission"
:submission="currentSubmission"
:problemID="currentSubmission.problem_display_id"
:submissionID="currentSubmission.id"
hideList
@copied="toggleCodePanel(false)"
/>
</n-modal>
</n-flex>
</template>
<style scoped>
:deep(.n-data-table) {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { NSwitch, NTag } from "naive-ui"
import ContestTitle from "shared/components/ContestTitle.vue"
import ContestType from "shared/components/ContestType.vue"
import Pagination from "shared/components/Pagination.vue"
import { CONTEST_STATUS } from "utils/constants"
import { parseTime } from "utils/functions"
import type { Contest } from "utils/types"
import { editContest, getContestList } from "../api"
import Actions from "./components/Actions.vue"
const contests = ref<Contest[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
keyword: "",
})
function toggleVisible(contest: Contest) {
contest.visible = !contest.visible
editContest(contest)
}
const columns: DataTableColumn<Contest>[] = [
{ title: "ID", key: "id", width: 60 },
{
title: "比赛",
key: "title",
minWidth: 200,
render: (row) => h(ContestTitle, { contest: row }),
},
{
title: "标签",
key: "tag",
width: 100,
},
{
title: "类型",
key: "contest_type",
width: 100,
render: (row) => h(ContestType, { contest: row, size: "small" }),
},
{
title: "状态",
key: "status",
width: 100,
render: (row) =>
h(
NTag,
{ type: CONTEST_STATUS[row.status]["type"], size: "small" },
() => CONTEST_STATUS[row.status]["name"],
),
},
{
title: "创建者",
key: "created_by",
width: 120,
render: (row) => row.created_by.username,
},
{
title: "创建时间",
key: "create_time",
width: 160,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm"),
},
{
title: "可见",
key: "visible",
width: 100,
render: (row) =>
h(NSwitch, {
value: row.visible,
size: "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row),
}),
},
{
title: "选项",
key: "actions",
width: 300,
render: (row) => h(Actions, { contest: row }),
},
]
async function listContests() {
const offset = (query.page - 1) * query.limit
const res = await getContestList(offset, query.limit, query.keyword)
contests.value = res.data.results
total.value = res.data.total
}
onMounted(listContests)
watch(() => [query.page, query.limit], listContests)
watchDebounced(() => query.keyword, listContests, {
debounce: 500,
maxWait: 1000,
})
</script>
<template>
<n-flex justify="space-between" class="titleWrapper">
<n-flex align="center">
<h2 class="title">比赛列表</h2>
<n-button
type="primary"
@click="$router.push({ name: 'admin contest create' })"
>
新建
</n-button>
</n-flex>
<div>
<n-input v-model:value="query.keyword" placeholder="输入标题关键字" />
</div>
</n-flex>
<n-data-table :columns="columns" :data="contests" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>