提交列表: - 焦点在单选框、下拉框上时方向键不再一边切控件一边换条;跨页没回来前不再连跳一整页 - 重判、重新评分之后右栏重新拉,不再命中旧缓存;同一条新旧请求不再互相覆盖 - 从「我的提交」点顶栏「提交」、浏览器后退时,地址里没有的筛选回到默认 - 找回点用户名开个人主页;「只看他 / 只看这题」学生也能用;重判不用等代码拉下来 - 手机上老师也能重判、能按语言筛,流程图行有手机排版;学生界面带着的班级筛选显示成能点掉的标签 - mermaid 画不出来时先说中文、原始报错折起来 统计页: - 没人交时说「这段时间没有人交」,不再说「全都做完了」;没填题号时各处统一叫「做对过题」 - 班级按 class_name 单独传;查询失败清掉旧数据并说清原因;标签跟着查出来的那一份走 - 「语法未过」的格子单独标「语」;正确率不算还在判的;截断提示说清哪些数字受影响 - 看板的请假隐藏只藏还没交过、没在举手的人,「恢复」只恢复这个班的 - 手机上筛选折行、上下排,不再挤出屏幕 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
298 lines
8.2 KiB
Vue
298 lines
8.2 KiB
Vue
<script setup lang="ts">
|
|
import PageHeader from "admin/components/PageHeader.vue"
|
|
import { errorMessage } from "utils/api"
|
|
import type { AcmHelperItem, ContestSubmissionInfo } from "utils/types"
|
|
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
|
|
}
|
|
|
|
/**
|
|
* ACM 助手行。`acInfo` 的**内容**是 acm_contest_rank.submission_info 的 JSONB 原文,
|
|
* 键名保持 snake_case —— 判题写进去的就是这套键名,见 utils/types.ts 的 ContestSubmissionInfo。
|
|
*/
|
|
type HelperItem = Omit<AcmHelperItem, "acInfo"> & {
|
|
acInfo: ContestSubmissionInfo
|
|
}
|
|
|
|
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.problemId, newChecked)
|
|
// 更新本地状态
|
|
item.checked = newChecked
|
|
item.acInfo.checked = newChecked
|
|
|
|
// 强制触发响应式更新
|
|
submissions.value = [...submissions.value]
|
|
|
|
message.success(newChecked ? "已标记为已检查" : "已取消标记")
|
|
} catch (err) {
|
|
message.error(errorMessage(err, "操作失败"))
|
|
}
|
|
}
|
|
|
|
// 批量标记为已检查
|
|
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.problemId, true)
|
|
item.checked = true
|
|
item.acInfo.checked = true
|
|
}
|
|
|
|
// 强制触发响应式更新
|
|
submissions.value = [...submissions.value]
|
|
|
|
loadingMsg.destroy()
|
|
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
|
|
} catch (err) {
|
|
loadingMsg.destroy()
|
|
message.error(errorMessage(err, "批量操作失败"))
|
|
}
|
|
}
|
|
|
|
// 过滤后的提交列表
|
|
const filteredSubmissions = computed(() => {
|
|
return submissions.value.filter((item) => {
|
|
if (query.username && !item.username.includes(query.username)) return false
|
|
if (query.problemId && !item.problemDisplayId.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,
|
|
problemDisplayId: item.problemDisplayId,
|
|
contestId: props.contestID,
|
|
result: "0", // ACCEPTED
|
|
language: "",
|
|
page: 1,
|
|
offset: 0,
|
|
limit: 1,
|
|
})
|
|
|
|
if (res.results.length === 0) {
|
|
message.warning("未找到该用户的 AC 提交")
|
|
return
|
|
}
|
|
|
|
// 获取提交详情
|
|
const submissionListItem = res.results[0]
|
|
const detailRes = await getSubmission(submissionListItem.id)
|
|
|
|
// 手动添加 contest 字段(ACM模式下后端不返回此字段)
|
|
currentSubmission.value = {
|
|
...detailRes,
|
|
contest: Number(props.contestID),
|
|
problem_display_id: item.problemDisplayId,
|
|
}
|
|
|
|
toggleCodePanel(true)
|
|
} catch (err) {
|
|
message.error(errorMessage(err, "加载提交失败"))
|
|
}
|
|
}
|
|
|
|
// 加载数据
|
|
async function loadData() {
|
|
try {
|
|
// 先获取比赛信息,获取开始时间
|
|
const contestRes = await getContest(props.contestID)
|
|
contestStartTime.value = new Date(contestRes.startTime)
|
|
|
|
// 再获取 AC 提交列表
|
|
const data = await getACMHelperList(Number(props.contestID))
|
|
submissions.value = data
|
|
} catch (err) {
|
|
message.error(errorMessage(err, "加载失败"))
|
|
}
|
|
}
|
|
|
|
const columns: DataTableColumn<HelperItem>[] = [
|
|
{
|
|
title: "用户名",
|
|
key: "username",
|
|
width: 150,
|
|
},
|
|
{
|
|
title: "题目",
|
|
key: "problem_display_id",
|
|
width: 100,
|
|
render: (row) => h(NTag, { type: "info" }, () => row.problemDisplayId),
|
|
},
|
|
{
|
|
title: "AC时间",
|
|
key: "ac_time",
|
|
width: 180,
|
|
render: (row) => formatACTime(row.acInfo.ac_time),
|
|
},
|
|
{
|
|
title: "错误次数",
|
|
key: "error_number",
|
|
width: 100,
|
|
render: (row) =>
|
|
h(
|
|
NTag,
|
|
{
|
|
type: row.acInfo.error_number > 0 ? "warning" : "success",
|
|
size: "small",
|
|
},
|
|
() => row.acInfo.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>
|
|
<PageHeader
|
|
title="比赛辅助检查"
|
|
description="赛后人工审核代码,检查是否存在抄袭、作弊等行为。请逐个查看通过(AC)的提交代码,检查完成后勾选「已检查」。"
|
|
:back="{ name: 'admin contest list' }"
|
|
>
|
|
<template #extra>
|
|
<n-tag type="info" round>总计 {{ stats.total }}</n-tag>
|
|
<n-tag type="success" round>已检查 {{ stats.checked }}</n-tag>
|
|
<n-tag type="warning" round>未检查 {{ stats.unchecked }}</n-tag>
|
|
</template>
|
|
<template #actions>
|
|
<n-button type="primary" :disabled="stats.unchecked === 0" @click="markAllAsChecked">
|
|
标记全部为已检查
|
|
</n-button>
|
|
</template>
|
|
<template #filters>
|
|
<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" />
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<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"
|
|
:submissionID="currentSubmission.id"
|
|
hideList
|
|
@copied="toggleCodePanel(false)"
|
|
/>
|
|
</n-modal>
|
|
</n-flex>
|
|
</template>
|
|
|
|
<style scoped>
|
|
:deep(.n-data-table) {
|
|
margin-top: 16px;
|
|
}
|
|
</style>
|