feat(提交列表): 重做成左边列表、右边代码,方向键一条条翻,不再开弹框

- 右栏常驻选中那条的代码:编译失败 / 运行时错误用中文说清第几行错了并标红,
  老师看得到逐个测试点,学生看别人的只显示结果
- 老师一键「这节课」:筛到正在上课的班、这节课的题、今天
- 「正在评分」的行自己变成结果;新交的不插队,顶上提示「有 N 条新提交」
- 按天分组、默认每页 30 条;状态筛选补全,语言补上 SQL;流程图的评分详情也放进右栏
- 课堂看板的格子和名字能直接点进这个学生今天的提交

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 11:08:49 +08:00
1 parent ea480d5e0c
commit 660f689a5c
14 files changed
+2845 -673

No files matched your search

+7
View File
@@ -10,6 +10,7 @@ import {
type TutorialSummary,
type ClassActivity,
type ClassBoard,
type ClassLesson,
type KnowledgeMap,
type ClassComparisonResponse,
type ClassRankItem,
@@ -223,6 +224,11 @@ export function getClassBoard(className?: string) {
return api.get<ClassBoard>("classroom/board", { params: { className } })
}
/** 这个班「这节课」的题(提交列表那颗按钮用)。不传班级就猜正在上课的班,和看板一样 */
export function getClassLesson(className?: string) {
return api.get<ClassLesson>("classroom/lesson", { params: { className } })
}
/** 给这个班布置今天的题,空数组 = 清掉 */
export function setClassLesson(className: string, problemDisplayIds: string[]) {
return api.put<null>("classroom/lesson", { className, problemDisplayIds })
@@ -412,6 +418,7 @@ export function getFlowchartSubmission(id: string) {
export function getFlowchartSubmissions(params: {
username?: string
className?: string
problemDisplayId?: string
myself?: string
offset?: number
+51 -2
View File
@@ -198,6 +198,24 @@ const GROUP_LABEL: Record<
working: { text: "在做", type: "info" },
done: { text: "做完了", type: "success" },
}
const router = useRouter()
/**
* 点格子 / 名字到提交列表看他今天交的代码。新标签打开:看板多半正投在屏幕上,别把它顶掉。
* 用户名按整名匹配(学号互相包含是常态,ks24a1 会混进 ks24a10)
*/
function submissionsHref(student: ClassBoardStudent, problemDisplayId?: string) {
return router.resolve({
name: "submissions",
query: {
username: student.username,
exactUsername: "1",
today: "1",
...(problemDisplayId ? { problem: problemDisplayId } : {}),
},
}).href
}
</script>
<template>
@@ -331,7 +349,18 @@ const GROUP_LABEL: Record<
</thead>
<tbody>
<tr v-for="student in students" :key="student.userId">
<td class="name">{{ nameOf(student) }}</td>
<td class="name">
<a
v-if="student.lastSubmitAt"
class="cell-link"
:href="submissionsHref(student)"
target="_blank"
:title="`看 ${nameOf(student)} 今天交的全部`"
>
{{ nameOf(student) }}
</a>
<template v-else>{{ nameOf(student) }}</template>
</td>
<td>
<n-tag
size="small"
@@ -350,7 +379,16 @@ const GROUP_LABEL: Record<
</n-tag>
</td>
<td v-for="(cell, i) in student.cells" :key="i" :class="cellClass(cell)">
{{ cellText(cell) }}
<a
v-if="cell.attempts"
class="cell-link"
:href="submissionsHref(student, board.problems[i]!.problemDisplayId)"
target="_blank"
:title="`看 ${nameOf(student)} 今天在这道题上交的 ${cell.attempts} 次`"
>
{{ cellText(cell) }}
</a>
<template v-else>{{ cellText(cell) }}</template>
</td>
<td class="meta">
{{ student.lastSubmitAt ? parseTime(student.lastSubmitAt, "HH:mm") : "—" }}
@@ -371,6 +409,17 @@ const GROUP_LABEL: Record<
</template>
<style scoped>
/* 格子、名字能点进提交列表,但别长得像一片蓝色链接 —— 投影上一眼看的是颜色 */
.cell-link {
color: inherit;
text-decoration: none;
cursor: pointer;
}
.cell-link:hover {
text-decoration: underline;
}
.board {
max-width: 1400px;
margin: 0 auto;
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useDark } from "@vueuse/core"
/**
* 只读的代码区:行号 + 高亮 + 可选地标红一行(编译失败 / 运行时错误报了行号时)。
*
* 不用 n-code 自带的 show-line-numbers:它没法标出某一行。行高写死成一个值,
* 标红的那条底色按行号算 top,和代码逐行对齐。
*/
const props = withDefaults(
defineProps<{
code: string
/** highlight.js 的语言名(LANGUAGE_FORMAT_VALUE) */
language: string
/** 要标红的行,1 起 */
markLine?: number | null
fontSize?: number
lineHeight?: number
}>(),
{ markLine: null, fontSize: 18, lineHeight: 30 },
)
const theme = useThemeVars()
const isDark = useDark()
const PADDING = 8
const lineCount = computed(() => Math.max(1, props.code.split("\n").length))
const markStyle = computed(() =>
props.markLine && props.markLine <= lineCount.value
? {
top: `${PADDING + (props.markLine - 1) * props.lineHeight}px`,
height: `${props.lineHeight}px`,
}
: null,
)
const scroller = useTemplateRef<HTMLElement>("scroller")
// 换了一条就回到顶上;有标红行的,滚到那一行附近
watch(
() => [props.code, props.markLine] as const,
async () => {
await nextTick()
const el = scroller.value
if (!el) return
const target = props.markLine ? (props.markLine - 3) * props.lineHeight : 0
el.scrollTop = Math.max(0, target)
el.scrollLeft = 0
},
{ immediate: true },
)
</script>
<template>
<div
ref="scroller"
class="code-view"
:style="{
'--fs': `${fontSize}px`,
'--lh': `${lineHeight}px`,
'--pad': `${PADDING}px`,
}"
>
<div class="gutter">
<div v-for="n in lineCount" :key="n" :class="{ marked: n === markLine }">{{ n }}</div>
</div>
<div class="body">
<div v-if="markStyle" class="mark" :style="markStyle"></div>
<n-code :code="code" :language="language" />
</div>
</div>
</template>
<style scoped>
.code-view {
flex: 1 1 0;
min-height: 0;
display: flex;
align-items: flex-start;
overflow: auto;
border: 1px solid v-bind("theme.dividerColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
font-family: Consolas, Monaco, monospace;
font-size: var(--fs);
line-height: var(--lh);
}
.gutter {
position: sticky;
left: 0;
z-index: 1;
flex: none;
min-width: 40px;
min-height: 100%;
box-sizing: border-box;
padding: var(--pad) 10px var(--pad) 8px;
text-align: right;
color: v-bind("theme.textColor3");
background: v-bind("isDark ? 'rgba(255,255,255,0.03)' : '#fafafb'");
border-right: 1px solid v-bind("theme.dividerColor");
user-select: none;
}
.gutter .marked {
color: v-bind("theme.errorColor");
font-weight: 700;
}
.body {
position: relative;
flex: 1 0 auto;
padding: var(--pad) 14px;
}
.mark {
position: absolute;
left: 0;
right: 0;
background: v-bind("isDark ? 'rgba(232, 128, 128, 0.16)' : '#fdf0f2'");
}
.body :deep(.n-code) {
position: relative;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.body :deep(pre) {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
white-space: pre;
}
</style>
@@ -0,0 +1,470 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { getFlowchartSubmission } from "oj/api"
import { useMermaid } from "shared/composables/useMermaid"
import { sortFlowchartCriteria } from "utils/constants"
import type { FlowchartSubmission, FlowchartSubmissionListItem } from "utils/types"
import { FlowchartSubmissionStatus } from "utils/types"
import { useTone } from "../composables/tone"
import { submissionTimeText } from "../utils"
import FlowchartState from "./FlowchartState.vue"
import UserName from "./UserName.vue"
/**
* 流程图模式的右栏:左边画出来的图,右边四项评分和建议。原来要点编号再开一个
* 1000px 宽的弹框(FlowchartScoreDetail),翻全班的图时一样是「开 → 关 → 开」。
*/
const props = defineProps<{
row: FlowchartSubmissionListItem
teacher: boolean
position: { index: number; count: number } | null
/** 手机上的整屏详情:头部折行、图和评分上下排、不提方向键 */
narrow?: boolean
}>()
const emit = defineEmits<{
filterUser: [username: string]
filterProblem: [displayId: string]
openProblem: [row: FlowchartSubmissionListItem]
retry: [id: string]
}>()
const theme = useThemeVars()
const tone = useTone()
const { renderError, renderFlowchart } = useMermaid()
const detail = ref<FlowchartSubmission | null>(null)
const loading = ref(false)
const failed = ref(false)
const cache = new Map<string, FlowchartSubmission>()
const graph = useTemplateRef<HTMLElement>("graph")
const bigGraph = useTemplateRef<HTMLElement>("bigGraph")
const bigOpen = ref(false)
const evaluating = computed(
() =>
props.row.status === FlowchartSubmissionStatus.PENDING ||
props.row.status === FlowchartSubmissionStatus.PROCESSING,
)
async function load() {
const row = props.row
failed.value = false
bigOpen.value = false
if (!row.showLink) {
detail.value = null
return
}
const key = `${row.id}:${row.status}`
let res = cache.get(key)
if (!res) {
loading.value = true
try {
res = await getFlowchartSubmission(row.id)
if (!evaluating.value) cache.set(key, res)
} catch {
if (props.row.id === row.id) failed.value = true
return
} finally {
if (props.row.id === row.id) loading.value = false
}
}
if (props.row.id !== row.id) return
detail.value = res
await nextTick()
await renderFlowchart(graph.value, res.mermaidCode)
}
watch(() => [props.row.id, props.row.status, props.row.showLink] as const, load, {
immediate: true,
})
watch(bigOpen, async (open) => {
if (!open || !detail.value) return
await nextTick()
await renderFlowchart(bigGraph.value, detail.value.mermaidCode)
})
const shown = computed(() => (detail.value?.id === props.row.id ? detail.value : null))
/**
* 评分项明细。内容是 AI 模型原样吐出的 JSON,后端不校验形状,只能按约定断言,
* 字段缺失时用 0 / 空串兜底。jsonb 不保留键序,按固定顺序排(逻辑正确性在最前)
*/
const criteria = computed(() => {
const raw = shown.value?.aiCriteriaDetails ?? {}
const parsed = Object.fromEntries(
Object.entries(raw).map(([key, value]) => {
const item = (value ?? {}) as Partial<{ score: number; max: number; comment: string }>
return [key, { score: item.score ?? 0, max: item.max ?? 0, comment: item.comment ?? "" }]
}),
)
return sortFlowchartCriteria(parsed)
})
function barColor(score: number, max: number) {
const pct = max ? score / max : 0
if (pct >= 0.8) return tone("success").solid
if (pct >= 0.6) return tone("info").solid
if (pct >= 0.4) return tone("warning").solid
return tone("error").solid
}
const suggestions = computed(() =>
(shown.value?.aiSuggestions ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean),
)
const canRetry = computed(
() =>
props.teacher &&
(props.row.status === FlowchartSubmissionStatus.COMPLETED ||
props.row.status === FlowchartSubmissionStatus.FAILED),
)
</script>
<template>
<section class="pane" :class="{ narrow }">
<div class="head">
<div class="who">
<UserName :username="row.username" />
<button v-if="teacher" class="filter-pill" @click="emit('filterUser', row.username)">
<Icon icon="ph:funnel-simple-bold" :width="11" />只看他
</button>
</div>
<span class="dot">·</span>
<a class="problem" href="#" @click.prevent="emit('openProblem', row)">
<span class="problem-id">{{ row.problemDisplayId }}</span>
{{ row.problemTitle }}
</a>
<button
v-if="teacher"
class="filter-pill"
@click="emit('filterProblem', row.problemDisplayId)"
>
<Icon icon="ph:funnel-simple-bold" :width="11" />只看这题
</button>
<div class="spacer"></div>
<n-button
v-if="shown && !renderError && shown.mermaidCode"
size="small"
@click="bigOpen = true"
>
<template #icon><Icon icon="ph:corners-out" /></template>
查看大图
</n-button>
<n-button v-if="teacher" size="small" :disabled="!canRetry" @click="emit('retry', row.id)">
<template #icon><Icon icon="ph:arrow-clockwise" /></template>
重新评分
</n-button>
</div>
<div class="meta">
<FlowchartState :status="row.status" :grade="row.aiGrade" :score="row.aiScore" />
<span>{{ submissionTimeText(row.createTime, true) }}</span>
<span v-if="row.processingTime">评分用时 {{ row.processingTime.toFixed(1) }} 秒</span>
</div>
<div v-if="!row.showLink" class="locked">
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
<span class="locked-title">别人画的流程图看不到</span>
</div>
<div v-else class="body">
<div class="graph-box">
<n-alert v-if="renderError" type="error" title="流程图画不出来">
{{ renderError }}
</n-alert>
<div v-show="!renderError" ref="graph" class="graph"></div>
<n-spin v-if="loading && !shown" size="small" />
<span v-if="failed" class="muted"
>没拉下来,<a href="#" @click.prevent="load">再试一次</a></span
>
</div>
<div class="score-box">
<template v-if="evaluating">
<n-spin size="small" />
<span class="muted">AI 正在评分,一般几秒钟</span>
</template>
<template v-else-if="row.status === FlowchartSubmissionStatus.FAILED">
<span class="muted">这次评分失败了{{ teacher ? ",可以点「重新评分」" : "" }}</span>
</template>
<template v-else-if="shown">
<div v-for="[name, item] in criteria" :key="name" class="crit">
<div class="crit-head">
<b>{{ name }}</b>
<div class="spacer"></div>
<span class="num">{{ item.score }} / {{ item.max }}</span>
</div>
<div class="bar">
<div
class="bar-fill"
:style="{
width: `${item.max ? Math.min(100, (item.score / item.max) * 100) : 0}%`,
background: barColor(item.score, item.max),
}"
></div>
</div>
<div v-if="item.comment" class="muted small">{{ item.comment }}</div>
</div>
<div v-if="shown.aiFeedback" class="note">{{ shown.aiFeedback }}</div>
<div v-if="suggestions.length" class="note">
<b>改进建议:</b>
<div v-for="(line, i) in suggestions" :key="i">{{ line }}</div>
</div>
</template>
</div>
</div>
<div v-if="!narrow" class="foot">
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd></span>
<span>换上一张 / 下一张</span>
<div class="spacer"></div>
<span v-if="position">本页第 {{ position.index }} / {{ position.count }} 条</span>
</div>
<n-modal v-model:show="bigOpen" preset="card" title="流程图" style="width: 90vw">
<div ref="bigGraph" class="big-graph"></div>
</n-modal>
</section>
</template>
<style scoped>
.pane {
flex: 1 1 0;
min-width: 0;
min-height: 0;
box-sizing: border-box;
padding: 14px 20px 12px;
display: flex;
flex-direction: column;
gap: 10px;
background: v-bind("theme.bodyColor");
}
.head {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-size: 15px;
}
.narrow {
padding: 12px 14px;
overflow-y: auto;
}
.narrow .head {
flex-wrap: wrap;
row-gap: 8px;
}
.narrow .head .spacer {
flex-basis: 100%;
}
.narrow .body {
flex-direction: column;
flex: none;
}
.narrow .graph-box {
flex: none;
height: 320px;
}
.who {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.dot,
.problem-id,
.muted {
color: v-bind("theme.textColor3");
}
.problem {
color: v-bind("theme.textColor1");
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
}
.filter-pill {
flex: none;
height: 22px;
padding: 0 7px;
border: 1px solid v-bind("tone('success').background");
border-radius: 11px;
background: transparent;
font: inherit;
font-size: 12px;
color: v-bind("tone('success').color");
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
}
.spacer {
flex: 1 1 0;
}
.meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: v-bind("theme.textColor3");
}
.body {
flex: 1 1 0;
min-height: 0;
display: flex;
gap: 12px;
}
.graph-box,
.score-box,
.locked {
border: 1px solid v-bind("theme.dividerColor");
border-radius: 6px;
background: v-bind("theme.cardColor");
}
.graph-box {
flex: 0 0 42%;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: auto;
padding: 8px;
box-sizing: border-box;
}
.graph {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.graph :deep(svg) {
max-width: 100%;
max-height: 100%;
}
.score-box {
flex: 1 1 0;
min-width: 0;
overflow: auto;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.crit {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.crit-head {
display: flex;
align-items: center;
}
.num {
font-variant-numeric: tabular-nums;
}
.bar {
height: 6px;
border-radius: 3px;
background: v-bind("theme.dividerColor");
}
.bar-fill {
height: 6px;
border-radius: 3px;
}
.small {
font-size: 12px;
}
.note {
font-size: 13px;
line-height: 1.6;
border-top: 1px solid v-bind("theme.dividerColor");
padding-top: 10px;
}
.locked {
flex: 1 1 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
border-style: dashed;
}
.lock-icon {
color: v-bind("theme.textColor3");
opacity: 0.7;
}
.locked-title {
font-size: 16px;
font-weight: 600;
}
.foot {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: v-bind("theme.textColor3");
}
.keys {
display: inline-flex;
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;
}
.big-graph {
display: flex;
justify-content: center;
overflow: auto;
max-height: 80vh;
}
</style>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { FlowchartSubmissionStatus } from "utils/types"
import { useTone, type Tone } from "../composables/tone"
/**
* 流程图提交在列表里的那个胶囊:评完的直接是「A 级 86 分」,没评完的是
* 排队中 / 评分中 / 评分失败。原来等级、分数、状态分三列,老师要横着对。
*/
const props = defineProps<{
status: number
grade: string | null
score: number | null
}>()
const tone = useTone()
const GRADE_TONE: Record<string, Tone> = { S: "success", A: "info", B: "warning", C: "error" }
const view = computed(() => {
if (props.status === FlowchartSubmissionStatus.COMPLETED && props.grade) {
return { tone: GRADE_TONE[props.grade] ?? "default", grade: props.grade, text: "" }
}
if (props.status === FlowchartSubmissionStatus.FAILED) {
return { tone: "error" as const, grade: "", text: "评分失败" }
}
if (props.status === FlowchartSubmissionStatus.PROCESSING) {
return { tone: "info" as const, grade: "", text: "评分中" }
}
return { tone: "default" as const, grade: "", text: "排队中" }
})
const colors = computed(() => tone(view.value.tone))
</script>
<template>
<span class="pill" :style="{ color: colors.color, background: colors.background }">
<template v-if="view.grade">
<b>{{ view.grade }} 级</b><span class="score">{{ score ?? 0 }} 分</span>
</template>
<template v-else>{{ view.text }}</template>
</span>
</template>
<style scoped>
.pill {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 9px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
flex: none;
}
.score {
font-weight: 400;
}
</style>
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import type { SUBMISSION_RESULT } from "utils/types"
import { useTone, type Tone } from "../composables/tone"
const props = defineProps<{ result: SUBMISSION_RESULT }>()
const tone = useTone()
/**
* 判题结果的胶囊。颜色按「学生该怎么想」分三档,而不是照搬 JUDGE_STATUS 的 type:
* 红 = 答案不对(答案错误、超时、超内存、系统错误),橙 = 程序本身有问题
* (编译失败、运行时错误)或对了一半(部分正确、语法未通过),蓝 = 还在判。
*/
const TONE: Partial<Record<number, Tone>> = {
[SubmissionStatus.accepted]: "success",
[SubmissionStatus.wrong_answer]: "error",
[SubmissionStatus.cpu_time_limit_exceeded]: "error",
[SubmissionStatus.real_time_limit_exceeded]: "error",
[SubmissionStatus.memory_limit_exceeded]: "error",
[SubmissionStatus.system_error]: "error",
[SubmissionStatus.compile_error]: "warning",
[SubmissionStatus.runtime_error]: "warning",
[SubmissionStatus.partial_accepted]: "warning",
[SubmissionStatus.ast_check_failed]: "warning",
[SubmissionStatus.pending]: "info",
[SubmissionStatus.judging]: "info",
[SubmissionStatus.submitting]: "info",
}
const judging = computed(
() =>
props.result === SubmissionStatus.pending ||
props.result === SubmissionStatus.judging ||
props.result === SubmissionStatus.submitting,
)
const icon = computed(() => {
const r = props.result
if (r === SubmissionStatus.accepted || r === SubmissionStatus.ast_check_failed) {
return "ph:check-bold"
}
if (
r === SubmissionStatus.compile_error ||
r === SubmissionStatus.runtime_error ||
r === SubmissionStatus.partial_accepted
) {
return "ph:warning-bold"
}
if (
r === SubmissionStatus.cpu_time_limit_exceeded ||
r === SubmissionStatus.real_time_limit_exceeded
) {
return "ph:clock-bold"
}
return "ph:x-bold"
})
const colors = computed(() => tone(TONE[props.result] ?? "default"))
</script>
<template>
<span
class="pill"
:style="{ color: colors.color, background: colors.background }"
:title="JUDGE_STATUS[result]?.title"
>
<span v-if="judging" class="spinner" :style="{ borderTopColor: colors.color }"></span>
<Icon v-else :icon="icon" :width="12" />
{{ JUDGE_STATUS[result]?.name ?? "未知状态" }}
</span>
</template>
<style scoped>
.pill {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 9px 0 7px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
flex: none;
}
.spinner {
width: 10px;
height: 10px;
box-sizing: border-box;
border-radius: 50%;
border: 2px solid rgba(128, 128, 128, 0.35);
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,629 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { getSubmission } from "oj/api"
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { useUserStore } from "shared/store/user"
import {
JUDGE_STATUS,
LANGUAGE_FORMAT_VALUE,
LANGUAGE_SHOW_VALUE,
SubmissionStatus,
} from "utils/constants"
import {
submissionCaseResults,
submissionMemoryFormat,
submissionTimeFormat,
} from "utils/functions"
import type { Submission, SubmissionListItem } from "utils/types"
import { useCopySubmission } from "../composables/copySubmission"
import { useTone } from "../composables/tone"
import { submissionTimeText } from "../utils"
import CodeView from "./CodeView.vue"
import StatusPill from "./StatusPill.vue"
import UserName from "./UserName.vue"
/**
* 提交列表的右栏:选中那一条的代码和结果(设计稿「提交列表重设计 · 定稿」)。
* 原来要点开一个弹框看,老师翻全班代码时是「点开 → 看 → 关掉 → 点下一行」。
*/
const props = defineProps<{
row: SubmissionListItem
/** 老师那套:「只看他」「只看这题」、重新判题、逐个测试点 */
teacher: boolean
/** 在比赛的提交列表里(不能重判,题目链接走比赛) */
contest: boolean
/** 右下角的「本页第 i / n 条」 */
position: { index: number; count: number } | null
/** 手机上的整屏详情:头部折行、不提方向键 */
narrow?: boolean
}>()
const emit = defineEmits<{
filterUser: [username: string]
filterProblem: [displayId: string]
openProblem: [row: SubmissionListItem]
rejudge: [id: string]
}>()
const userStore = useUserStore()
const theme = useThemeVars()
// 暗色下测试点方块的底色是主题色本身(浅),白字就看不清了
const isDark = useDark()
const tone = useTone()
const { copyToCat, copyToProblem } = useCopySubmission()
/**
* 详情按「id + 结果」缓存:同一条判完之前结果会变,变了要重拉;判完的代码不会再变,
* 老师在几行之间来回按方向键时不用每次都等网络。
*/
const cache = new Map<string, Submission>()
const detail = ref<Submission | null>(null)
const loading = ref(false)
const failed = ref(false)
const judging = computed(
() =>
props.row.result === SubmissionStatus.pending || props.row.result === SubmissionStatus.judging,
)
async function load() {
const row = props.row
failed.value = false
if (!row.showLink) {
detail.value = null
return
}
const key = `${row.id}:${row.result}`
const hit = cache.get(key)
if (hit) {
detail.value = hit
return
}
// 换行时先别清空:接口一般几十毫秒就回来,清了会闪一下白
loading.value = true
try {
const res = await getSubmission(row.id)
if (props.row.id !== row.id) return
if (!judging.value) cache.set(key, res)
detail.value = res
} catch {
if (props.row.id === row.id) failed.value = true
} finally {
if (props.row.id === row.id) loading.value = false
}
}
watch(() => [props.row.id, props.row.result, props.row.showLink] as const, load, {
immediate: true,
})
const shown = computed(() => (detail.value?.id === props.row.id ? detail.value : null))
const locked = computed(() => {
if (props.row.showLink) return null
if (!userStore.isAuthed) return "anon" as const
if (props.row.userId === userStore.user?.id) return "problemset" as const
return "others" as const
})
/** 逐个测试点。只有管理员拿得到 info(每个点带 output_md5),学生只有 caseSummary 两个数 */
const cases = computed(() => submissionCaseResults(shown.value?.info))
function stripJudgePath(text: string) {
return text.replace(/\/judger\/run\/[^/"]+\//g, "")
}
/**
* 编译失败 / 运行时错误说清楚「错在第几行、为什么」—— 翻译表和题目页结果区同一份
* (pythonError.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 = s.language === "Python" && err ? explainPythonCompileError(err) : 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), 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
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 failedCaseText = computed(() => {
const bad = cases.value
.map((item, index) => ({ item, index }))
.filter(({ item }) => item.result !== SubmissionStatus.accepted)
if (!bad.length) return ""
const names = bad.map(({ index }) => index + 1).join("、")
const kinds = new Set(bad.map(({ item }) => JUDGE_STATUS[item.result]?.name ?? ""))
return kinds.size === 1 ? `测试点 ${names} ${[...kinds][0]}` : `测试点 ${names} 没通过`
})
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
}
const canRejudge = computed(() => props.teacher && !props.contest)
const moreOptions = computed(() => [
{ label: "复制回到题目", key: "problem" },
...(shown.value?.language !== "SQL" ? [{ label: "复制到自测猫", key: "cat" }] : []),
])
function handleMore(key: string) {
if (!shown.value) return
if (key === "problem") copyToProblem(shown.value)
else copyToCat(shown.value)
}
function openStandalone() {
window.open("/submission/" + props.row.id, "_blank")
}
</script>
<template>
<section class="pane" :class="{ narrow }">
<div class="head">
<div class="who">
<UserName :username="row.username" :muted="!!locked && locked !== 'problemset'" />
<button
v-if="teacher"
class="filter-pill"
title="只看这个人的提交"
@click="emit('filterUser', row.username)"
>
<Icon icon="ph:funnel-simple-bold" :width="11" />只看他
</button>
</div>
<span class="dot">·</span>
<a class="problem" href="#" @click.prevent="emit('openProblem', row)">
<span class="problem-id">{{ row.problemDisplayId }}</span>
{{ row.problemTitle }}
</a>
<button
v-if="teacher"
class="filter-pill"
title="只看这道题的提交"
@click="emit('filterProblem', row.problemDisplayId)"
>
<Icon icon="ph:funnel-simple-bold" :width="11" />只看这题
</button>
<a
v-if="row.problemSet"
class="problemset"
:href="'/problemset/' + row.problemSet.id"
target="_blank"
:title="'从题单「' + row.problemSet.title + '」交的'"
>
题单 {{ row.problemSet.title }}
</a>
<div class="spacer"></div>
<template v-if="shown">
<template v-if="teacher">
<n-button v-if="canRejudge" size="small" @click="emit('rejudge', row.id)">
<template #icon><Icon icon="ph:arrow-clockwise" /></template>
重新判题
</n-button>
<n-button
size="small"
quaternary
title="新页面打开(有测试点明细)"
aria-label="新页面打开"
@click="openStandalone"
>
<template #icon><Icon icon="ph:arrow-square-out" /></template>
</n-button>
<n-dropdown :options="moreOptions" trigger="click" @select="handleMore">
<n-button size="small" quaternary aria-label="更多" title="复制回到题目、复制到自测猫">
<template #icon><Icon icon="ph:dots-three-bold" /></template>
</n-button>
</n-dropdown>
</template>
<template v-else>
<n-button size="small" type="primary" @click="copyToProblem(shown)">
<template #icon><Icon icon="ph:arrow-u-up-left" /></template>
复制回到题目
</n-button>
<n-button v-if="shown.language !== 'SQL'" size="small" @click="copyToCat(shown)">
复制到自测猫
</n-button>
<n-button
size="small"
quaternary
title="新页面打开"
aria-label="新页面打开"
@click="openStandalone"
>
<template #icon><Icon icon="ph:arrow-square-out" /></template>
</n-button>
</template>
</template>
</div>
<div class="meta">
<StatusPill :result="row.result" />
<span v-if="teacher && 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="`测试点 ${index + 1}:${JUDGE_STATUS[item.result]?.name ?? ''}`"
>
{{ index + 1 }}
</span>
<span v-if="failedCaseText" class="muted">{{ failedCaseText }}</span>
</span>
<span
v-else-if="row.caseSummary && row.caseSummary.passed < row.caseSummary.total"
class="partial"
:style="{ color: tone('error').color }"
>
通过 {{ row.caseSummary.passed }}/{{ row.caseSummary.total }} 个测试点
</span>
<span>{{ LANGUAGE_SHOW_VALUE[row.language] }}</span>
<span>{{ submissionTimeText(row.createTime, true) }}</span>
<template v-if="row.statisticInfo?.time_cost !== undefined">
<span>耗时 {{ submissionTimeFormat(row.statisticInfo.time_cost) }}</span>
<span>内存 {{ submissionMemoryFormat(row.statisticInfo.memory_cost) }}</span>
</template>
</div>
<!-- 看不到代码的三种情况 -->
<div v-if="locked" class="locked">
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
<template v-if="locked === 'others'">
<span class="locked-title">别人的代码看不到</span>
<span class="muted">自己动手做出来,才是自己的。</span>
<n-button type="primary" @click="emit('openProblem', row)">
去做 {{ row.problemDisplayId }} {{ row.problemTitle }}
</n-button>
</template>
<template v-else-if="locked === 'problemset'">
<span class="locked-title">这道题在你加入的题单里</span>
<span class="muted">加入题单之前交的代码先藏起来,在题单里做完这道题就能看到。</span>
</template>
<template v-else>
<span class="locked-title">登录之后才能看自己的代码</span>
<span class="muted">谁交了什么题、对没对,不登录也能看。</span>
</template>
</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>
<CodeView
v-if="shown"
:code="shown.code"
:language="LANGUAGE_FORMAT_VALUE[shown.language]"
:mark-line="explain?.line ?? null"
/>
<div v-else class="placeholder">
<n-spin v-if="loading" size="small" />
<span v-else-if="failed" class="muted">
代码没拉下来,
<a href="#" @click.prevent="load">再试一次</a>
</span>
</div>
</template>
<div v-if="!narrow" class="foot">
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd></span>
<span>
{{
teacher
? "换上一条 / 下一条,到本页头尾自动翻页"
: "换上一条 / 下一条(只在能看代码的提交之间走)"
}}
</span>
<div class="spacer"></div>
<span v-if="position">本页第 {{ position.index }} / {{ position.count }} 条</span>
</div>
</section>
</template>
<style scoped>
.pane {
flex: 1 1 0;
min-width: 0;
min-height: 0;
box-sizing: border-box;
padding: 14px 20px 12px;
display: flex;
flex-direction: column;
gap: 10px;
background: v-bind("theme.bodyColor");
}
.head {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
font-size: 15px;
min-width: 0;
}
.narrow {
padding: 12px 14px;
}
.narrow .head {
flex-wrap: wrap;
row-gap: 8px;
}
.narrow .head .spacer {
flex-basis: 100%;
}
.who {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.dot {
color: v-bind("theme.textColor3");
}
.problem {
color: v-bind("theme.textColor1");
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.problem:hover {
color: v-bind("theme.primaryColor");
}
.problem-id {
color: v-bind("theme.textColor3");
}
.problemset {
flex: none;
height: 20px;
padding: 0 7px;
border-radius: 10px;
font-size: 12px;
display: inline-flex;
align-items: center;
text-decoration: none;
color: v-bind("tone('info').color");
background: v-bind("tone('info').background");
}
.filter-pill {
flex: none;
height: 22px;
padding: 0 7px;
border: 1px solid v-bind("tone('success').background");
border-radius: 11px;
background: transparent;
font: inherit;
font-size: 12px;
color: v-bind("tone('success').color");
display: inline-flex;
align-items: center;
gap: 3px;
cursor: pointer;
}
.filter-pill:hover {
background: v-bind("tone('success').background");
}
.spacer {
flex: 1 1 0;
}
.meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: v-bind("theme.textColor3");
white-space: nowrap;
flex-wrap: wrap;
row-gap: 6px;
}
.cases {
display: inline-flex;
align-items: center;
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;
}
.cases .muted {
margin-left: 4px;
}
.partial {
font-weight: 600;
}
.muted {
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;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
padding: 16px;
border-radius: 6px;
}
.locked {
border: 1px dashed v-bind("theme.borderColor");
background: v-bind("theme.cardColor");
}
.lock-icon {
color: v-bind("theme.textColor3");
opacity: 0.7;
}
.locked-title {
font-size: 16px;
font-weight: 600;
}
.foot {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: v-bind("theme.textColor3");
}
.keys {
display: inline-flex;
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;
}
</style>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { USERNAME_CLASS_RE } from "utils/constants"
/**
* 用户名:班级前缀(`ks253`)淡一档,名字加粗 —— 一个班的人前缀都一样,
* 老师要认的是后面那段。`muted` 用在学生看别人的那几行(看得到结果、看不到代码)。
*/
const props = defineProps<{ username: string; muted?: boolean }>()
const theme = useThemeVars()
const parts = computed(() => {
const match = props.username.match(USERNAME_CLASS_RE)
if (!match) return { prefix: "", name: props.username }
return { prefix: match[0], name: props.username.slice(match[0].length) || match[0] }
})
</script>
<template>
<span class="user" :class="{ muted }" :title="username">
<span v-if="parts.prefix" class="prefix">{{ parts.prefix }}</span>
<span class="name">{{ parts.name }}</span>
</span>
</template>
<style scoped>
.user {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.prefix {
color: v-bind("theme.textColor3");
}
.name {
font-weight: 600;
color: v-bind("theme.textColor1");
}
.muted .name {
font-weight: 400;
color: v-bind("theme.textColor2");
}
</style>
@@ -0,0 +1,60 @@
import { LANGUAGE_FORMAT_VALUE } from "utils/constants"
import { compressToBase64 } from "utils/functions"
import type { Submission } from "utils/types"
import { useCodeStore } from "oj/store/code"
import storage from "utils/storage"
/**
* 「复制到自测猫」「复制回到题目」。提交列表的右栏和独立的 /submission/:id 页面共用。
*/
export function useCopySubmission() {
const route = useRoute()
const router = useRouter()
const codeStore = useCodeStore()
function copyToCat(submission: Submission) {
const data = {
lang: LANGUAGE_FORMAT_VALUE[submission.language],
code: submission.code,
input: "",
}
const base64 = compressToBase64(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank")
}
/**
* 编辑器的 storageKey 用 display id(problem._id),不是 submission.problemId
* (内部数字 id)。**不能只靠调用方传的题号** —— 独立的 /submission/:id 路由
* 只有提交 id,原来会一路带进 router.push 抛 `Missing required param "problemID"`。
* 响应里的 problemDisplayId 就是干这个的。
*
* 比赛提交的 contestId 只下发给管理员;学生在比赛里复制,靠的是当前路由上的比赛 id。
*/
function copyToProblem(submission: Submission) {
const { code, language } = submission
const problemID = submission.problemDisplayId
const contestId =
submission.contestId ?? (route.params.contestID ? Number(route.params.contestID) : null)
const storageKey = `problem_${problemID}_contest_${contestId || null}_lang_${language}`
storage.set(storageKey, code)
// 设置语言 + 代码:localStorage 覆盖全新挂载的编辑器,
// setCode 覆盖已挂载(同页 modal)的编辑器
codeStore.setLanguage(language)
codeStore.setCode(code)
const problemSetId = (route.params.problemSetId as string) ?? ""
if (contestId) {
router.push({
name: "contest problem",
params: { contestID: String(contestId), problemID },
})
} else if (problemSetId) {
router.push({ name: "problemset problem", params: { problemSetId, problemID } })
} else {
router.push({ name: "problem", params: { problemID } })
}
}
return { copyToCat, copyToProblem }
}
@@ -0,0 +1,50 @@
import { useThemeVars } from "naive-ui"
import { useDark } from "@vueuse/core"
export type Tone = "success" | "error" | "warning" | "info" | "default"
function rgba(hex: string, alpha: number) {
const value = hex.replace("#", "")
const full =
value.length === 3
? value
.split("")
.map((c) => c + c)
.join("")
: value.slice(0, 6)
const n = parseInt(full, 16)
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
}
/**
* 提交列表里各种小胶囊的配色:浅底 + 深一档的字(设计稿「提交列表重设计 · 定稿」)。
* 从主题色里取,暗色下字换成主题色本身(深色字放在暗底上看不清)。
* 不用 color-mix:Chrome 105 没有(111 才有),机房有一部分还是 105。
*/
export function useTone() {
const theme = useThemeVars()
const isDark = useDark()
return function tone(kind: Tone) {
const t = theme.value
const base = {
success: t.successColor,
error: t.errorColor,
warning: t.warningColor,
info: t.infoColor,
default: t.textColor3,
}[kind]
const pressed = {
success: t.successColorPressed,
error: t.errorColorPressed,
warning: t.warningColorPressed,
info: t.infoColorPressed,
default: t.textColor2,
}[kind]
return {
color: isDark.value ? base : pressed,
background: kind === "default" ? t.actionColor : rgba(base, isDark.value ? 0.16 : 0.12),
solid: base,
}
}
}
+5 -48
View File
@@ -8,13 +8,11 @@ import {
submissionMemoryFormat,
submissionResultTitle,
submissionTimeFormat,
compressToBase64,
} from "utils/functions"
import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useCodeStore } from "oj/store/code"
import storage from "utils/storage"
import { useCopySubmission } from "./composables/copySubmission"
const props = defineProps<{
submissionID: string
@@ -26,10 +24,6 @@ const props = defineProps<{
// 在弹框中使用时,父组件监听此事件关闭弹框,否则弹框会挡住已更新的编辑器
const emit = defineEmits<{ copied: [] }>()
const route = useRoute()
const router = useRouter()
const codeStore = useCodeStore()
const { isMobile, isDesktop } = useBreakpoints()
const submission = ref<Submission>()
@@ -69,51 +63,14 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
},
]
const { copyToCat: catCopy, copyToProblem: problemCopy } = useCopySubmission()
function copyToCat() {
const lang = LANGUAGE_FORMAT_VALUE[submission.value!.language]
const data = {
lang,
code: submission.value!.code,
input: "",
}
const base64 = compressToBase64(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank")
catCopy(submission.value!)
}
function copyToProblem() {
const { code, language, contestId } = submission.value!
// 编辑器的 storageKey 用 display id(problem._id),不是 submission.problemId
// (内部数字 id)。**不能只靠 props.problemID** —— 独立的 /submission/:id 路由
// 只喂 submissionID,那个 prop 是 undefined,原来会一路带进 router.push 抛
// `Missing required param "problemID"`。响应里的 problemDisplayId 就是干这个的。
const problemID = props.problemID ?? submission.value!.problemDisplayId
const contestIDForKey = contestId || null
const storageKey = `problem_${problemID}_contest_${contestIDForKey}_lang_${language}`
storage.set(storageKey, code)
// 设置语言 + 代码:localStorage 覆盖全新挂载的编辑器,
// setCode 覆盖已挂载(同页 modal)的编辑器
codeStore.setLanguage(language)
codeStore.setCode(code)
const problemSetId = (route.params.problemSetId as string) ?? ""
if (contestId) {
router.push({
name: "contest problem",
params: { contestID: String(contestId), problemID },
})
} else if (problemSetId) {
router.push({
name: "problemset problem",
params: { problemSetId, problemID },
})
} else {
router.push({
name: "problem",
params: { problemID },
})
}
problemCopy(submission.value!)
emit("copied")
}
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
import { parseTime } from "utils/functions"
/**
* 提交时间的显示:今天的只写钟点,别的日子带上月日,跨年再带年份。列表里一眼看的是
* 「刚才」还是「以前」,完整的年月日时分秒只会把这一列撑宽。
* 日历按东八区算(parseTime),不跟浏览器时区走。
*/
export function submissionTimeText(time: string, withSeconds = false) {
const clock = parseTime(time, withSeconds ? "HH:mm:ss" : "HH:mm")
const day = parseTime(time, "YYYY-MM-DD")
const now = parseTime(new Date(), "YYYY-MM-DD")
if (day === now) return clock
if (day.slice(0, 4) === now.slice(0, 4)) return `${parseTime(time, "M月D日")} ${clock}`
return `${parseTime(time, "YYYY年M月D日")} ${clock}`
}
/** 列表里每行只写钟点,日期在分隔行上(submissionDayText) */
export function submissionClockText(time: string) {
return parseTime(time, "HH:mm")
}
/** 分隔行上的日子:今天 / 昨天 / 9月28日(跨年带年份) */
export function submissionDayText(time: string) {
const day = parseTime(time, "YYYY-MM-DD")
const now = parseTime(new Date(), "YYYY-MM-DD")
const yesterday = parseTime(new Date(Date.now() - 86_400_000), "YYYY-MM-DD")
if (day === now) return "今天"
if (day === yesterday) return `昨天 · ${parseTime(time, "M月D日")}`
if (day.slice(0, 4) === now.slice(0, 4)) return parseTime(time, "M月D日")
return parseTime(time, "YYYY年M月D日")
}
/** 班级下拉的标签:`253` → `25计算机3班`,和统计面板、班级 PK 同一个写法 */
export function classLabel(className: string) {
return `${className.slice(0, 2)}计算机${className.slice(2)}班`
}
+3 -1
View File
@@ -268,6 +268,8 @@ export interface SubmissionListPayload {
username?: string
/** "1" = username 精确匹配(默认是「包含」)。协作中老师看某个学生的提交用 */
exactUsername?: "1"
/** 班级(user.class_name,如 `253`),精确匹配;和 username 可以同时用 */
className?: string
contestId?: string
problemDisplayId?: string
language: LANGUAGE | ""
@@ -293,7 +295,7 @@ export type { ClassComparison, ClassRankItem, ClassUserRank } from "@oj2/contrac
export type { ClassActivity, ClassActivityProblem } from "@oj2/contract"
/** 课堂看板:一个班今天这节课的题 × 全班学生 */
export type { ClassBoard, ClassBoardStudent, ClassBoardCell } from "@oj2/contract"
export type { ClassBoard, ClassBoardStudent, ClassBoardCell, ClassLesson } from "@oj2/contract"
/** 知识点地图:每个知识点做对几道、在第几档,只给自己看 */
export type { KnowledgeLevel, KnowledgeMap } from "@oj2/contract"