Compare commits
2
Commits
754a9d7caf
...
5c1f1d2bf8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c1f1d2bf8 | ||
|
|
0a4f528c44 |
No files matched your search
@@ -9,6 +9,7 @@ import {
|
|||||||
type ClassActivityProblem,
|
type ClassActivityProblem,
|
||||||
type LastVisit,
|
type LastVisit,
|
||||||
type LessonLanguage,
|
type LessonLanguage,
|
||||||
|
type StatisticInfo,
|
||||||
FLOWCHART_PASS_GRADES,
|
FLOWCHART_PASS_GRADES,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from "drizzle-orm"
|
import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from "drizzle-orm"
|
||||||
@@ -558,7 +559,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
// 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径
|
// 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径
|
||||||
const recentActivity = activityRows({ since: windowStart, users: userIds })
|
const recentActivity = activityRows({ since: windowStart, users: userIds })
|
||||||
const localDay = sql<string>`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')`
|
const localDay = sql<string>`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')`
|
||||||
const [cells, lastSubmits, lastDrawn, attendance, drawn] = await Promise.all([
|
const [cells, lastSubmits, lastDrawn, attendance, drawn, latests] = await Promise.all([
|
||||||
ids.length && userIds.length
|
ids.length && userIds.length
|
||||||
? db
|
? db
|
||||||
.select({
|
.select({
|
||||||
@@ -650,7 +651,42 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
)
|
)
|
||||||
.groupBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId)
|
.groupBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId)
|
||||||
: [],
|
: [],
|
||||||
|
/**
|
||||||
|
* 每人每题今天最后一次代码提交:没做对的格子靠它点开讲错误、按错法归堆。
|
||||||
|
* 范围和 cells 一样(非比赛、布置的语言),只多一个「今天」
|
||||||
|
*/
|
||||||
|
ids.length && userIds.length
|
||||||
|
? db
|
||||||
|
.selectDistinctOn([schema.submission.userId, schema.submission.problemId], {
|
||||||
|
userId: schema.submission.userId,
|
||||||
|
problemId: schema.submission.problemId,
|
||||||
|
id: schema.submission.id,
|
||||||
|
result: schema.submission.result,
|
||||||
|
runtimeError: sql<
|
||||||
|
StatisticInfo["runtime_error"] | null
|
||||||
|
>`${schema.submission.statisticInfo}->'runtime_error'`,
|
||||||
|
sampleCheck: sql<
|
||||||
|
StatisticInfo["sample_check"] | null
|
||||||
|
>`${schema.submission.statisticInfo}->'sample_check'`,
|
||||||
|
})
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNull(schema.submission.contestId),
|
||||||
|
inArray(schema.submission.userId, userIds),
|
||||||
|
inArray(schema.submission.problemId, ids),
|
||||||
|
gte(schema.submission.createTime, start),
|
||||||
|
lessonLanguageFilter(lesson.language),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
schema.submission.userId,
|
||||||
|
schema.submission.problemId,
|
||||||
|
desc(schema.submission.createTime),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
])
|
])
|
||||||
|
const latestByKey = new Map(latests.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
||||||
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
||||||
const drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
const drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
||||||
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
|
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
|
||||||
@@ -689,12 +725,21 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
const cell = cellByKey.get(key)
|
const cell = cellByKey.get(key)
|
||||||
const flow = drawnByKey.get(key)
|
const flow = drawnByKey.get(key)
|
||||||
const acceptedAt = earliest(cell?.firstAcceptedAt, flow?.firstPassedAt)
|
const acceptedAt = earliest(cell?.firstAcceptedAt, flow?.firstPassedAt)
|
||||||
|
const latest = acceptedAt ? undefined : latestByKey.get(key)
|
||||||
return {
|
return {
|
||||||
status: !cell && !flow ? "none" : acceptedAt ? "accepted" : "tried",
|
status: !cell && !flow ? "none" : acceptedAt ? "accepted" : "tried",
|
||||||
attempts: (cell?.attempts ?? 0) + (flow?.attempts ?? 0),
|
attempts: (cell?.attempts ?? 0) + (flow?.attempts ?? 0),
|
||||||
codeAttempts: cell?.attempts ?? 0,
|
codeAttempts: cell?.attempts ?? 0,
|
||||||
flowchartAttempts: flow?.attempts ?? 0,
|
flowchartAttempts: flow?.attempts ?? 0,
|
||||||
acceptedAt,
|
acceptedAt,
|
||||||
|
latest: latest
|
||||||
|
? {
|
||||||
|
submissionId: latest.id,
|
||||||
|
result: latest.result,
|
||||||
|
runtimeError: latest.runtimeError ?? undefined,
|
||||||
|
sampleCheck: latest.sampleCheck ?? undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
lastSubmitAt: lastByUser.get(student.userId) ?? null,
|
lastSubmitAt: lastByUser.get(student.userId) ?? null,
|
||||||
|
|||||||
+859
-398
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,200 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export interface ExplainPerson {
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
submissionId: string
|
||||||
|
/** 错法的短名(errorKind),挂在名字后面 */
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { onKeyStroke } from "@vueuse/core"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import SubmissionView from "oj/submission/components/SubmissionView.vue"
|
||||||
|
import UserName from "shared/components/UserName.vue"
|
||||||
|
import type { ClassBoardProblem } from "utils/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课堂看板「讲错误」(设计稿 BdOpen):点一个红格子或错法堆里的名字打开。老师在讲台上
|
||||||
|
* 一个个讲这道题错着的人,所以顶上是这几个人,← → 换人;下面和独立的提交详情页同一块
|
||||||
|
* (代码占满宽、为什么错在后),只是不出名字那一行、点「第几次」就地换。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
problem: ClassBoardProblem | null
|
||||||
|
people: ExplainPerson[]
|
||||||
|
}>()
|
||||||
|
const show = defineModel<boolean>("show", { required: true })
|
||||||
|
const index = defineModel<number>("index", { required: true })
|
||||||
|
|
||||||
|
const theme = useThemeVars()
|
||||||
|
/**
|
||||||
|
* 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面,
|
||||||
|
* v-bind 的变量挂在组件根上,传不过去(弹层底色整个透明)
|
||||||
|
*/
|
||||||
|
const vars = computed(() => ({
|
||||||
|
"--card": theme.value.cardColor,
|
||||||
|
"--text1": theme.value.textColor1,
|
||||||
|
"--text2": theme.value.textColor2,
|
||||||
|
"--text3": theme.value.textColor3,
|
||||||
|
"--divider": theme.value.dividerColor,
|
||||||
|
"--border": theme.value.borderColor,
|
||||||
|
"--success": theme.value.successColor,
|
||||||
|
"--hover": theme.value.hoverColor,
|
||||||
|
}))
|
||||||
|
const current = computed(() => props.people[index.value] ?? null)
|
||||||
|
|
||||||
|
function step(delta: -1 | 1) {
|
||||||
|
const next = index.value + delta
|
||||||
|
if (next >= 0 && next < props.people.length) index.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyStroke(["ArrowLeft", "ArrowRight"], (event) => {
|
||||||
|
if (!show.value || props.people.length < 2) return
|
||||||
|
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||||
|
event.preventDefault()
|
||||||
|
step(event.key === "ArrowLeft" ? -1 : 1)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-modal v-model:show="show" :auto-focus="false">
|
||||||
|
<!-- 无 preset 的 n-modal 直接子节点要是普通元素 -->
|
||||||
|
<div
|
||||||
|
class="sheet"
|
||||||
|
:style="vars"
|
||||||
|
role="dialog"
|
||||||
|
:aria-label="problem ? `讲 ${problem.problemDisplayId} 的错误` : '讲错误'"
|
||||||
|
>
|
||||||
|
<div class="top">
|
||||||
|
<span v-if="problem" class="problem">
|
||||||
|
<b>{{ problem.problemDisplayId }}</b> {{ problem.title }}
|
||||||
|
<template v-if="people.length > 1"> · 错着的</template>
|
||||||
|
</span>
|
||||||
|
<div class="people">
|
||||||
|
<button
|
||||||
|
v-for="(person, i) in people"
|
||||||
|
:key="person.userId"
|
||||||
|
type="button"
|
||||||
|
class="person"
|
||||||
|
:class="{ on: i === index }"
|
||||||
|
@click="index = i"
|
||||||
|
>
|
||||||
|
<UserName :username="person.username" />
|
||||||
|
<span class="kind">{{ person.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span v-if="people.length > 1" class="keys"><kbd>←</kbd><kbd>→</kbd> 换人</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<n-button
|
||||||
|
v-if="current"
|
||||||
|
tag="a"
|
||||||
|
:href="'/submission/' + current.submissionId"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
在新页面打开
|
||||||
|
</n-button>
|
||||||
|
<n-button quaternary aria-label="关闭" @click="show = false">
|
||||||
|
<template #icon><Icon icon="ph:x-bold" /></template>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<SubmissionView v-if="current" :id="current.submissionId" embedded />
|
||||||
|
</div>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sheet {
|
||||||
|
width: min(1240px, calc(100vw - 48px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 18px 24px 24px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--card);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.problem b {
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.people {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text1);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person.on {
|
||||||
|
border-color: var(--success);
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--card);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { classifyDiff, type DiffKind } from "oj/problem/utils/wrongAnswer"
|
||||||
|
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
||||||
|
import type { ClassBoardCell } from "utils/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课堂看板:一道题错着的人「按错法归堆」(设计稿「课堂看板重设计」A+B)。老师讲错误时
|
||||||
|
* 要的是「这道题全班错在哪」:同一种错讲一遍就够,所以按最后一次提交归类,堆名要短,
|
||||||
|
* 一眼能读完 —— 长的中文解释在点开之后的弹层里(和提交详情同一份)。
|
||||||
|
*
|
||||||
|
* 依据是提交里存的 runtime_error / sample_check(判题机写的),和提交详情、题目页结果区同源。
|
||||||
|
*/
|
||||||
|
export type BoardLatest = NonNullable<ClassBoardCell["latest"]>
|
||||||
|
|
||||||
|
export interface ErrorKind {
|
||||||
|
/** 归堆用:同一个 key 是同一种错 */
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
/** 报错指到的行,答案错误没有 */
|
||||||
|
line: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFF_LABEL: Record<DiffKind, string> = {
|
||||||
|
"no-output": "什么都没输出",
|
||||||
|
whitespace: "只差空格、换行",
|
||||||
|
punctuation: "中英文标点不对",
|
||||||
|
case: "大小写不对",
|
||||||
|
extra: "多输出了东西",
|
||||||
|
missing: "少输出了东西",
|
||||||
|
"number-format": "小数位数不对",
|
||||||
|
values: "算出来的数不对",
|
||||||
|
other: "输出和例子不一样",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** runtime-diagnosis.ts 细分出来的 kind 里常见的几种;没列的按异常类型归 */
|
||||||
|
const KIND_LABEL: Record<string, string> = {
|
||||||
|
"int-parse": "int() 转换失败",
|
||||||
|
"float-parse": "float() 转换失败",
|
||||||
|
unpack: "拆出来的个数对不上",
|
||||||
|
"unpack-number": "一个数拆给好几个变量",
|
||||||
|
"str-concat": "文字和数字用 + 连",
|
||||||
|
operand: "文字和数字一起算",
|
||||||
|
compare: "文字和数字比大小",
|
||||||
|
"not-int": "该给整数的地方不是整数",
|
||||||
|
"format-spec": "保留小数的格式写错",
|
||||||
|
"not-callable": "把不是函数的当函数用",
|
||||||
|
"func-value": "函数名后面忘了括号",
|
||||||
|
"none-value": "拿 None 接着用",
|
||||||
|
"map-value": "map() 的结果没转成列表",
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<string, string> = {
|
||||||
|
EOFError: "input() 用多了",
|
||||||
|
IndexError: "下标越界",
|
||||||
|
ZeroDivisionError: "除以 0",
|
||||||
|
KeyError: "字典里没有这个键",
|
||||||
|
RecursionError: "递归停不下来",
|
||||||
|
UnboundLocalError: "变量还没赋值就用",
|
||||||
|
ModuleNotFoundError: "导入了没有的模块",
|
||||||
|
ImportError: "导入了没有的模块",
|
||||||
|
ValueError: "值不符合要求",
|
||||||
|
TypeError: "类型不对",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** C / C++ 只有信号:11 段错误、8 除零 */
|
||||||
|
const SIGNAL_LABEL: Record<number, string> = {
|
||||||
|
11: "数组越界或指针出错",
|
||||||
|
8: "除以 0",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorKind(latest: BoardLatest): ErrorKind {
|
||||||
|
const result = latest.result
|
||||||
|
if (result === SubmissionStatus.wrong_answer) {
|
||||||
|
const check = latest.sampleCheck
|
||||||
|
if (!check) return { key: "wa", label: "答案错误", line: null }
|
||||||
|
if (check.passed) return { key: "wa-hidden", label: "例子都对,错在没公开的测试点", line: null }
|
||||||
|
const diff = classifyDiff(check.expected ?? "", check.output ?? "")
|
||||||
|
return { key: `wa-${diff}`, label: DIFF_LABEL[diff], line: null }
|
||||||
|
}
|
||||||
|
if (result === SubmissionStatus.runtime_error) {
|
||||||
|
const rt = latest.runtimeError
|
||||||
|
const line = rt?.line ?? null
|
||||||
|
if (!rt) return { key: "re", label: "运行时出错", line }
|
||||||
|
if (rt.signal && SIGNAL_LABEL[rt.signal]) {
|
||||||
|
return { key: `re-signal-${rt.signal}`, label: SIGNAL_LABEL[rt.signal]!, line }
|
||||||
|
}
|
||||||
|
if (rt.type === "NameError") {
|
||||||
|
return rt.name
|
||||||
|
? { key: `re-name-${rt.name}`, label: `「${rt.name}」没有定义`, line }
|
||||||
|
: { key: "re-name", label: "用了没定义的名字", line }
|
||||||
|
}
|
||||||
|
if (rt.kind && KIND_LABEL[rt.kind]) {
|
||||||
|
return { key: `re-${rt.kind}`, label: KIND_LABEL[rt.kind]!, line }
|
||||||
|
}
|
||||||
|
if (rt.type && TYPE_LABEL[rt.type]) {
|
||||||
|
return { key: `re-${rt.type}`, label: TYPE_LABEL[rt.type]!, line }
|
||||||
|
}
|
||||||
|
return { key: "re", label: "运行时出错", line }
|
||||||
|
}
|
||||||
|
if (result === SubmissionStatus.compile_error) {
|
||||||
|
return { key: "ce", label: "编译没通过", line: null }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: `r${result}`,
|
||||||
|
label: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? "没通过",
|
||||||
|
line: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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 { 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"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单独打开的一条提交(设计稿「提交详情重设计」A,用户改成代码在前、例子在后):一栏到底 ——
|
||||||
|
* 名字和题目、结果、这道题交的几次(横排一串,← → 一次次翻)、代码、为什么错。
|
||||||
|
* 原来是 B(左代码右说明),右栏占掉 540px,机房屏上代码框只剩 640px 宽,一成的
|
||||||
|
* C 代码最长那行有 80 列,得左右滚。一栏到底代码占满整宽,字号不用缩。
|
||||||
|
*
|
||||||
|
* 入口是提交列表右栏的「新页面打开」、统计页的方块、后台卡点分析 —— 多半是老师在看某个学生
|
||||||
|
* 怎么一次次改对的。
|
||||||
|
*
|
||||||
|
* 独立的提交详情页(detail.vue)和课堂看板「讲错误」的弹层共用这一个。`embedded`(弹层里):
|
||||||
|
* 不出名字和题目那一行(弹层顶上有一排人名),不占 ← →(弹层拿它换人),
|
||||||
|
* 点「第几次」就地换、不改地址栏。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ id: string; embedded?: boolean }>()
|
||||||
|
|
||||||
|
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 currentId = ref(props.id)
|
||||||
|
watch(
|
||||||
|
() => props.id,
|
||||||
|
(id) => (currentId.value = id),
|
||||||
|
)
|
||||||
|
|
||||||
|
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(currentId.value, 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(currentId, () => 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) return
|
||||||
|
if (props.embedded) currentId.value = id
|
||||||
|
else 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 (props.embedded) return
|
||||||
|
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" },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
async function rejudge() {
|
||||||
|
const s = shown.value
|
||||||
|
if (!s) return
|
||||||
|
try {
|
||||||
|
await adminRejudge(s.id)
|
||||||
|
} catch {
|
||||||
|
message.error("重新判题没成功")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.success("已重新判题,结果出来会自己更新")
|
||||||
|
load(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="detail" :class="{ 'oj-page': !embedded, narrow: isMobile }">
|
||||||
|
<template v-if="shown">
|
||||||
|
<div v-if="!embedded" class="head">
|
||||||
|
<div class="who">
|
||||||
|
<a
|
||||||
|
class="user-link"
|
||||||
|
:href="`/user?name=${encodeURIComponent(shown.username)}`"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<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-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>
|
||||||
|
|
||||||
|
<section v-if="attempts.length > 1" class="attempts">
|
||||||
|
<span class="attempts-title">{{ who }}这道题交了 {{ attempts.length }} 次</span>
|
||||||
|
<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 }"
|
||||||
|
:title="`第 ${index + 1} 次`"
|
||||||
|
@click.prevent="item.showLink && openAttempt(item.id)"
|
||||||
|
>
|
||||||
|
<Icon v-if="!item.showLink" icon="ph:lock-simple" class="muted" />
|
||||||
|
<StatusPill v-else :result="item.result" bare />
|
||||||
|
<span class="t">{{ attemptTime(item.createTime) }}</span>
|
||||||
|
</a>
|
||||||
|
<span v-if="!isMobile && !embedded && canStep" class="keys"
|
||||||
|
><kbd>←</kbd><kbd>→</kbd> 换一次</span
|
||||||
|
>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
class="code"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
</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>
|
||||||
|
.detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 这道题交的几次:横排一串,点哪次换哪次(设计稿 A) */
|
||||||
|
.attempts {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempts-title {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid v-bind("theme.dividerColor");
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt:hover {
|
||||||
|
background: v-bind("theme.hoverColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt.on {
|
||||||
|
border-color: v-bind("theme.successColor");
|
||||||
|
background: v-bind("tone('success').background");
|
||||||
|
color: v-bind("theme.textColor1");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt.off {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt .t {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 代码占满整宽、有多长画多长,不在框里上下滚(CodeView 自己默认是撑满父级的 flex 子项) */
|
||||||
|
.detail .code {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked {
|
||||||
|
min-height: 240px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,647 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import SubmissionView from "./components/SubmissionView.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"
|
|
||||||
import { useSubmissionExplain } from "./composables/explain"
|
|
||||||
import { useTone } from "./composables/tone"
|
|
||||||
import { classLabel, submissionTimeText } from "./utils"
|
|
||||||
|
|
||||||
/**
|
/** 单独打开的一条提交,主体在 SubmissionView(课堂看板「讲错误」的弹层也用它) */
|
||||||
* 单独打开的一条提交(设计稿「提交详情重设计」A,用户改成代码在前、例子在后):一栏到底 ——
|
defineProps<{ submissionID: string }>()
|
||||||
* 名字和题目、结果、这道题交的几次(横排一串,← → 一次次翻)、代码、为什么错。
|
|
||||||
* 原来是 B(左代码右说明),右栏占掉 540px,机房屏上代码框只剩 640px 宽,一成的
|
|
||||||
* C 代码最长那行有 80 列,得左右滚。一栏到底代码占满整宽,字号不用缩。
|
|
||||||
*
|
|
||||||
* 入口是提交列表右栏的「新页面打开」、统计页的方块、后台卡点分析 —— 多半是老师在看某个学生
|
|
||||||
* 怎么一次次改对的。
|
|
||||||
*/
|
|
||||||
const props = defineProps<{ submissionID: string }>()
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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" },
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
async function rejudge() {
|
|
||||||
const s = shown.value
|
|
||||||
if (!s) return
|
|
||||||
try {
|
|
||||||
await adminRejudge(s.id)
|
|
||||||
} catch {
|
|
||||||
message.error("重新判题没成功")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
message.success("已重新判题,结果出来会自己更新")
|
|
||||||
load(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="oj-page detail" :class="{ narrow: isMobile }">
|
<SubmissionView :id="submissionID" />
|
||||||
<template v-if="shown">
|
|
||||||
<div class="head">
|
|
||||||
<div class="who">
|
|
||||||
<a
|
|
||||||
class="user-link"
|
|
||||||
:href="`/user?name=${encodeURIComponent(shown.username)}`"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<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-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>
|
||||||
<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>
|
|
||||||
|
|
||||||
<section v-if="attempts.length > 1" class="attempts">
|
|
||||||
<span class="attempts-title">{{ who }}这道题交了 {{ attempts.length }} 次</span>
|
|
||||||
<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 }"
|
|
||||||
:title="`第 ${index + 1} 次`"
|
|
||||||
@click.prevent="item.showLink && openAttempt(item.id)"
|
|
||||||
>
|
|
||||||
<Icon v-if="!item.showLink" icon="ph:lock-simple" class="muted" />
|
|
||||||
<StatusPill v-else :result="item.result" bare />
|
|
||||||
<span class="t">{{ attemptTime(item.createTime) }}</span>
|
|
||||||
</a>
|
|
||||||
<span v-if="!isMobile && canStep" class="keys"><kbd>←</kbd><kbd>→</kbd> 换一次</span>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<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"
|
|
||||||
class="code"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<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" />
|
|
||||||
</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>
|
|
||||||
.detail {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.head {
|
|
||||||
display: flex;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 这道题交的几次:横排一串,点哪次换哪次(设计稿 A) */
|
|
||||||
.attempts {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attempts-title {
|
|
||||||
margin-right: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: v-bind("theme.textColor3");
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attempt {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 12px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border: 1px solid v-bind("theme.dividerColor");
|
|
||||||
border-radius: 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: v-bind("theme.textColor2");
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attempt:hover {
|
|
||||||
background: v-bind("theme.hoverColor");
|
|
||||||
}
|
|
||||||
|
|
||||||
.attempt.on {
|
|
||||||
border-color: v-bind("theme.successColor");
|
|
||||||
background: v-bind("tone('success').background");
|
|
||||||
color: v-bind("theme.textColor1");
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attempt.off {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attempt .t {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keys {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 12px;
|
|
||||||
color: v-bind("theme.textColor3");
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 3px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 代码占满整宽、有多长画多长,不在框里上下滚(CodeView 自己默认是撑满父级的 flex 子项) */
|
|
||||||
.detail .code {
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
|
||||||
color: v-bind("theme.textColor3");
|
|
||||||
}
|
|
||||||
|
|
||||||
.locked {
|
|
||||||
min-height: 240px;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,6 +2,7 @@ import { z } from "zod"
|
|||||||
|
|
||||||
import { judgeStatusSchema } from "./judge-status"
|
import { judgeStatusSchema } from "./judge-status"
|
||||||
import { lessonLanguageSchema as lessonLanguageValueSchema } from "./language"
|
import { lessonLanguageSchema as lessonLanguageValueSchema } from "./language"
|
||||||
|
import { statisticInfoSchema } from "./submission"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「班里在做」:同班同一天有好几个人做的题,基本就是老师在课上点名的那几道。
|
* 「班里在做」:同班同一天有好几个人做的题,基本就是老师在课上点名的那几道。
|
||||||
@@ -105,6 +106,21 @@ export const classBoardCellSchema = z.object({
|
|||||||
flowchartAttempts: z.number().int(),
|
flowchartAttempts: z.number().int(),
|
||||||
/** 第一次通过的时刻;今天之前通过的,前端显示成「之前」 */
|
/** 第一次通过的时刻;今天之前通过的,前端显示成「之前」 */
|
||||||
acceptedAt: z.string().nullable(),
|
acceptedAt: z.string().nullable(),
|
||||||
|
/**
|
||||||
|
* 还没做对时,今天最后一次**代码**提交:点格子打开它讲错误,题卡片下面按错法归堆。
|
||||||
|
* 做对了、今天没交代码(只画了流程图)的为 null。
|
||||||
|
*
|
||||||
|
* 只带归类要用的那几样(runtime_error / sample_check 和提交里存的同一份),
|
||||||
|
* 不带代码和原始报错 —— 一班四五十人,真要看代码的时候按 submissionId 再拉
|
||||||
|
*/
|
||||||
|
latest: z
|
||||||
|
.object({
|
||||||
|
submissionId: z.string(),
|
||||||
|
result: judgeStatusSchema,
|
||||||
|
runtimeError: statisticInfoSchema.shape.runtime_error,
|
||||||
|
sampleCheck: statisticInfoSchema.shape.sample_check,
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const classBoardStudentSchema = z.object({
|
export const classBoardStudentSchema = z.object({
|
||||||
|
|||||||
Reference in new issue
Block a user