题目页重构之后按「功能对照 / 状态 / 结果页 / 布局与后端」四路审查了一遍,查出的问题一起修: 提交与换题 - 提交、画流程图途中换题:A 题格式化好的代码会盖掉 B 的草稿,A 判对了把 B 标成已解决、 弹 B 的点评、题单里跳回题单。现在每个请求回来先核对题目,轮询和推送回来的也核对提交编号 - 画着流程图换到不能画的题,语言退到题目的第一门、还被记成偏好;现在回到学生习惯的那门 - 强制点评评完点「继续 / 下一题」,学生之前在看「题目」页签的话会停在原题 - 离开题目页之后判题、流程图的 WebSocket 一直连着,现在闲 15 分钟断开 运行例子 - 输出开头的空格被削掉,打印菱形这类题对的代码也显示「没对上」 - C 的编译警告拼进了「你的输出」(后台生成测试点也会写进 .out) - Python 的语法错误(中文标点)被说成「下标有没有越界、除数是不是 0」 - 比赛里仍然把空格画成「·」、把不同的地方标红 入口与协作 - 比赛里不给「课堂统计」、不绑协作:房间和统计都按题号找,比赛题号和公开题撞号 - 「看他交过什么」按整名匹配,不再混进学号相近的同学 - 协作中点上下文条、相似题、下一题开新标签,协作不断 - 题单入口给管理员的「⋯」里留「本题提交」 结果与题面 - 「去自己输入拿 51 这些数试试」取到了题面 HTML 里的颜色值;「题目原话」露出 " - 流程图 A 级的学生标题下还写着「还没交过」 - 「我的提交」上方补回「答案错误 × 3」这样的计数 - 流程图历次评分能点开看那一版的图;读分数失败不再说「还没交过流程图」;手机上能看流程图点评 - 「结果」页签有新结果没看时加粗上色,看过就恢复 - 空画布拖进第一个节点画布会跳一下;画布底下空着一条 - 比赛没开始写成「已结束」、倒计时超过一个月算错;分隔条拖到头工具栏被挤坏;题单里的题目页有备案页脚 - 流程图历次分数只读需要的几列(一个学生一道题 23 次提交:96 kB → 2 kB) - 比赛密码、比赛没开始等几条学生看得到的英文报错换成中文 设计文档照实际做法更新了第 5.5、5.6、6、8、9、10、11 节。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
755 lines
24 KiB
Vue
755 lines
24 KiB
Vue
<script setup lang="ts">
|
||
import { errorCode } from "utils/api"
|
||
import { NButton, NFlex, NTag, NText } from "naive-ui"
|
||
import { useRouteQuery } from "@vueuse/router"
|
||
import {
|
||
adminRejudge,
|
||
getFlowchartSubmissions,
|
||
getSubmissions,
|
||
getTodaySubmissionCount,
|
||
retryFlowchartSubmission,
|
||
} from "oj/api"
|
||
import { parseTime } from "utils/functions"
|
||
import type { Grade as GradeValue } from "utils/types"
|
||
import type { FlowchartSubmissionListItem, LANGUAGE, SubmissionListItem } from "utils/types"
|
||
import Pagination from "shared/components/Pagination.vue"
|
||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||
import { usePagination } from "shared/composables/pagination"
|
||
import { useUserStore } from "shared/store/user"
|
||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||
import { FlowchartSubmissionStatus } from "utils/types"
|
||
|
||
// 流程图提交的四种状态,列表里原来一列都没有 ——
|
||
// 老师分不出「还在评」和「评失败了」,两种都只是分数栏空着
|
||
const FLOWCHART_STATUS_TAG: Record<
|
||
number,
|
||
{ text: string; type: "default" | "info" | "success" | "error" }
|
||
> = {
|
||
[FlowchartSubmissionStatus.PENDING]: { text: "排队中", type: "default" },
|
||
[FlowchartSubmissionStatus.PROCESSING]: { text: "评分中", type: "info" },
|
||
[FlowchartSubmissionStatus.COMPLETED]: { text: "已完成", type: "success" },
|
||
[FlowchartSubmissionStatus.FAILED]: { text: "评分失败", type: "error" },
|
||
}
|
||
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
|
||
import SubmissionLink from "./components/SubmissionLink.vue"
|
||
import Grade from "./components/Grade.vue"
|
||
import FlowchartLink from "./components/FlowchartLink.vue"
|
||
|
||
// 下面四个组件只在默认关闭的 n-modal 里用,其中两个统计面板还只有老师看得见。
|
||
// 静态 import 会把它们拖进本路由的关键路径——光 chart.js 就 197KB,进页面前必须先下完。
|
||
// 改成异步后本路由增量下载从 675KB / 59 个文件降到 360KB 出头。
|
||
const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue"))
|
||
const FlowchartStatisticsPanel = defineAsyncComponent(
|
||
() => import("shared/components/FlowchartStatisticsPanel.vue"),
|
||
)
|
||
const TodayStatistics = defineAsyncComponent(() => import("./components/TodayStatistics.vue"))
|
||
const SubmissionDetail = defineAsyncComponent(() => import("./detail.vue"))
|
||
const FlowchartScoreDetail = defineAsyncComponent(
|
||
() => import("./components/FlowchartScoreDetail.vue"),
|
||
)
|
||
|
||
interface SubmissionQuery {
|
||
username: string
|
||
result: string
|
||
myself: "0" | "1"
|
||
problem: string
|
||
language: LANGUAGE | ""
|
||
today: "0" | "1"
|
||
}
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const userStore = useUserStore()
|
||
const message = useMessage()
|
||
|
||
const { isMobile, isDesktop } = useBreakpoints()
|
||
|
||
const submissions = ref<SubmissionListItem[]>([])
|
||
const flowcharts = ref<FlowchartSubmissionListItem[]>([])
|
||
const total = ref(0)
|
||
const todayCount = ref(0)
|
||
// 没有它的话,等接口这段时间表格就是一片空白,连转圈都没有
|
||
const loading = ref(false)
|
||
|
||
/**
|
||
* 转圈圈延迟出现。翻页、切筛选大多在一百毫秒内就回来了,直接切数据比「闪一下
|
||
* 转圈再切」看着稳;只有真的慢到 300ms 以上才值得给个反馈。
|
||
*
|
||
* 注意是**单向**延迟:只推迟「显示」,一旦请求结束立刻收掉,不留尾巴。
|
||
*
|
||
* 为什么不用 n-spin 的 delay:n-data-table 压根不走 n-spin,它渲染的是内部的
|
||
* _internal/loading,直接由 loading 控制、没有 delay 参数。要用内置的就得把表格
|
||
* 包进 <n-spin :show :delay> 再去掉 :loading —— 那会换掉加载样式,还会丢掉
|
||
* loading 时锁住排序/分页交互(DataTable 里那个 `disabled: this.loading`)。
|
||
* 下面这几行就是 n-spin 内部同样的写法(watchEffect + setTimeout + 清理)。
|
||
*/
|
||
const LOADING_DELAY = 300
|
||
const showLoading = ref(false)
|
||
let loadingTimer: ReturnType<typeof setTimeout> | undefined
|
||
watch(loading, (value) => {
|
||
clearTimeout(loadingTimer)
|
||
if (!value) {
|
||
showLoading.value = false
|
||
return
|
||
}
|
||
loadingTimer = setTimeout(() => (showLoading.value = true), LOADING_DELAY)
|
||
})
|
||
onUnmounted(() => clearTimeout(loadingTimer))
|
||
|
||
// 使用分页 composable
|
||
const { query, clearQuery } = usePagination<SubmissionQuery>({
|
||
username: useRouteQuery("username", "").value,
|
||
result: useRouteQuery("result", "").value,
|
||
myself: useRouteQuery("myself", "0").value,
|
||
problem: useRouteQuery("problem", "").value,
|
||
language: useRouteQuery("language", "").value,
|
||
today: "0",
|
||
})
|
||
/**
|
||
* 从题目页协作条「看他交过什么」点进来时带着 `exactUsername=1`:用户名按整名匹配。
|
||
* 默认是「包含」,而学号互相包含是常态(帮 ks24a1 时会混进 ks24a10–ks24a19)。
|
||
* 只对带进来的那个用户名生效,老师在输入框里改了名字就回到原来的包含匹配
|
||
*/
|
||
const exactUsernameFor =
|
||
useRouteQuery<string>("exactUsername", "").value === "1" ? query.username : ""
|
||
const submissionID = ref("")
|
||
const problemDisplayID = ref("")
|
||
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
|
||
// 「今日提交数」旁边那颗「统计」按钮的弹框
|
||
const [todayPanel, toggleTodayPanel] = useToggle(false)
|
||
|
||
const [codePanel, toggleCodePanel] = useToggle(false)
|
||
const [scoreDetailPanel, toggleScoreDetailPanel] = useToggle(false)
|
||
const selectedFlowchartId = ref("")
|
||
const selectedFlowchart = computed(() => {
|
||
return flowcharts.value.find((f) => f.id === selectedFlowchartId.value)
|
||
})
|
||
|
||
const resultOptions: SelectOption[] = [
|
||
{ label: "全部", value: "" },
|
||
{ label: "答案正确", value: "0" },
|
||
{ label: "语法未通过", value: "10" },
|
||
{ label: "答案错误", value: "-1" },
|
||
{ label: "编译失败", value: "-2" },
|
||
{ label: "运行时错误", value: "4" },
|
||
]
|
||
|
||
const gradeOptions: SelectOption[] = [
|
||
{ label: "全部", value: "" },
|
||
{ label: "S级", value: "S" },
|
||
{ label: "A级", value: "A" },
|
||
{ label: "B级", value: "B" },
|
||
{ label: "C级", value: "C" },
|
||
]
|
||
|
||
const languageOptions: SelectOption[] = [
|
||
{ label: "流程图", value: "Flowchart" },
|
||
{ label: "全部语言", value: "" },
|
||
{ label: "Python", value: "Python" },
|
||
{ label: "C语言", value: "C" },
|
||
{ label: "C++", value: "C++" },
|
||
]
|
||
async function listSubmissions() {
|
||
if (query.page < 1) query.page = 1
|
||
const offset = query.limit * (query.page - 1)
|
||
loading.value = true
|
||
try {
|
||
if (query.language === "Flowchart") {
|
||
const res = await getFlowchartSubmissions({
|
||
username: query.username,
|
||
problemDisplayId: query.problem,
|
||
myself: query.myself,
|
||
offset,
|
||
limit: query.limit,
|
||
today: query.today,
|
||
grade: query.result,
|
||
})
|
||
total.value = res.total
|
||
flowcharts.value = res.results
|
||
} else {
|
||
const res = await getSubmissions({
|
||
...query,
|
||
offset,
|
||
problemDisplayId: query.problem,
|
||
contestId: (route.params.contestID as string) ?? "",
|
||
language: query.language,
|
||
today: query.today,
|
||
...(exactUsernameFor && query.username === exactUsernameFor
|
||
? { exactUsername: "1" as const }
|
||
: {}),
|
||
})
|
||
submissions.value = res.results
|
||
total.value = res.total
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
consumePendingJump()
|
||
}
|
||
|
||
async function getTodayCount() {
|
||
const res = await getTodaySubmissionCount(query.language)
|
||
todayCount.value = res
|
||
}
|
||
|
||
onMounted(() => {
|
||
listSubmissions()
|
||
if (route.name === "submissions") {
|
||
getTodayCount()
|
||
}
|
||
})
|
||
|
||
function search(username: string, problem: string) {
|
||
query.username = username
|
||
query.problem = problem
|
||
}
|
||
|
||
function clear() {
|
||
clearQuery()
|
||
}
|
||
|
||
async function rejudge(submissionID: string) {
|
||
await adminRejudge(submissionID)
|
||
message.success("重新判分成功")
|
||
listSubmissions()
|
||
}
|
||
|
||
async function retryFlowchart(submissionId: string) {
|
||
// 后端会拒掉「还在评分中」的提交(409 retry-not-allowed),也可能撞上限流。
|
||
// 不兜住的话拦截器静默 reject,老师点下去完全没反应。
|
||
// 按错误码分支而不是 match 文案(见 utils/api.ts 的约定)——后端文案是英文的,
|
||
// 直接弹给老师看不合适
|
||
const retryTips: Record<string, string> = {
|
||
"retry-not-allowed": "这条还在评分中,等出了结果再重新评分",
|
||
"too-many-submissions": "操作太频繁了,缓一下再试",
|
||
"flowchart-not-found": "提交不存在,或者没有权限",
|
||
}
|
||
try {
|
||
await retryFlowchartSubmission(submissionId)
|
||
} catch (err) {
|
||
message.error(retryTips[errorCode(err) ?? ""] ?? "重新评分失败")
|
||
return
|
||
}
|
||
message.success("重新评分已提交")
|
||
listSubmissions()
|
||
}
|
||
|
||
function problemClicked(row: SubmissionListItem | FlowchartSubmissionListItem) {
|
||
if (route.name === "contest submissions") {
|
||
const path = router.resolve({
|
||
name: "contest problem",
|
||
params: {
|
||
problemID: row.problemDisplayId,
|
||
},
|
||
})
|
||
window.open(path.href, "_blank")
|
||
} else {
|
||
window.open("/problem/" + row.problemDisplayId, "_blank")
|
||
}
|
||
}
|
||
|
||
// 今日统计弹框里点题目。那颗按钮只在 route.name === "submissions" 上出现
|
||
// (今日提交数本身就只在那一页拉),所以不用管比赛里的题目路由
|
||
function openProblem(displayId: string) {
|
||
window.open("/problem/" + displayId, "_blank")
|
||
}
|
||
|
||
function showCodePanel(id: string, problem: string) {
|
||
toggleCodePanel(true)
|
||
submissionID.value = id
|
||
problemDisplayID.value = problem
|
||
}
|
||
|
||
/**
|
||
* 代码详情的键盘翻阅。老师看一个班的提交时原来是「点开 → 看 → 关掉 → 再点下一行」,
|
||
* 这里让弹框开着就能上下切换,走到本页头尾自动翻页续上。
|
||
*
|
||
* 只在**能看代码**的行之间走 —— showLink 是后端按题单规则算出来的,
|
||
* 跳到一条看不了的上面只会得到一个空弹框。
|
||
*/
|
||
const viewableSubmissions = computed(() => submissions.value.filter((row) => row.showLink))
|
||
const currentCodeIndex = computed(() =>
|
||
viewableSubmissions.value.findIndex((row) => row.id === submissionID.value),
|
||
)
|
||
const maxPage = computed(() => Math.ceil(total.value / query.limit))
|
||
|
||
// 翻页是异步的,所以先记下「落地后停在哪一头」,等新一页的数据回来再开
|
||
let pendingJump: "first" | "last" | null = null
|
||
|
||
function consumePendingJump() {
|
||
const jump = pendingJump
|
||
pendingJump = null
|
||
if (!jump || !codePanel.value) return
|
||
const list = viewableSubmissions.value
|
||
const row = jump === "first" ? list[0] : list[list.length - 1]
|
||
// 整页都是看不了代码的(题单没做出来那批),此时保持弹框里原来那条不动
|
||
if (!row) {
|
||
message.info("这一页没有可以查看的代码")
|
||
return
|
||
}
|
||
showCodePanel(row.id, row.problemDisplayId)
|
||
}
|
||
|
||
function moveCodePanel(step: 1 | -1) {
|
||
const index = currentCodeIndex.value
|
||
if (index === -1) return
|
||
const next = viewableSubmissions.value[index + step]
|
||
if (next) {
|
||
showCodePanel(next.id, next.problemDisplayId)
|
||
return
|
||
}
|
||
const page = query.page + step
|
||
if (page < 1 || page > maxPage.value) return
|
||
pendingJump = step > 0 ? "first" : "last"
|
||
query.page = page // 监听器会去 listSubmissions,回来后 consumePendingJump 接手
|
||
}
|
||
|
||
// 上下(和左右)翻阅代码详情。Esc 关弹框是 n-modal 自带的,不用管。
|
||
// 弹框没开就什么都不做,页面正常滚动
|
||
onKeyStroke(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"], (e: KeyboardEvent) => {
|
||
if (!codePanel.value) return
|
||
// 焦点在输入框里(弹框底下那几个筛选框)时不抢方向键
|
||
const target = e.target as HTMLElement | null
|
||
if (
|
||
target &&
|
||
(target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)
|
||
) {
|
||
return
|
||
}
|
||
e.preventDefault()
|
||
moveCodePanel(e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : 1)
|
||
})
|
||
|
||
function showScoreDetail(id: string) {
|
||
selectedFlowchartId.value = id
|
||
toggleScoreDetailPanel(true)
|
||
}
|
||
|
||
function getGradeType(grade?: string) {
|
||
if (!grade) return "default"
|
||
if (grade === "S") return "primary"
|
||
if (grade === "A") return "info"
|
||
if (grade === "B") return "warning"
|
||
return "error"
|
||
}
|
||
|
||
// 监听用户名和题号变化(防抖)
|
||
watchDebounced(() => [query.username, query.problem], listSubmissions, {
|
||
debounce: 500,
|
||
maxWait: 1000,
|
||
})
|
||
|
||
// 监听其他查询条件变化
|
||
watch(
|
||
() => [query.page, query.limit, query.myself, query.result, query.language, query.today],
|
||
listSubmissions,
|
||
)
|
||
|
||
// 切换语言时重置过滤条件,刷新今日提交数
|
||
watch(
|
||
() => query.language,
|
||
() => {
|
||
query.result = ""
|
||
if (route.name === "submissions") getTodayCount()
|
||
},
|
||
)
|
||
|
||
// 登录状态变化后刷新提交列表,更新提交编号列的可点击状态。
|
||
// 今日提交数不看登录态,onMounted 那次就够了,这里不用再拉一遍。
|
||
watch(
|
||
() => userStore.isAuthed,
|
||
() => listSubmissions(),
|
||
)
|
||
|
||
const columns = computed(() => {
|
||
const res: DataTableColumn<SubmissionListItem>[] = [
|
||
{
|
||
title: "提交时间",
|
||
key: "create_time",
|
||
minWidth: 200,
|
||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
||
},
|
||
{
|
||
title: "提交编号",
|
||
key: "id",
|
||
minWidth: 200,
|
||
render: (row) =>
|
||
h(SubmissionLink, {
|
||
submission: row,
|
||
onShowCode: () => showCodePanel(row.id, row.problemDisplayId),
|
||
}),
|
||
},
|
||
{
|
||
title: "状态",
|
||
key: "status",
|
||
minWidth: 140,
|
||
render: (row) => h(SubmissionResultTag, { result: row.result }),
|
||
},
|
||
{
|
||
title: "题目",
|
||
key: "problem",
|
||
minWidth: 360,
|
||
render: (row) => {
|
||
const problem = h(
|
||
ButtonWithSearch,
|
||
{
|
||
type: "题目",
|
||
onClick: () => problemClicked(row),
|
||
onSearch: () => (query.problem = row.problemDisplayId),
|
||
},
|
||
() => `${row.problemDisplayId} ${row.problemTitle}`,
|
||
)
|
||
// 从题单入口做出来的提交才有这个标记(后端只在提交时记来源),
|
||
// 同一道题从普通题库刷的不会带。老提交里只有当年首次 AC 那条有
|
||
const problemSet = row.problemSet
|
||
if (!problemSet) return problem
|
||
return h(NFlex, { align: "center", size: 8, wrap: false }, () => [
|
||
problem,
|
||
h(
|
||
NTag,
|
||
{
|
||
size: "small",
|
||
round: true,
|
||
type: "info",
|
||
bordered: false,
|
||
style: { cursor: "pointer", flexShrink: 0 },
|
||
onClick: () => window.open("/problemset/" + problemSet.id, "_blank"),
|
||
},
|
||
() => "题单 " + problemSet.title,
|
||
),
|
||
])
|
||
},
|
||
},
|
||
{
|
||
title: "语言",
|
||
key: "language",
|
||
minWidth: 120,
|
||
render: (row) => LANGUAGE_SHOW_VALUE[row.language],
|
||
},
|
||
{
|
||
title: "用户",
|
||
key: "username",
|
||
minWidth: 200,
|
||
render: (row) =>
|
||
h(
|
||
ButtonWithSearch,
|
||
{
|
||
type: "用户",
|
||
username: row.username,
|
||
onClick: () => window.open("/user?name=" + row.username, "_blank"),
|
||
onSearch: () => (query.username = row.username),
|
||
onFilterClass: (classname: string) => (query.username = classname),
|
||
},
|
||
() => row.username,
|
||
),
|
||
},
|
||
]
|
||
if (!route.params.contestID && userStore.isTeacherOrAbove) {
|
||
res.push({
|
||
title: "选项",
|
||
key: "rejudge",
|
||
render: (row) =>
|
||
h(
|
||
NButton,
|
||
{
|
||
quaternary: true,
|
||
size: "small",
|
||
type: "primary",
|
||
onClick: () => rejudge(row.id),
|
||
},
|
||
() => "重新判题",
|
||
),
|
||
})
|
||
}
|
||
return res
|
||
})
|
||
|
||
const flowchartColumns = computed(() => {
|
||
const res: DataTableColumn<FlowchartSubmissionListItem>[] = [
|
||
{
|
||
title: "提交时间",
|
||
key: "create_time",
|
||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
||
},
|
||
{
|
||
title: "提交编号",
|
||
key: "id",
|
||
render: (row) =>
|
||
h(FlowchartLink, {
|
||
flowchart: row,
|
||
onShowDetail: (id: string) => showScoreDetail(id),
|
||
}),
|
||
},
|
||
{
|
||
title: "题目",
|
||
key: "problem_title",
|
||
render: (row) =>
|
||
h(
|
||
ButtonWithSearch,
|
||
{
|
||
type: "题目",
|
||
onClick: () => problemClicked(row),
|
||
onSearch: () => (query.problem = row.problemDisplayId),
|
||
},
|
||
() => `${row.problemDisplayId} ${row.problemTitle}`,
|
||
),
|
||
},
|
||
{
|
||
title: "状态",
|
||
key: "status",
|
||
render: (row) => {
|
||
const tag = FLOWCHART_STATUS_TAG[row.status]
|
||
return h(
|
||
NTag,
|
||
{ size: "small", round: true, type: tag?.type ?? "default" },
|
||
() => tag?.text ?? "未知",
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: "评分",
|
||
key: "ai_score",
|
||
// 只有评完的才有分数。没评完也渲染 Grade 的话会显示成 0 分,
|
||
// 看着像「评了但得了 0 分」
|
||
render: (row) =>
|
||
row.status === FlowchartSubmissionStatus.COMPLETED
|
||
? h(Grade, {
|
||
score: row.aiScore ?? 0,
|
||
grade: (row.aiGrade ?? "") as GradeValue,
|
||
})
|
||
: h(NText, { depth: 3 }, () => "—"),
|
||
},
|
||
{
|
||
title: "用户",
|
||
key: "username",
|
||
minWidth: 200,
|
||
render: (row) =>
|
||
h(
|
||
ButtonWithSearch,
|
||
{
|
||
type: "用户",
|
||
username: row.username,
|
||
onClick: () => window.open("/user?name=" + row.username, "_blank"),
|
||
onSearch: () => (query.username = row.username),
|
||
onFilterClass: (classname: string) => (query.username = classname),
|
||
},
|
||
() => row.username,
|
||
),
|
||
},
|
||
]
|
||
if (!route.params.contestID && userStore.isTeacherOrAbove) {
|
||
res.push({
|
||
title: "选项",
|
||
key: "retry",
|
||
// 后端只接受已完成 / 已失败的重判(其余返回 409),这里同步置灰,
|
||
// 免得老师点了才发现不行
|
||
render: (row) =>
|
||
h(
|
||
NButton,
|
||
{
|
||
quaternary: true,
|
||
size: "small",
|
||
type: "primary",
|
||
disabled:
|
||
row.status !== FlowchartSubmissionStatus.COMPLETED &&
|
||
row.status !== FlowchartSubmissionStatus.FAILED,
|
||
onClick: () => retryFlowchart(row.id),
|
||
},
|
||
() => "重新判题",
|
||
),
|
||
})
|
||
}
|
||
return res
|
||
})
|
||
</script>
|
||
<template>
|
||
<n-flex vertical size="large">
|
||
<n-space>
|
||
<n-form :show-feedback="false" inline label-placement="left">
|
||
<n-form-item v-if="isDesktop && userStore.isAuthed" label="只看自己">
|
||
<n-switch v-model:value="query.myself" checked-value="1" unchecked-value="0" />
|
||
</n-form-item>
|
||
<n-form-item label="语言" v-if="route.name !== 'contest submissions'">
|
||
<n-select class="select" v-model:value="query.language" :options="languageOptions" />
|
||
</n-form-item>
|
||
<n-form-item :label="query.language === 'Flowchart' ? '等级' : '状态'">
|
||
<n-select
|
||
class="select"
|
||
v-model:value="query.result"
|
||
:options="query.language === 'Flowchart' ? gradeOptions : resultOptions"
|
||
/>
|
||
</n-form-item>
|
||
</n-form>
|
||
<n-form :show-feedback="false" inline label-placement="left">
|
||
<n-form-item>
|
||
<n-input
|
||
:disabled="query.myself === '1'"
|
||
style="width: 140px"
|
||
clearable
|
||
v-model:value="query.username"
|
||
placeholder="用户"
|
||
/>
|
||
</n-form-item>
|
||
<n-form-item>
|
||
<n-input
|
||
style="width: 120px"
|
||
clearable
|
||
v-model:value="query.problem"
|
||
placeholder="题号"
|
||
/>
|
||
</n-form-item>
|
||
</n-form>
|
||
<n-form :show-feedback="false" inline label-placement="left">
|
||
<n-form-item v-if="isMobile && userStore.isAuthed" label="只看自己">
|
||
<n-switch v-model:value="query.myself" checked-value="1" unchecked-value="0" />
|
||
</n-form-item>
|
||
<n-form-item>
|
||
<n-button @click="search(query.username, query.problem)"> 搜索 </n-button>
|
||
</n-form-item>
|
||
<n-form-item>
|
||
<n-button @click="clear" quaternary>重置</n-button>
|
||
</n-form-item>
|
||
<n-form-item v-if="userStore.isTeacherOrAbove && route.name === 'submissions'">
|
||
<n-button quaternary type="warning" @click="toggleStatisticPanel(true)">
|
||
数据统计
|
||
</n-button>
|
||
</n-form-item>
|
||
</n-form>
|
||
<n-flex v-if="todayCount > 0" align="center" :size="8">
|
||
<n-tag
|
||
checkable
|
||
:checked="query.today === '1'"
|
||
type="success"
|
||
size="large"
|
||
@update:checked="(v: boolean) => (query.today = v ? '1' : '0')"
|
||
>
|
||
<n-gradient-text v-if="query.today !== '1'" type="success">
|
||
今日提交数:{{ todayCount }}
|
||
</n-gradient-text>
|
||
<template v-else>今日提交数:{{ todayCount }}</template>
|
||
</n-tag>
|
||
<!--
|
||
筛到今天之后才出现。流程图那档不给 —— 这个统计只算代码提交
|
||
(流程图提交在另一张表、只有 AI 评级没有判题状态),点开会是一份
|
||
对不上标签数字的统计。
|
||
-->
|
||
<n-button
|
||
v-if="query.today === '1' && query.language !== 'Flowchart'"
|
||
quaternary
|
||
type="success"
|
||
@click="toggleTodayPanel(true)"
|
||
>
|
||
统计
|
||
</n-button>
|
||
</n-flex>
|
||
</n-space>
|
||
<n-data-table
|
||
v-if="query.language === 'Flowchart'"
|
||
:bordered="false"
|
||
:columns="flowchartColumns"
|
||
:data="flowcharts"
|
||
:loading="showLoading"
|
||
/>
|
||
<n-data-table
|
||
v-else
|
||
:bordered="false"
|
||
:columns="columns"
|
||
:data="submissions"
|
||
:loading="showLoading"
|
||
/>
|
||
</n-flex>
|
||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||
<n-modal
|
||
v-if="userStore.isTeacherOrAbove"
|
||
v-model:show="statisticPanel"
|
||
preset="card"
|
||
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
|
||
:content-style="{ overflow: 'auto' }"
|
||
:title="query.language === 'Flowchart' ? '流程图提交的统计' : '提交记录的统计'"
|
||
>
|
||
<FlowchartStatisticsPanel
|
||
v-if="query.language === 'Flowchart'"
|
||
:problem="query.problem"
|
||
:username="query.username"
|
||
/>
|
||
<StatisticsPanel v-else :problem="query.problem" :username="query.username" />
|
||
</n-modal>
|
||
<n-modal
|
||
v-model:show="todayPanel"
|
||
preset="card"
|
||
:style="{ maxWidth: isDesktop && '700px', maxHeight: '80vh' }"
|
||
:content-style="{ overflow: 'auto' }"
|
||
title="今日提交统计"
|
||
>
|
||
<TodayStatistics @open-problem="(id: string) => openProblem(id)" />
|
||
</n-modal>
|
||
<n-modal
|
||
v-model:show="codePanel"
|
||
preset="card"
|
||
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
||
:content-style="{ overflow: 'auto' }"
|
||
>
|
||
<template #header>
|
||
<n-flex align="center" :size="12">
|
||
<n-text>代码详情</n-text>
|
||
<n-text v-if="isDesktop && viewableSubmissions.length > 1" depth="3">
|
||
<span class="shortcut-hint">
|
||
本页第 {{ currentCodeIndex + 1 }} / {{ viewableSubmissions.length }}
|
||
条 · ↑ ↓ ← → 切换上下一条 · Esc 关闭
|
||
</span>
|
||
</n-text>
|
||
</n-flex>
|
||
</template>
|
||
<!--
|
||
key 换掉才会重新拉代码 —— detail.vue 只在 onMounted 里取一次,
|
||
键盘切换时不换 key 的话弹框里还是上一条的代码
|
||
-->
|
||
<SubmissionDetail
|
||
:key="submissionID"
|
||
:problemID="problemDisplayID"
|
||
:submissionID="submissionID"
|
||
hideList
|
||
@copied="toggleCodePanel(false)"
|
||
/>
|
||
</n-modal>
|
||
<n-modal
|
||
v-model:show="scoreDetailPanel"
|
||
preset="card"
|
||
:style="{ maxWidth: isDesktop && '1000px', maxHeight: '80vh' }"
|
||
:content-style="{ overflow: 'auto' }"
|
||
>
|
||
<template #header>
|
||
<n-flex align="center">
|
||
<n-text>流程图评分详情</n-text>
|
||
<n-text v-if="selectedFlowchart" :type="getGradeType(selectedFlowchart.aiGrade ?? '')">
|
||
{{ selectedFlowchart.aiScore }}分 {{ selectedFlowchart.aiGrade }}级
|
||
</n-text>
|
||
</n-flex>
|
||
</template>
|
||
<FlowchartScoreDetail :submissionId="selectedFlowchartId" />
|
||
</n-modal>
|
||
</template>
|
||
<style scoped>
|
||
.select {
|
||
width: 120px;
|
||
}
|
||
|
||
.code {
|
||
font-size: 20px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.shortcut-hint {
|
||
font-size: 13px;
|
||
font-weight: normal;
|
||
}
|
||
|
||
.flowchart-iframe {
|
||
width: 100%;
|
||
height: 100%;
|
||
border: none;
|
||
display: block;
|
||
}
|
||
</style>
|