feat(提交详情): 单独打开的一条提交改成左边代码、右边为什么错 + 这道题交的每一次
Deploy / deploy (push) Canceled after 0s

- 原来不显示是哪道题、时间只到日、答案错误不说为什么,测试点表 5 行一模一样
- 右边摆题目页那块例子对比(输入 / 正确输出 / 他的输出,差的字标红),编译失败、运行时错误同提交列表右栏的说法,代码里标出那一行
- 「这道题交了 N 次」常驻右栏,← → 一次次翻,看他是怎么改对的;题单藏着的那几次点不进
- 老师:「他的全部提交」和「⋯」(复制两项、重新判题);测试点方块悬停看用时和内存;判题中自己刷新
- 别人的代码、链接不对原来一直转圈,现在说清楚是哪种;自己在题单布置期内的照样看得到是哪道题、什么结果
- 后端详情多给题目名、班级、题单,`?attempts=1` 带上同题的每一次;看不到别人的回 403 而不是 404;提交者本人也拿得到 contestId(比赛里的题号是比赛自己的)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 01:00:22 +08:00
1 parent 4240a451e4
commit df76e7f101
8 files changed
+989 -258

No files matched your search

+73 -18
View File
@@ -384,48 +384,99 @@ function listCaseSummary(row: {
return { passed: row.casesPassed ?? 0, total: row.casesTotal }
}
async function submissionDetail(id: string, user: AuthUser) {
/**
* 提交详情。三种结局:没有这条(404)、别人的代码看不到(403)、能看 —— 能看里又分自己的
* 代码在题单布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。
*/
async function submissionDetail(id: string, user: AuthUser, withAttempts: boolean) {
const [row] = await db
.select({
submission: schema.submission,
problem: schema.problem,
contest: schema.contest,
// 当前用户名 / 班级,和提交列表同口径;已删号的学生 user 表里没有行
currentUsername: schema.user.username,
className: schema.user.className,
problemSetTitle: schema.problemset.title,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id))
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.leftJoin(schema.problemset, eq(schema.problemset.id, schema.submission.problemsetId))
.where(eq(schema.submission.id, id))
.limit(1)
if (!row) return null
if (!row) return { status: "not-found" } as const
const own = row.submission.userId === user.id
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103)
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
const cutoffs =
isTeacherOrAbove(user) || row.submission.userId !== user.id
isTeacherOrAbove(user) || !own
? undefined
: await problemSetLockCutoffs(user.id, [row.submission.problemId])
if (!canViewSubmission(user, row.submission, row.problem, cutoffs)) return null
const visible = canViewSubmission(user, row.submission, row.problem, cutoffs)
// 走到这里还看不了、又是自己的,只剩题单布置期这一种(见 canViewSubmission)
if (!visible && !own) return { status: "forbidden" } as const
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
const full = isAdminRole(user)
const full = isAdminRole(user) && visible
const attempts = withAttempts
? await db
.select({
id: schema.submission.id,
createTime: schema.submission.createTime,
userId: schema.submission.userId,
problemId: schema.submission.problemId,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId),
row.submission.contestId === null
? isNull(schema.submission.contestId)
: eq(schema.submission.contestId, row.submission.contestId),
),
)
.orderBy(schema.submission.createTime, schema.submission.id)
.limit(200)
: null
return {
status: "ok",
data: {
id: row.submission.id,
createTime: row.submission.createTime,
userId: row.submission.userId,
username: row.submission.username,
code: row.submission.code,
username: row.currentUsername ?? row.submission.username,
code: visible ? row.submission.code : "",
result: row.submission.result,
info: full ? row.submission.info : {},
language: row.submission.language,
statisticInfo: asRecord(row.submission.statisticInfo),
// contest 也在旧后端的排除名单里,同样只给管理员
contestId: full ? row.submission.contestId : null,
statisticInfo: visible ? asRecord(row.submission.statisticInfo) : {},
// contest 也在旧后端的排除名单里,同样只给管理员。提交者本人例外:他知道自己在哪场比赛
// 里交的,不给的话独立页面上「复制回到题目」「题目链接」只能拼出题库的
// /problem/<题号> —— 比赛里的题号是比赛自己的,指到的是另一道题
contestId: full || own ? row.submission.contestId : null,
problemId: row.submission.problemId,
// problem 表本来就 join 了,不额外查库
problemDisplayId: row.problem.displayId,
showLink: true,
caseSummary: caseSummary(row.submission),
} satisfies SubmissionDetail
problemTitle: row.problem.title,
className: row.className,
problemSet:
row.submission.problemsetId !== null && row.problemSetTitle !== null
? { id: row.submission.problemsetId, title: row.problemSetTitle }
: null,
showLink: visible,
caseSummary: visible ? caseSummary(row.submission) : null,
attempts:
attempts?.map((item) => ({
id: item.id,
createTime: item.createTime,
result: item.result,
showLink: canViewSubmission(user, item, row.problem, cutoffs),
})) ?? null,
} satisfies SubmissionDetail,
} as const
}
/**
@@ -688,9 +739,13 @@ submissionRoutes.get(
submissionRoutes.get("/submissions/:id", requireAuth, async (c) => {
const user = c.get("user")!
const data = await submissionDetail(c.req.param("id"), user)
if (!data) {
const detail = await submissionDetail(c.req.param("id"), user, c.req.query("attempts") === "1")
if (detail.status === "not-found") {
return failure(c, 404, "submission-not-found", "Submission does not exist")
}
return success(c, data)
// 原来看不到也回 404,独立页面分不清「链接不对」和「别人的」,只能一直转圈
if (detail.status === "forbidden") {
return failure(c, 403, "submission-forbidden", "No permission to view this submission")
}
return success(c, detail.data)
})
+5 -2
View File
@@ -131,10 +131,13 @@ export function getProblemStats(problemID: number) {
return api.get<ProblemStats>(`problems/${problemID}/stats`)
}
export async function getSubmission(id: string): Promise<Submission> {
/** `attempts`:带上这个人这道题交过的每一次(独立的提交详情页用,见契约 attempts) */
export async function getSubmission(id: string, attempts = false): Promise<Submission> {
const [{ submissionDetailSchema }, response] = await Promise.all([
loadDetailSchemas(),
api.get<unknown>(`submissions/${encodeURIComponent(id)}`),
api.get<unknown>(`submissions/${encodeURIComponent(id)}`, {
params: attempts ? { attempts: "1" } : undefined,
}),
])
return contract("GET /submissions/:id", submissionDetailSchema, response)
}
@@ -31,6 +31,11 @@ const props = defineProps<{
* 格式错、漏了一行这类错误,对着题目原话一看就明白;运行例子那边不需要
*/
full?: boolean
/**
* 看的是别人的提交(老师在独立的提交详情页看学生的):「你的输出」「你的程序」都换成「他」。
* 提示句全是对学生说的第二人称,整句换掉人称就读得通
*/
peer?: boolean
}>()
const theme = useThemeVars()
@@ -63,9 +68,10 @@ const hint = computed(() => {
if (props.check.passed) return ""
if (!kind.value) {
const name = JUDGE_STATUS[props.check.result as keyof typeof JUDGE_STATUS]?.name ?? "出错了"
return `你的程序在这个例子上就「${name}」了,先让它在例子上跑通。`
return `${props.peer ? "他" : "你"}的程序在这个例子上就「${name}」了,先让它在例子上跑通。`
}
return explainDiff(kind.value, props.check, props.code, props.language)
const text = explainDiff(kind.value, props.check, props.code, props.language)
return props.peer ? text.replace(/你/g, "他") : text
})
const diffLine = computed(() =>
firstDifferentLine(props.check.expected ?? "", props.check.output ?? ""),
@@ -160,7 +166,7 @@ function lines(text: string) {
</div>
</div>
<div class="block">
<div class="label">你的输出</div>
<div class="label">{{ peer ? "他" : "你" }}的输出</div>
<div v-if="check.output" class="text">
<template v-for="(line, i) in lines(check.output)" :key="i">
<div v-if="i === diffLine && diffParts?.output" class="line">
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import type { SubmissionExplain } from "../composables/explain"
/**
* 「第 9 行:这一行末尾少了分号」那一条,原始报错收在右边的「原始报错」里。
* 提交列表右栏和独立的提交详情页共用,说法见 composables/explain.ts
*/
const props = defineProps<{
explain: SubmissionExplain
tone: { background: string; color: string }
}>()
const theme = useThemeVars()
const rawOpen = ref(false)
// 换了一条就收起来
watch(
() => props.explain,
() => (rawOpen.value = false),
)
</script>
<template>
<div class="explain" :style="{ background: tone.background, borderColor: tone.background }">
<div class="explain-line">
<b :style="{ color: tone.color }">
<template v-if="explain.line">第 {{ explain.line }} 行:</template>{{ explain.text }}
</b>
<div class="spacer"></div>
<a v-if="explain.raw" href="#" class="raw-toggle" @click.prevent="rawOpen = !rawOpen">
原始报错
<Icon :icon="rawOpen ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" :width="11" />
</a>
</div>
<pre v-if="rawOpen && explain.raw" class="raw">{{ explain.raw }}</pre>
</div>
</template>
<style scoped>
.explain {
padding: 10px 14px;
border-radius: 6px;
border: 1px solid;
display: flex;
flex-direction: column;
gap: 8px;
}
.explain-line {
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 1.6;
}
.spacer {
flex: 1 1 0;
}
.raw-toggle {
flex: none;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 3px;
color: v-bind("theme.textColor3");
text-decoration: none;
}
.raw {
margin: 0;
max-height: 140px;
overflow: auto;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -3,9 +3,6 @@ import LockNote from "oj/problemset/components/LockNote.vue"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { getSubmission } from "oj/api"
import { explainCCompileError } from "oj/problem/utils/cError"
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { useUserStore } from "shared/store/user"
import {
JUDGE_STATUS,
@@ -16,9 +13,11 @@ import {
import { submissionCaseResults } from "utils/functions"
import type { Submission, SubmissionListItem } from "utils/types"
import { useCopySubmission } from "../composables/copySubmission"
import { useSubmissionExplain } from "../composables/explain"
import { useTone } from "../composables/tone"
import { classLabel, submissionTimeText } from "../utils"
import CodeView from "./CodeView.vue"
import ExplainNote from "./ExplainNote.vue"
import StatusPill from "./StatusPill.vue"
import UserName from "shared/components/UserName.vue"
@@ -118,87 +117,7 @@ const locked = computed(() => {
/** 逐个测试点。只有管理员拿得到 info(每个点带 output_md5),学生只有 caseSummary 两个数 */
const cases = computed(() => submissionCaseResults(shown.value?.info))
function stripJudgePath(text: string) {
return text.replace(/\/judger\/run\/[^/"]+\//g, "")
}
/**
* 编译失败 / 运行时错误说清楚「错在第几行、为什么」—— 翻译表和题目页结果区同一份
* (pythonError.ts / cError.ts / runtimeError.ts)。原来的看代码弹框压根不显示报错。
*/
const explain = computed(() => {
const s = shown.value
if (!s) return null
const info = s.statisticInfo
const err = info?.err_info ? stripJudgePath(info.err_info) : ""
switch (s.result) {
case SubmissionStatus.compile_error: {
const ex = !err
? null
: s.language === "Python"
? explainPythonCompileError(err, s.code)
: s.language === "C" || s.language === "C++"
? explainCCompileError(err, s.code, s.language)
: null
if (ex) return { line: ex.line, text: ex.message, raw: err }
return { line: null, text: "编译没通过,看看下面的原始报错。", raw: err }
}
case SubmissionStatus.runtime_error: {
const rt = info?.runtime_error
if (rt) return { line: rt.line ?? null, text: explainRuntimeError(rt, s.code), raw: err }
return { line: null, text: "程序运行到一半出错,停下来了。", raw: err }
}
case SubmissionStatus.cpu_time_limit_exceeded:
case SubmissionStatus.real_time_limit_exceeded:
return {
line: null,
text: "程序跑了太久还没结束:可能有死循环,或者在等一个没有给的输入。",
raw: "",
}
case SubmissionStatus.memory_limit_exceeded:
return { line: null, text: "程序占的内存太多了。", raw: "" }
case SubmissionStatus.system_error:
return { line: null, text: "判题机出错了,不是代码的问题。", raw: err }
case SubmissionStatus.wrong_answer: {
const check = info?.sample_check
if (!check) return null
// 「题目里的」不能省:上面那排格子是测试点,也从 1 编号,只写「例子 1」时老师看到
// 格子 1 是绿的,以为说明写反了(例子 1 是题面样例,和测试点 1 不是一组数据)
const text = check.passed
? "题目里的例子都对,错在没公开的测试点上。"
: `题目里的例子 ${(check.index ?? 0) + 1} 就错了。`
return { line: null, text, raw: "" }
}
case SubmissionStatus.ast_check_failed: {
const failedRules = (info?.ast_results ?? []).filter((rule) => !rule.passed)
const text = failedRules.length
? `答案对了,但没按要求写:${failedRules.map((rule) => rule.description).join(";")}`
: "答案对了,但没按题目要求的写法写。"
return { line: null, text, raw: "" }
}
default:
return null
}
})
const explainTone = computed(() => {
const r = props.row.result
return tone(
r === SubmissionStatus.compile_error ||
r === SubmissionStatus.runtime_error ||
r === SubmissionStatus.ast_check_failed
? "warning"
: r === SubmissionStatus.system_error
? "default"
: "error",
)
})
const rawOpen = ref(false)
watch(
() => props.row.id,
() => (rawOpen.value = false),
)
const { explain, explainTone } = useSubmissionExplain(shown)
function caseColor(result: number) {
if (result === SubmissionStatus.accepted) return tone("success").solid
@@ -312,7 +231,7 @@ function openStandalone() {
v-if="shown"
size="small"
quaternary
title="新页面打开(有测试点明细)"
title="新页面打开(看他这道题交的每一次)"
aria-label="新页面打开"
@click="openStandalone"
>
@@ -398,23 +317,7 @@ function openStandalone() {
</div>
<template v-else>
<div
v-if="explain"
class="explain"
:style="{ background: explainTone.background, borderColor: explainTone.background }"
>
<div class="explain-line">
<b :style="{ color: explainTone.color }">
<template v-if="explain.line">第 {{ explain.line }} 行:</template>{{ explain.text }}
</b>
<div class="spacer"></div>
<a v-if="explain.raw" href="#" class="raw-toggle" @click.prevent="rawOpen = !rawOpen">
原始报错
<Icon :icon="rawOpen ? 'ph:caret-up-bold' : 'ph:caret-down-bold'" :width="11" />
</a>
</div>
<pre v-if="rawOpen && explain.raw" class="raw">{{ explain.raw }}</pre>
</div>
<ExplainNote v-if="explain" :explain="explain" :tone="explainTone" />
<CodeView
v-if="shown"
:code="shown.code"
@@ -624,43 +527,6 @@ function openStandalone() {
color: v-bind("theme.textColor3");
}
.explain {
padding: 10px 14px;
border-radius: 6px;
border: 1px solid;
display: flex;
flex-direction: column;
gap: 8px;
}
.explain-line {
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 1.6;
}
.raw-toggle {
flex: none;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 3px;
color: v-bind("theme.textColor3");
text-decoration: none;
}
.raw {
margin: 0;
max-height: 140px;
overflow: auto;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.locked,
.placeholder {
flex: 1 1 0;
@@ -0,0 +1,96 @@
import { explainCCompileError } from "oj/problem/utils/cError"
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { SubmissionStatus } from "utils/constants"
import type { Submission } from "utils/types"
import { useTone } from "./tone"
export interface SubmissionExplain {
/** 错在第几行(编辑器里那一行标红),说不出来的为 null */
line: number | null
text: string
/** 判题机的原始报错,收在「原始报错」里;没有就是空串 */
raw: string
}
function stripJudgePath(text: string) {
return text.replace(/\/judger\/run\/[^/"]+\//g, "")
}
/**
* 编译失败 / 运行时错误说清楚「错在第几行、为什么」—— 翻译表和题目页结果区同一份
* (pythonError.ts / cError.ts / runtimeError.ts)。提交列表右栏和独立的提交详情页共用。
*/
export function useSubmissionExplain(submission: Ref<Submission | null | undefined>) {
const tone = useTone()
const explain = computed<SubmissionExplain | null>(() => {
const s = submission.value
if (!s || !s.showLink) return null
const info = s.statisticInfo
const err = info?.err_info ? stripJudgePath(info.err_info) : ""
switch (s.result) {
case SubmissionStatus.compile_error: {
const ex = !err
? null
: s.language === "Python"
? explainPythonCompileError(err, s.code)
: s.language === "C" || s.language === "C++"
? explainCCompileError(err, s.code, s.language)
: null
if (ex) return { line: ex.line, text: ex.message, raw: err }
return { line: null, text: "编译没通过,看看下面的原始报错。", raw: err }
}
case SubmissionStatus.runtime_error: {
const rt = info?.runtime_error
if (rt) return { line: rt.line ?? null, text: explainRuntimeError(rt, s.code), raw: err }
return { line: null, text: "程序运行到一半出错,停下来了。", raw: err }
}
case SubmissionStatus.cpu_time_limit_exceeded:
case SubmissionStatus.real_time_limit_exceeded:
return {
line: null,
text: "程序跑了太久还没结束:可能有死循环,或者在等一个没有给的输入。",
raw: "",
}
case SubmissionStatus.memory_limit_exceeded:
return { line: null, text: "程序占的内存太多了。", raw: "" }
case SubmissionStatus.system_error:
return { line: null, text: "判题机出错了,不是代码的问题。", raw: err }
case SubmissionStatus.wrong_answer: {
const check = info?.sample_check
if (!check) return null
// 「题目里的」不能省:上面那排格子是测试点,也从 1 编号,只写「例子 1」时老师看到
// 格子 1 是绿的,以为说明写反了(例子 1 是题面样例,和测试点 1 不是一组数据)
const text = check.passed
? "题目里的例子都对,错在没公开的测试点上。"
: `题目里的例子 ${(check.index ?? 0) + 1} 就错了。`
return { line: null, text, raw: "" }
}
case SubmissionStatus.ast_check_failed: {
const failedRules = (info?.ast_results ?? []).filter((rule) => !rule.passed)
const text = failedRules.length
? `答案对了,但没按要求写:${failedRules.map((rule) => rule.description).join(";")}`
: "答案对了,但没按题目要求的写法写。"
return { line: null, text, raw: "" }
}
default:
return null
}
})
const explainTone = computed(() => {
const r = submission.value?.result
return tone(
r === SubmissionStatus.compile_error ||
r === SubmissionStatus.runtime_error ||
r === SubmissionStatus.ast_check_failed
? "warning"
: r === SubmissionStatus.system_error
? "default"
: "error",
)
})
return { explain, explainTone }
}
+694 -96
View File
@@ -1,124 +1,722 @@
<script setup lang="ts">
import { getSubmission } from "oj/api"
import type { JudgeCaseResult } from "@oj2/contract"
import { JUDGE_STATUS, LANGUAGE_FORMAT_VALUE, LANGUAGE_SHOW_VALUE } from "utils/constants"
import {
parseTime,
submissionCaseResults,
submissionMemoryFormat,
submissionResultTitle,
submissionTimeFormat,
} from "utils/functions"
import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { Icon } from "@iconify/vue"
import { onKeyStroke } from "@vueuse/core"
import { useThemeVars } from "naive-ui"
import { adminRejudge, getSubmission } from "oj/api"
import LockNote from "oj/problemset/components/LockNote.vue"
import WrongAnswerExplain from "oj/problem/components/WrongAnswerExplain.vue"
import UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { errorCode } from "utils/api"
import {
JUDGE_STATUS,
LANGUAGE_FORMAT_VALUE,
LANGUAGE_SHOW_VALUE,
SubmissionStatus,
} from "utils/constants"
import { parseTime, submissionCaseResults } from "utils/functions"
import type { Submission } from "utils/types"
import CodeView from "./components/CodeView.vue"
import ExplainNote from "./components/ExplainNote.vue"
import StatusPill from "./components/StatusPill.vue"
import { useCopySubmission } from "./composables/copySubmission"
const props = defineProps<{
submissionID: string
submission?: Submission
hideList?: boolean
}>()
// 在弹框中使用时,父组件监听此事件关闭弹框,否则弹框会挡住已更新的编辑器
const emit = defineEmits<{ copied: [] }>()
const { isMobile, isDesktop } = useBreakpoints()
const submission = ref<Submission>()
const loading = ref(false)
import { useSubmissionExplain } from "./composables/explain"
import { useTone } from "./composables/tone"
import { classLabel, submissionTimeText } from "./utils"
/**
* 测试点明细。`info` 在契约里是「完整形状或空对象」的联合(非管理员拿到的是空对象),
* `data` 本身也可能为 null —— 两种情况都由这个访问器归成空数组,模板里不再直接取。
* 单独打开的一条提交(设计稿「提交详情重设计」B):左边代码,右边为什么错 + 这道题交的几次。
* 入口是提交列表右栏的「新页面打开」、统计页的方块、后台卡点分析 —— 多半是老师在看某个学生
* 怎么一次次改对的,所以「这道题交了几次」常驻右栏,← → 一次次翻。
*/
const caseResults = computed(() => submissionCaseResults(submission.value?.info))
const props = defineProps<{ submissionID: string }>()
async function init() {
submission.value = props.submission
if (submission.value) return
loading.value = true
const res = await getSubmission(props.submissionID)
submission.value = res
loading.value = false
const router = useRouter()
const message = useMessage()
const theme = useThemeVars()
const isDark = useDark()
const tone = useTone()
const userStore = useUserStore()
const { isMobile } = useBreakpoints()
const { copyToCat, copyToProblem } = useCopySubmission()
const detail = ref<Submission | null>(null)
/** forbidden:别人的代码;missing:链接不对 / 已删掉 */
const state = ref<"loading" | "ok" | "forbidden" | "missing" | "failed">("loading")
const judging = computed(
() =>
detail.value?.result === SubmissionStatus.pending ||
detail.value?.result === SubmissionStatus.judging,
)
// 还在判的每 2 秒拉一次:这一页不在提交列表的轮询里,刚交完或刚重判时不刷新就一直是「等待评分」
let timer: ReturnType<typeof setTimeout> | undefined
// 翻得快的时候,晚回来的上一条不能盖掉这一条
let loadSeq = 0
async function load(quiet = false) {
const seq = ++loadSeq
clearTimeout(timer)
if (!quiet) state.value = "loading"
try {
const res = await getSubmission(props.submissionID, true)
if (seq !== loadSeq) return
detail.value = res
state.value = "ok"
if (judging.value) timer = setTimeout(() => load(true), 2000)
} catch (err) {
if (seq !== loadSeq) return
const code = errorCode(err)
state.value =
code === "submission-forbidden"
? "forbidden"
: code === "submission-not-found"
? "missing"
: "failed"
}
}
const columns: DataTableColumn<JudgeCaseResult>[] = [
{ title: "测试用例", key: "test_case" },
{
title: "测试状态",
key: "result",
render: (row) => h(SubmissionResultTag, { result: row.result }),
},
{
title: "占用内存",
key: "memory",
render: (row) => submissionMemoryFormat(row.memory),
},
{
title: "执行耗时",
key: "real_time",
render: (row) => submissionTimeFormat(row.real_time),
},
watch(
() => props.submissionID,
() => load(),
{ immediate: true },
)
onBeforeUnmount(() => clearTimeout(timer))
const shown = computed(() => (state.value === "ok" ? detail.value : null))
const own = computed(() => !!shown.value && shown.value.userId === userStore.user?.id)
const teacher = computed(() => userStore.isTeacherOrAbove)
const who = computed(() => (own.value ? "你" : "他"))
const { explain, explainTone } = useSubmissionExplain(shown)
/** 答案错误、后端在例子上对比过:摆出输入 / 正确输出 / 他的输出(和题目页结果区同一块) */
const sampleCheck = computed(() => {
const s = shown.value
if (!s?.showLink || s.result !== SubmissionStatus.wrong_answer) return null
return s.statisticInfo?.sample_check ?? null
})
/** 逐个测试点。只有管理员拿得到 info,学生只有 caseSummary 两个数 */
const cases = computed(() => submissionCaseResults(shown.value?.info))
function caseColor(result: number) {
if (result === SubmissionStatus.accepted) return tone("success").solid
if (result === SubmissionStatus.runtime_error) return tone("warning").solid
return tone("error").solid
}
function caseTitle(item: (typeof cases.value)[number], index: number) {
const name = JUDGE_STATUS[item.result as keyof typeof JUDGE_STATUS]?.name ?? ""
const memory = item.memory === undefined ? "" : ` · ${(item.memory / 1048576).toFixed(1)}MB`
const time = item.real_time === undefined ? "" : ` · ${item.real_time}ms`
return `测试点 ${index + 1}:${name}${time}${memory}`
}
const attempts = computed(() => shown.value?.attempts ?? [])
const attemptIndex = computed(() => attempts.value.findIndex((a) => a.id === shown.value?.id))
/** 和正在看的这次同一天的只写钟点(一节课里连交好几次是常态),别的天带上日期 */
function attemptTime(time: string) {
const current = shown.value?.createTime
if (current && parseTime(time, "YYYY-MM-DD") === parseTime(current, "YYYY-MM-DD"))
return parseTime(time, "HH:mm:ss")
return submissionTimeText(time)
}
/** 除了正在看的这次,还有别的能点开的才提 ← → */
const canStep = computed(() =>
attempts.value.some((item) => item.showLink && item.id !== shown.value?.id),
)
/** 「← →」一次次往前往后翻,跳过题单藏着的那几次 */
function step(delta: -1 | 1) {
const list = attempts.value
for (let i = attemptIndex.value + delta; i >= 0 && i < list.length; i += delta) {
if (list[i]!.showLink) return openAttempt(list[i]!.id)
}
}
function openAttempt(id: string) {
if (id !== shown.value?.id) router.replace("/submission/" + id)
}
function typing(event: KeyboardEvent) {
const el = event.target as HTMLElement | null
return !!el && (["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) || el.isContentEditable)
}
onKeyStroke(["ArrowLeft", "ArrowRight"], (event) => {
if (typing(event) || event.altKey || event.ctrlKey || event.metaKey || attempts.value.length < 2)
return
event.preventDefault()
step(event.key === "ArrowLeft" ? -1 : 1)
})
const problemLink = computed(() => {
const s = shown.value
if (!s) return "/"
if (s.contestId) {
return router.resolve({
name: "contest problem",
params: { contestID: String(s.contestId), problemID: s.problemDisplayId },
}).href
}
return "/problem/" + s.problemDisplayId
})
const allSubmissionsLink = computed(() => {
const s = shown.value
if (!s) return "/submission"
return router.resolve({
name: "submissions",
query: { username: s.username, exactUsername: "1" },
}).href
})
const canRejudge = computed(() => teacher.value && !!shown.value && !shown.value.contestId)
/** 老师的「⋯」:和提交列表右栏一样,复制两项 + 重新判题 */
const moreOptions = computed(() => {
const s = shown.value
if (!s) return []
const copy = s.showLink
? [
{ label: "复制回到题目", key: "problem" },
...(s.language !== "SQL" ? [{ label: "复制到自测猫", key: "cat" }] : []),
]
: []
// 手机上名字、班级和这个按钮排不进一行,收进「⋯」
const all = !own.value && isMobile.value ? [{ label: "他的全部提交", key: "all" }] : []
if (!canRejudge.value) return [...all, ...copy]
return [
...all,
...copy,
...(copy.length ? [{ type: "divider", key: "divider" }] : []),
{ label: "重新判题", key: "rejudge" },
]
})
const { copyToCat: catCopy, copyToProblem: problemCopy } = useCopySubmission()
function copyToCat() {
catCopy(submission.value!)
async function rejudge() {
const s = shown.value
if (!s) return
try {
await adminRejudge(s.id)
} catch {
message.error("重新判题没成功")
return
}
message.success("已重新判题,结果出来会自己更新")
load(true)
}
function copyToProblem() {
problemCopy(submission.value!)
emit("copied")
function handleMore(key: string) {
const s = shown.value
if (!s) return
if (key === "all") router.push(allSubmissionsLink.value)
else if (key === "rejudge") rejudge()
else if (key === "problem") copyToProblem(s)
else copyToCat(s)
}
onMounted(init)
</script>
<template>
<n-flex vertical v-if="submission" class="oj-page" :size="24">
<n-flex :vertical="isMobile" justify="space-between">
<n-alert
style="flex: 1"
:type="JUDGE_STATUS[submission.result]['type']"
:title="submissionResultTitle(submission)"
<div class="oj-page detail" :class="{ narrow: isMobile }">
<template v-if="shown">
<div class="head">
<div class="who">
<a
class="user-link"
:href="`/user?name=${encodeURIComponent(shown.username)}`"
target="_blank"
>
<n-flex>
<span>提交时间:{{ parseTime(submission.createTime) }}</span>
<span>编程语言:{{ LANGUAGE_SHOW_VALUE[submission.language] }}</span>
<span>用户:{{ submission.username }}</span>
</n-flex>
</n-alert>
<n-flex :vertical="isDesktop" justify="center">
<n-button v-if="submission.language !== 'SQL'" secondary @click="copyToCat">
复制到自测猫
<UserName :username="shown.username" />
</a>
<span v-if="teacher && shown.className" class="cls">
{{ classLabel(shown.className) }}
</span>
</div>
<span v-if="!isMobile" class="dot">·</span>
<a
class="problem"
:href="problemLink"
:title="`${shown.problemDisplayId} ${shown.problemTitle}`"
>
<span class="problem-id">{{ shown.problemDisplayId }}</span>
{{ shown.problemTitle }}
</a>
<a
v-if="shown.problemSet"
class="problemset"
:href="'/problemset/' + shown.problemSet.id"
:title="'从题单「' + shown.problemSet.title + '」交的'"
>
题单 {{ shown.problemSet.title }}
</a>
<div class="spacer"></div>
<div class="actions">
<template v-if="teacher">
<n-button v-if="!own && !isMobile" tag="a" :href="allSubmissionsLink">
他的全部提交
</n-button>
<n-button secondary @click="copyToProblem">复制回到题目</n-button>
</n-flex>
</n-flex>
<n-card embedded>
<n-code
class="code"
:language="LANGUAGE_FORMAT_VALUE[submission.language]"
:code="submission.code"
show-line-numbers
<n-dropdown
v-if="moreOptions.length"
:options="moreOptions"
trigger="click"
placement="bottom-end"
@select="handleMore"
>
<n-button quaternary aria-label="更多" title="复制、重新判题">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</template>
<template v-else-if="shown.showLink">
<n-button type="primary" @click="copyToProblem(shown)">
<template #icon><Icon icon="ph:arrow-u-up-left" /></template>
复制回到题目
</n-button>
<n-button v-if="shown.language !== 'SQL'" @click="copyToCat(shown)"
>复制到自测猫</n-button
>
</template>
</div>
</div>
<div class="meta">
<StatusPill :result="shown.result" />
<span v-if="cases.length" class="cases">
<span
v-for="(item, index) in cases"
:key="index"
class="case"
:style="{ background: caseColor(item.result), color: isDark ? '#18181c' : '#ffffff' }"
:title="caseTitle(item, index)"
>
{{ index + 1 }}
</span>
</span>
<span
v-else-if="shown.caseSummary && shown.caseSummary.passed < shown.caseSummary.total"
class="partial"
:style="{ color: tone('error').color }"
>
通过 {{ shown.caseSummary.passed }}/{{ shown.caseSummary.total }} 个测试点
</span>
<span>{{ LANGUAGE_SHOW_VALUE[shown.language] }}</span>
<span>{{ submissionTimeText(shown.createTime, true) }}</span>
<span v-if="attemptIndex >= 0 && attempts.length > 1"
>第 {{ attemptIndex + 1 }} 次提交</span
>
</div>
<div class="body">
<div class="code-col">
<div v-if="!shown.showLink" class="locked">
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
<span class="locked-title">这份代码在题单布置期内先藏着</span>
<span class="muted"><LockNote :problem="{ displayId: shown.problemDisplayId }" /></span>
</div>
<CodeView
v-else
:code="shown.code"
:language="LANGUAGE_FORMAT_VALUE[shown.language]"
:mark-line="explain?.line ?? null"
:font-size="isMobile ? 14 : 18"
:line-height="isMobile ? 24 : 30"
/>
</n-card>
<n-data-table v-if="!hideList && caseResults.length" :columns="columns" :data="caseResults" />
</n-flex>
<n-spin v-else :show="loading" class="loading-container"> </n-spin>
</div>
<div class="side">
<WrongAnswerExplain
v-if="sampleCheck"
:check="sampleCheck"
:code="shown.code"
:language="shown.language"
:plain="!!shown.contestId"
:peer="!own"
/>
<ExplainNote v-else-if="explain" :explain="explain" :tone="explainTone" />
<section v-if="attempts.length > 1" class="attempts">
<div class="attempts-head">
<h3>{{ who }}这道题交了 {{ attempts.length }} 次</h3>
<div class="spacer"></div>
<span v-if="!isMobile && canStep" class="keys"><kbd>←</kbd><kbd>→</kbd> 换一次</span>
</div>
<a
v-for="(item, index) in attempts"
:key="item.id"
:href="item.showLink ? '/submission/' + item.id : undefined"
class="attempt"
:class="{ on: item.id === shown.id, off: !item.showLink }"
@click.prevent="item.showLink && openAttempt(item.id)"
>
<span class="n">第 {{ index + 1 }} 次</span>
<span class="t">{{ attemptTime(item.createTime) }}</span>
<StatusPill :result="item.result" />
<div class="spacer"></div>
<span v-if="item.id === shown.id" class="tag">正在看</span>
<Icon v-else-if="!item.showLink" icon="ph:lock-simple" class="muted" />
</a>
</section>
</div>
</div>
</template>
<div v-else-if="state === 'loading'" class="center">
<n-spin />
</div>
<div v-else class="locked full">
<Icon icon="ph:lock-simple" :width="34" class="lock-icon" />
<template v-if="state === 'forbidden'">
<span class="locked-title">别人的代码看不到</span>
<span class="muted">自己动手做出来,才是自己的。</span>
</template>
<template v-else-if="state === 'missing'">
<span class="locked-title">这条提交不存在</span>
<span class="muted">可能是链接复制错了,或者它已经被删掉。</span>
</template>
<template v-else>
<span class="locked-title">没拉下来</span>
<n-button @click="load()">再试一次</n-button>
</template>
<n-button v-if="state !== 'failed'" tag="a" href="/submission">去提交列表</n-button>
</div>
</div>
</template>
<style scoped>
.code {
font-size: 20px;
overflow: auto;
.detail {
display: flex;
flex-direction: column;
gap: 14px;
}
.loading-container {
min-height: 200px;
.head {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
white-space: nowrap;
font-size: 18px;
min-width: 0;
}
.who {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.user-link {
display: flex;
text-decoration: none;
}
.user-link:hover :deep(.name) {
color: v-bind("theme.primaryColor");
}
.cls {
height: 22px;
padding: 0 8px;
border-radius: 11px;
font-size: 12px;
display: inline-flex;
align-items: center;
color: v-bind("tone('success').color");
border: 1px solid v-bind("tone('success').background");
}
.dot {
color: v-bind("theme.textColor3");
}
.problem {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 600;
color: v-bind("theme.textColor1");
text-decoration: none;
}
.problem:hover {
color: v-bind("theme.primaryColor");
}
.problem-id {
color: v-bind("theme.textColor3");
font-weight: 400;
}
.problemset {
flex: none;
height: 22px;
padding: 0 8px;
border-radius: 11px;
font-size: 12px;
display: inline-flex;
align-items: center;
text-decoration: none;
color: v-bind("tone('info').color");
background: v-bind("tone('info').background");
}
.spacer {
flex: 1 1 0;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.meta {
display: flex;
align-items: center;
gap: 14px;
row-gap: 6px;
flex-wrap: wrap;
font-size: var(--oj-fs-sec);
color: v-bind("theme.textColor3");
white-space: nowrap;
}
.cases {
display: inline-flex;
gap: 4px;
}
.case {
width: 22px;
height: 22px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
}
.partial {
font-weight: 600;
}
.body {
display: flex;
gap: 20px;
align-items: flex-start;
}
/* 代码占满左栏到屏幕底:机房 1366×640 的屏上也不被右边的说明挤下去,长的代码在框里滚 */
.code-col {
flex: 1 1 0;
min-width: 0;
position: sticky;
top: 16px;
height: max(360px, calc(100vh - 200px));
display: flex;
flex-direction: column;
}
.side {
width: 540px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.attempts {
padding: 12px 10px 8px;
border: 1px solid v-bind("theme.dividerColor");
border-radius: 8px;
background: v-bind("theme.cardColor");
display: flex;
flex-direction: column;
gap: 2px;
}
.attempts-head {
display: flex;
align-items: center;
padding: 0 6px 6px;
}
.attempts-head h3 {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.keys {
font-size: 12px;
color: v-bind("theme.textColor3");
display: inline-flex;
align-items: center;
gap: 3px;
}
kbd {
min-width: 18px;
height: 18px;
padding: 0 4px;
box-sizing: border-box;
border: 1px solid v-bind("theme.borderColor");
border-radius: 3px;
background: v-bind("theme.cardColor");
font: inherit;
font-size: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.attempt {
display: flex;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 10px;
border-radius: 6px;
font-size: 14px;
color: v-bind("theme.textColor1");
text-decoration: none;
white-space: nowrap;
}
.attempt:hover {
background: v-bind("theme.hoverColor");
}
.attempt.on {
background: v-bind("tone('success').background");
}
.attempt.off {
cursor: default;
opacity: 0.6;
}
.attempt .n {
width: 48px;
flex-shrink: 0;
font-size: 13px;
color: v-bind("theme.textColor3");
}
.attempt .t {
min-width: 128px;
font-variant-numeric: tabular-nums;
}
.tag {
font-size: 12px;
padding: 1px 6px;
border-radius: 4px;
color: v-bind("tone('success').color");
background: v-bind("theme.cardColor");
}
.muted {
color: v-bind("theme.textColor3");
}
.locked {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px;
text-align: center;
border: 1px dashed v-bind("theme.borderColor");
border-radius: 8px;
background: v-bind("theme.cardColor");
}
.locked.full {
min-height: 320px;
}
.lock-icon {
color: v-bind("theme.textColor3");
opacity: 0.7;
}
.locked-title {
font-size: 17px;
font-weight: 600;
}
.center {
min-height: 240px;
display: flex;
align-items: center;
justify-content: center;
}
/* 手机:一栏往下排,为什么错在代码前面 */
.narrow .head {
flex-wrap: wrap;
white-space: normal;
font-size: 16px;
row-gap: 6px;
}
.narrow .problem {
flex-basis: 100%;
order: 3;
white-space: normal;
}
.narrow .problemset {
order: 4;
}
.narrow .actions {
order: 2;
}
.narrow .body {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
/* 右栏拆开:说明 → 代码 → 交的几次(设计稿手机那块) */
.narrow .side {
display: contents;
}
.narrow .side > * {
order: 1;
}
.narrow .code-col {
order: 2;
position: static;
height: auto;
}
.narrow .attempts {
order: 3;
}
.narrow .attempt .t {
min-width: 0;
}
.narrow .code-col :deep(.code-view) {
flex: none;
}
</style>
+25
View File
@@ -224,9 +224,34 @@ export const submissionDetailSchema = z.object({
* `Missing required param "problemID"`。
*/
problemDisplayId: z.string(),
problemTitle: z.string(),
/** 提交者**现在**的班级(和提交列表同口径);已删号的为 null */
className: z.string().nullable(),
/** 从哪个题单交的,同列表的 problemSet */
problemSet: z.object({ id: z.number().int(), title: z.string() }).nullable(),
/**
* false = 自己的这份代码在题单布置期内先藏着(见后端 problemSetLockCutoffs):code 为空串、
* statisticInfo / info 为空对象,其余照给,页面照样摆出是谁、哪道题、什么结果。
* 别人的代码看不到时不走这里,直接 403
*/
showLink: z.boolean(),
/** 通过了几个测试点,口径见 caseSummarySchema */
caseSummary: caseSummarySchema,
/**
* 同一个人在同一道题、同一场比赛(或都不在比赛里)交过的每一次,按时间先后。只有带
* `?attempts=1` 时才给(独立的提交详情页用),否则为 null —— 题目页每判完一次都要拉一遍
* 详情,用不着这一串。showLink 的口径同上,题单藏着的那几次点不进去
*/
attempts: z
.array(
z.object({
id: z.string(),
createTime: z.string(),
result: judgeStatusSchema,
showLink: z.boolean(),
}),
)
.nullable(),
})
/**