fix(题目): 修掉题目页重构审查查出的问题——提交途中换题会把结果算到新题上、运行例子把对的菱形判错、点评完「下一题」不跳等
题目页重构之后按「功能对照 / 状态 / 结果页 / 布局与后端」四路审查了一遍,查出的问题一起修: 提交与换题 - 提交、画流程图途中换题:A 题格式化好的代码会盖掉 B 的草稿,A 判对了把 B 标成已解决、 弹 B 的点评、题单里跳回题单。现在每个请求回来先核对题目,轮询和推送回来的也核对提交编号 - 画着流程图换到不能画的题,语言退到题目的第一门、还被记成偏好;现在回到学生习惯的那门 - 强制点评评完点「继续 / 下一题」,学生之前在看「题目」页签的话会停在原题 - 离开题目页之后判题、流程图的 WebSocket 一直连着,现在闲 15 分钟断开 运行例子 - 输出开头的空格被削掉,打印菱形这类题对的代码也显示「没对上」 - C 的编译警告拼进了「你的输出」(后台生成测试点也会写进 .out) - Python 的语法错误(中文标点)被说成「下标有没有越界、除数是不是 0」 - 比赛里仍然把空格画成「·」、把不同的地方标红 入口与协作 - 比赛里不给「课堂统计」、不绑协作:房间和统计都按题号找,比赛题号和公开题撞号 - 「看他交过什么」按整名匹配,不再混进学号相近的同学 - 协作中点上下文条、相似题、下一题开新标签,协作不断 - 题单入口给管理员的「⋯」里留「本题提交」 结果与题面 - 「去自己输入拿 51 这些数试试」取到了题面 HTML 里的颜色值;「题目原话」露出 " - 流程图 A 级的学生标题下还写着「还没交过」 - 「我的提交」上方补回「答案错误 × 3」这样的计数 - 流程图历次评分能点开看那一版的图;读分数失败不再说「还没交过流程图」;手机上能看流程图点评 - 「结果」页签有新结果没看时加粗上色,看过就恢复 - 空画布拖进第一个节点画布会跳一下;画布底下空着一条 - 比赛没开始写成「已结束」、倒计时超过一个月算错;分隔条拖到头工具栏被挤坏;题单里的题目页有备案页脚 - 流程图历次分数只读需要的几列(一个学生一道题 23 次提交:96 kB → 2 kB) - 比赛密码、比赛没开始等几条学生看得到的英文报错换成中文 设计文档照实际做法更新了第 5.5、5.6、6、8、9、10、11 节。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ee66cef367
commit
e7b95d211d
34 files changed
+576
-105
No files matched your search
@@ -114,7 +114,7 @@ contestRoutes.get("/contests/:id", optionalAuth, async (c) => {
|
||||
c.get("user"),
|
||||
queryInteger(c.req.param("id"), 0, { min: 1 }),
|
||||
)
|
||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
if (!contest) return failure(c, 404, "contest-not-found", "比赛不存在")
|
||||
const byId = await creators([contest.createdById])
|
||||
return success(
|
||||
c,
|
||||
@@ -133,9 +133,8 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
||||
c.get("user"),
|
||||
queryInteger(c.req.param("id"), 0, { min: 1 }),
|
||||
)
|
||||
if (!contest || !contest.password)
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
const parsed = await parseBody(c, contestPasswordRequestSchema, "Password is required")
|
||||
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "比赛不存在")
|
||||
const parsed = await parseBody(c, contestPasswordRequestSchema, "请输入比赛密码")
|
||||
if (!parsed.success) return parsed.response
|
||||
// 比赛密码往往就是几位数字,不限的话一个脚本几分钟就能扫完。
|
||||
// 按「人 × 比赛」计失败次数:猜错的是自己,锁的也只是自己进这一场。
|
||||
@@ -151,7 +150,7 @@ contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
||||
}
|
||||
if (!checkContestPassword(parsed.data.password, contest.password)) {
|
||||
await countAttempt(attemptKey, CONTEST_PASSWORD_RULE)
|
||||
return failure(c, 403, "wrong-password", "Wrong password or password expired")
|
||||
return failure(c, 403, "wrong-password", "比赛密码不对,或者已经过期了,请重新输入")
|
||||
}
|
||||
await setContestPassword(c, contest.id, parsed.data.password)
|
||||
return success(c, true)
|
||||
@@ -162,8 +161,7 @@ contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
|
||||
c.get("user"),
|
||||
queryInteger(c.req.param("id"), 0, { min: 1 }),
|
||||
)
|
||||
if (!contest || !contest.password)
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "比赛不存在")
|
||||
const access = await canAccessContest(c, contest, "details")
|
||||
return success(c, { access: access.ok } satisfies ContestAccess)
|
||||
})
|
||||
@@ -276,7 +274,7 @@ contestRoutes.get(
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||
if (!row) return failure(c, 404, "problem-not-found", "题目不存在")
|
||||
const tags = await contestProblemTags([row.problem.id])
|
||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||
|
||||
@@ -618,16 +618,23 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||
/**
|
||||
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。
|
||||
* current / history / scores 三个接口都从这一份出。
|
||||
*
|
||||
* 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次
|
||||
* 的学生每切一次题就要搬几百 KB,而结果页签只要分数。要整行的 history 自己按 id 再取。
|
||||
* userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。
|
||||
*/
|
||||
async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
||||
const [rows, joinTimes] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
flowchart: schema.flowchartSubmission,
|
||||
username: schema.user.username,
|
||||
id: schema.flowchartSubmission.id,
|
||||
userId: schema.flowchartSubmission.userId,
|
||||
problemId: schema.flowchartSubmission.problemId,
|
||||
createTime: schema.flowchartSubmission.createTime,
|
||||
aiScore: schema.flowchartSubmission.aiScore,
|
||||
aiGrade: schema.flowchartSubmission.aiGrade,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
||||
.where(
|
||||
and(
|
||||
eq(schema.flowchartSubmission.userId, user.id),
|
||||
@@ -638,7 +645,7 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
||||
.orderBy(asc(schema.flowchartSubmission.createTime)),
|
||||
joinTimesFor(user, [{ userId: user.id, problemId }]),
|
||||
])
|
||||
const visible = rows.filter((row) => !hiddenByProblemSet(user, row.flowchart, joinTimes))
|
||||
const visible = rows.filter((row) => !hiddenByProblemSet(user, row, joinTimes))
|
||||
return { visible, hidden: rows.length - visible.length }
|
||||
}
|
||||
|
||||
@@ -650,7 +657,7 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
||||
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const { visible } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
|
||||
const latest = visible.at(-1)?.flowchart
|
||||
const latest = visible.at(-1)
|
||||
return success(c, {
|
||||
count: visible.length,
|
||||
score: latest?.aiScore ?? 0,
|
||||
@@ -661,11 +668,20 @@ flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) =>
|
||||
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
|
||||
const { visible: rows } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
|
||||
const user = c.get("user")!
|
||||
const { visible: rows } = await myEvaluatedFlowcharts(user, problemId)
|
||||
const selected = page === 0 ? rows.at(-1) : rows[page - 1]
|
||||
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
|
||||
// 列表只带了分数,选中的那一条再按 id 取整行。都是本人的提交,用户名就是自己的
|
||||
const [full] = selected
|
||||
? await db
|
||||
.select()
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(eq(schema.flowchartSubmission.id, selected.id))
|
||||
.limit(1)
|
||||
: []
|
||||
return success(c, {
|
||||
submission: selected ? flowchartData(selected.flowchart, selected.username) : null,
|
||||
submission: full ? flowchartData(full, user.username) : null,
|
||||
count: rows.length,
|
||||
} satisfies FlowchartDetail)
|
||||
})
|
||||
@@ -678,7 +694,7 @@ flowchartRoutes.get("/problems/:id/flowchart/scores", requireAuth, async (c) =>
|
||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const { visible, hidden } = await myEvaluatedFlowcharts(c.get("user")!, problemId)
|
||||
return success(c, {
|
||||
scores: visible.map(({ flowchart }) => ({
|
||||
scores: visible.map((flowchart) => ({
|
||||
id: flowchart.id,
|
||||
score: flowchart.aiScore ?? 0,
|
||||
grade: flowchart.aiGrade ?? "",
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function canAccessContest<E extends AppEnv>(
|
||||
return {
|
||||
ok: false as const,
|
||||
code: "wrong-password",
|
||||
message: "Wrong password or password expired",
|
||||
message: "比赛密码不对,或者已经过期了,请重新输入",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export async function canAccessContest<E extends AppEnv>(
|
||||
return {
|
||||
ok: false as const,
|
||||
code: "contest-not-started",
|
||||
message: "Contest has not started yet.",
|
||||
message: "比赛还没有开始",
|
||||
}
|
||||
}
|
||||
return { ok: true as const }
|
||||
@@ -119,7 +119,7 @@ export function requireContestAccess(
|
||||
const id = Number(c.req.param(paramName))
|
||||
const contest =
|
||||
Number.isInteger(id) && id > 0 ? await findAccessibleContest(c.get("user"), id) : null
|
||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
if (!contest) return failure(c, 404, "contest-not-found", "比赛不存在")
|
||||
const access = await canAccessContest(c, contest, checkType)
|
||||
if (!access.ok) {
|
||||
return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
|
||||
|
||||
@@ -23,7 +23,8 @@ function openHisSubmissions() {
|
||||
if (!room || !problem.value) return
|
||||
const target = router.resolve({
|
||||
name: "submissions",
|
||||
query: { problem: problem.value._id, username: room.peerName },
|
||||
// exactUsername:整名匹配,不然学号相近的同学(ks24a1 / ks24a10…)会混进来
|
||||
query: { problem: problem.value._id, username: room.peerName, exactUsername: "1" },
|
||||
})
|
||||
window.open(target.href, "_blank")
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useProblemStore } from "oj/store/problem"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { ContestStatus } from "utils/constants"
|
||||
import type { ProblemSet, ProblemSetProblem } from "utils/types"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
import ProblemChips, { type ChipItem } from "./ProblemChips.vue"
|
||||
@@ -213,6 +214,8 @@ const contestBar = computed(() => {
|
||||
title: contest.title,
|
||||
chips,
|
||||
remaining: contestStore.remaining,
|
||||
// 不在进行中的两种:管理员赛前看题是「还没开始」,不能一律写「已结束」
|
||||
notStarted: contestStore.contestStatus === ContestStatus.not_started,
|
||||
home: { name: "contest problems", params: { contestID: ctx.value.contestId } },
|
||||
rank: { name: "contest rank", params: { contestID: ctx.value.contestId } },
|
||||
}
|
||||
@@ -352,7 +355,7 @@ onSolvedHere(() => {
|
||||
<span class="meta">还剩</span>
|
||||
<span class="countdown">{{ contestBar.remaining }}</span>
|
||||
</template>
|
||||
<span v-else class="meta">已结束</span>
|
||||
<span v-else class="meta">{{ contestBar.notStarted ? "还没开始" : "已结束" }}</span>
|
||||
<router-link :to="contestBar.rank" class="contest-rank">排名</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -226,7 +226,7 @@ const changeLanguage = (v: LANGUAGE) => {
|
||||
</n-button>
|
||||
|
||||
<n-button
|
||||
v-if="statisticsInline && userStore.isTeacherOrAbove"
|
||||
v-if="ctx.classStats && statisticsInline && userStore.isTeacherOrAbove"
|
||||
:size="buttonSize"
|
||||
@click="statisticsOpen = true"
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { storeToRefs } from "pinia"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { sortFlowchartCriteria } from "utils/constants"
|
||||
import { decompressFromBase64, parseTime } from "utils/functions"
|
||||
|
||||
@@ -12,13 +13,29 @@ import { decompressFromBase64, parseTime } from "utils/functions"
|
||||
*
|
||||
* 原来评分详情是提交按钮旁一个 1000px 的弹框:盖住画布,学生没法一边看改进建议一边改图;
|
||||
* 翻历次评分要一页一页点分页器。现在摆在左栏,画布整列都在右边(设计文档第 6 节)。
|
||||
*
|
||||
* viewOnly:手机上只能看、不能画(设计文档 5.6),不给「照着它写代码」「载回画布」。
|
||||
*/
|
||||
const { viewOnly = false } = defineProps<{ viewOnly?: boolean }>()
|
||||
|
||||
const MyFlowchart = defineAsyncComponent(() => import("./MyFlowchart.vue"))
|
||||
|
||||
const flowchartStore = useFlowchartStore()
|
||||
const { phase, scores, hiddenCount, selectedId, details, detailLoading, editor } =
|
||||
const { phase, scores, hiddenCount, selectedId, details, detailLoading, scoresFailed, editor } =
|
||||
storeToRefs(flowchartStore)
|
||||
const problemStore = useProblemStore()
|
||||
const codeStore = useCodeStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 历次分数原来只由题面(ProblemContent)去读。手机上题面是另一个页签、不切过去就不挂载,
|
||||
// 直接打开 ?tab=result 的话「流程图点评」一条都没有。这里自己也读一次(同一道题只读一次)
|
||||
watch(
|
||||
() => [problemStore.problem?.id, userStore.isAuthed] as const,
|
||||
([id, authed]) => {
|
||||
if (id && authed) flowchartStore.ensureLoaded()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const message = useMessage()
|
||||
const theme = useThemeVars()
|
||||
// 暗色主题的成功色是浅薄荷绿,上面的白字看不清
|
||||
@@ -33,6 +50,23 @@ const current = computed(() => {
|
||||
const detail = computed(() => (current.value ? details.value[current.value.id] : undefined))
|
||||
const viewingLatest = computed(() => selectedId.value === null)
|
||||
|
||||
// ==================== 这一版的图 ====================
|
||||
/**
|
||||
* 点历史分数条上的哪一次,就摆哪一次交上去的图(只读的 Mermaid 渲染,不动画布)。
|
||||
* 原来的评分弹框左边就是这张图;挪进结果页签时丢了,学生翻到「第 2 次 C 42」
|
||||
* 只看得到评语,想不起来那一版画成什么样。图从评语同一个详情接口来,随评语一起现拉。
|
||||
*/
|
||||
const versionIndex = computed(() =>
|
||||
current.value ? scores.value.findIndex((row) => row.id === current.value!.id) : -1,
|
||||
)
|
||||
const versionTitle = computed(() => {
|
||||
if (!current.value) return ""
|
||||
return `第 ${versionIndex.value + 1} 次画的图 · ${parseTime(current.value.createTime, "MM-DD HH:mm")}`
|
||||
})
|
||||
/** 收起来之后翻别的次也保持收着:左栏在机房屏上只有六百来像素高 */
|
||||
const versionOpen = ref(true)
|
||||
const versionZoom = ref(false)
|
||||
|
||||
// 评完的那次、点到的那次,评语都要现拉(历次分数里只有分数)
|
||||
watch(
|
||||
() => current.value?.id,
|
||||
@@ -162,10 +196,18 @@ function goCode() {
|
||||
<template>
|
||||
<!-- 设计稿「画流程图:画布占满右栏,AI 点评在左边」「流程图评分中」 -->
|
||||
<div class="flowchart-result">
|
||||
<div v-if="scoresFailed && !scores.length && phase !== 'evaluating'" class="failed load-failed">
|
||||
<span>以前交过的流程图评分没读出来,可能是网络断了一下</span>
|
||||
<n-button size="small" secondary @click="flowchartStore.ensureLoaded()">再读一次</n-button>
|
||||
</div>
|
||||
<n-empty
|
||||
v-if="!scores.length && phase !== 'evaluating'"
|
||||
v-else-if="!scores.length && phase !== 'evaluating'"
|
||||
class="empty"
|
||||
description="还没交过流程图。画好之后点「交给 AI 点评」,评分和建议会出现在这里"
|
||||
:description="
|
||||
viewOnly
|
||||
? '还没交过流程图。流程图要在电脑上画(手机上拖不动),交给 AI 点评之后,评分和建议在这里也看得到'
|
||||
: '还没交过流程图。画好之后点「交给 AI 点评」,评分和建议会出现在这里'
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- 历次分数连成一条:一眼看出是不是越改越好;正在评的那次是虚线框 -->
|
||||
@@ -229,6 +271,35 @@ function goCode() {
|
||||
</n-flex>
|
||||
|
||||
<template v-if="detail">
|
||||
<div v-if="detail.mermaidCode" class="version">
|
||||
<div class="version-head">
|
||||
<span class="version-title">{{ versionTitle }}</span>
|
||||
<n-button text type="primary" size="small" @click="versionZoom = true">
|
||||
放大看
|
||||
</n-button>
|
||||
<n-button text size="small" @click="versionOpen = !versionOpen">
|
||||
{{ versionOpen ? "收起" : "展开" }}
|
||||
</n-button>
|
||||
</div>
|
||||
<div
|
||||
v-if="versionOpen"
|
||||
class="version-chart"
|
||||
role="button"
|
||||
:aria-label="`放大看${versionTitle}`"
|
||||
@click="versionZoom = true"
|
||||
>
|
||||
<MyFlowchart compact :code="detail.mermaidCode" />
|
||||
</div>
|
||||
<n-modal
|
||||
v-model:show="versionZoom"
|
||||
preset="card"
|
||||
:title="versionTitle"
|
||||
:style="{ maxWidth: '900px' }"
|
||||
>
|
||||
<MyFlowchart :code="detail.mermaidCode" />
|
||||
</n-modal>
|
||||
</div>
|
||||
|
||||
<div v-if="criteria.length" class="criteria">
|
||||
<div v-for="[name, item] in criteria" :key="name" class="criterion">
|
||||
<div class="criterion-head">
|
||||
@@ -254,7 +325,7 @@ function goCode() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<div v-if="!viewOnly" class="actions">
|
||||
<n-button
|
||||
v-if="isFlowchartPass(current.grade) && canCode"
|
||||
type="primary"
|
||||
@@ -369,6 +440,41 @@ function goCode() {
|
||||
border: 1px solid rgba(240, 160, 32, 0.35);
|
||||
}
|
||||
|
||||
.load-failed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
/* 这一版的图:和题面里「你画的流程图」同一个样子,点一下放大 */
|
||||
.version {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.version-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.version-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.version-chart {
|
||||
cursor: zoom-in;
|
||||
border-radius: 6px;
|
||||
background-color: rgba(128, 128, 128, 0.06);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.version-chart :deep(.flowchart-container.compact) {
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.score-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -5,15 +5,17 @@ import { useMermaid } from "shared/composables/useMermaid"
|
||||
/**
|
||||
* 学生自己画到 A/S 的那张流程图。题面里是缩略的一小块(compact),点「放大看」弹出整张。
|
||||
* 原来是左栏单独一个「我的流程图」页签,评到 A/S 时还会把学生强行切过去。
|
||||
*
|
||||
* 结果页签里翻历次评分时也用它摆「这一版的图」,那时由 code 传进来,不读 store。
|
||||
*/
|
||||
const props = defineProps<{ compact?: boolean }>()
|
||||
const props = defineProps<{ compact?: boolean; code?: string }>()
|
||||
|
||||
const store = useMyFlowchartStore()
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
|
||||
watch(
|
||||
() => store.mermaidCode,
|
||||
() => props.code ?? store.mermaidCode,
|
||||
async (code) => {
|
||||
if (!code) return
|
||||
await nextTick()
|
||||
|
||||
@@ -150,10 +150,14 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
<n-tag v-if="myStatus === 'solved'" size="small" type="success" :bordered="false">
|
||||
已解决
|
||||
</n-tag>
|
||||
<n-tag v-else-if="myStatus === 'tried'" size="small" type="warning" :bordered="false">
|
||||
<!-- 流程图画到 A / S 就算做完了(决定 4):别在「流程图 A 级」旁边再写「还没交过」
|
||||
「做过,还没对」—— 那两个说的是代码 -->
|
||||
<template v-else-if="!flowchartGrade">
|
||||
<n-tag v-if="myStatus === 'tried'" size="small" type="warning" :bordered="false">
|
||||
做过,还没对
|
||||
</n-tag>
|
||||
<n-tag v-else-if="myStatus === 'none'" size="small" :bordered="false">还没交过</n-tag>
|
||||
</template>
|
||||
<n-tag v-if="flowchartGrade" size="small" type="success" :bordered="false">
|
||||
流程图 {{ flowchartGrade }} 级
|
||||
</n-tag>
|
||||
|
||||
@@ -12,7 +12,8 @@ import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
|
||||
import { errorMarkExtensions } from "oj/problem/utils/errorMark"
|
||||
import EditorToolbar from "./EditorToolbar.vue"
|
||||
import { useDraftKey } from "../composables/draftKey"
|
||||
import { useTeacherCollab } from "../composables/teacherCollab"
|
||||
import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
import CollabBar from "./CollabBar.vue"
|
||||
import { useFlowchartStore } from "oj/store/flowchart"
|
||||
|
||||
@@ -27,6 +28,7 @@ const codeStore = useCodeStore()
|
||||
const problemStore = useProblemStore()
|
||||
const collabStore = useCollabStore()
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
const ctx = useProblemPageContext()
|
||||
|
||||
/**
|
||||
* 课堂求助的协作就开在这道题上。
|
||||
@@ -35,9 +37,7 @@ const { problem } = storeToRefs(problemStore)
|
||||
* 直接跳到题目页、就在页面这一个编辑器里协作 —— 顺带治好了「按一下 Esc 弹框就关、
|
||||
* 协作跟着结束」:页面上没有弹框可关,结束协作只有工具栏那个按钮和离开这一页两条路。
|
||||
*/
|
||||
const collabHere = computed(
|
||||
() => collabStore.room !== null && collabStore.room.problemId === problem.value?._id,
|
||||
)
|
||||
const collabHere = useCollabHere()
|
||||
|
||||
/** 协作中的教师:编辑器里是学生的代码,不是他自己的 */
|
||||
const teacherCollab = useTeacherCollab()
|
||||
@@ -151,6 +151,20 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 画着流程图时忽然不让画了:工具栏的「写代码 / 画流程图」和语言下拉都跟着 canDraw 藏起来,
|
||||
* 编辑器要是还停在画布上,学生就回不去写代码了。拖窄到手机宽度时页面会换一套布局、编辑器
|
||||
* 重新挂载,loadCode 自己会把语言改掉;但同一道题重新拉题面(登录后补拉)时老师刚好关掉了
|
||||
* 流程图,编辑器不重挂,只能靠这里。和工具栏的「写代码」走同一条路,回到他习惯的那门语言
|
||||
*/
|
||||
watch(
|
||||
() => problemStore.canDraw,
|
||||
(canDraw) => {
|
||||
if (canDraw || codeStore.code.language !== "Flowchart") return
|
||||
problemStore.switchLanguage(problemStore.supportedLanguage(codeStore.preferredLanguage()))
|
||||
},
|
||||
)
|
||||
|
||||
// 流程图编辑器交给 store:结果页签(在左栏,不在编辑器底下)要拿当前画布比一比、
|
||||
// 把旧版本载回去。子组件 SubmitFlowchart 还是从 inject 拿
|
||||
const flowchartStore = useFlowchartStore()
|
||||
@@ -172,12 +186,17 @@ provide("flowchartEditorRef", flowchartEditorRef)
|
||||
学生自己切到流程图就是不写代码了,编辑器卸载、协作正常结束(SyncCodeEditor
|
||||
的 detach),这是原来就有的语义。
|
||||
-->
|
||||
<FlowchartEditor v-if="codeStore.code.language === 'Flowchart'" ref="flowchartEditorRef" />
|
||||
<FlowchartEditor
|
||||
v-if="codeStore.code.language === 'Flowchart'"
|
||||
ref="flowchartEditorRef"
|
||||
:height="editorHeight"
|
||||
/>
|
||||
<SyncCodeEditor
|
||||
v-else
|
||||
v-model:value="codeStore.code.value"
|
||||
:language="codeStore.code.language"
|
||||
:problem-id="problem!._id"
|
||||
:collab="ctx.collab"
|
||||
:height="editorHeight"
|
||||
:extra-extensions="editorExtensions"
|
||||
@update:model-value="changeCode"
|
||||
|
||||
@@ -109,6 +109,25 @@ async function listSubmissions() {
|
||||
listed.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 「我的提交统计」:这一页里各种结果各几次(「答案错误 × 3」「答案正确 × 1」),和原来一样
|
||||
* 只数当前这一页。被题单闸门锁住的那几次不数 —— 它们在列表里只露一行说明,这里也不该露出结果
|
||||
*/
|
||||
const statusDistribution = computed(() => {
|
||||
const counts = new Map<number, number>()
|
||||
for (const row of submissions.value) {
|
||||
if (row.showLink) counts.set(row.result, (counts.get(row.result) ?? 0) + 1)
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([result, count]) => ({
|
||||
result,
|
||||
name: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name ?? "未知",
|
||||
type: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.type ?? "info",
|
||||
count,
|
||||
}))
|
||||
})
|
||||
|
||||
/** 列表是新的在前;「第几次」从最早那次数起 */
|
||||
function attemptNo(index: number) {
|
||||
return total.value - (query.page - 1) * query.limit - index
|
||||
@@ -220,6 +239,19 @@ watch(query, () => {
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div v-if="statusDistribution.length" class="distribution">
|
||||
<n-tag
|
||||
v-for="item in statusDistribution"
|
||||
:key="item.result"
|
||||
:type="item.type"
|
||||
size="small"
|
||||
round
|
||||
:bordered="false"
|
||||
>
|
||||
{{ item.name }} × {{ item.count }}
|
||||
</n-tag>
|
||||
</div>
|
||||
|
||||
<n-empty
|
||||
v-if="listed && !submissions.length"
|
||||
class="empty"
|
||||
@@ -333,6 +365,13 @@ watch(query, () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.distribution {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rank {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useProblemStore } from "oj/store/problem"
|
||||
import { useSubmissionStore } from "oj/store/submission"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useTeacherCollab } from "../composables/teacherCollab"
|
||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { parseTime } from "utils/functions"
|
||||
import ResultHeader from "./ResultHeader.vue"
|
||||
@@ -30,6 +32,8 @@ const { submission, syntaxErrorInfo, resultSegment, formattedBeforeSubmit } =
|
||||
storeToRefs(submissionStore)
|
||||
const { problem } = storeToRefs(useProblemStore())
|
||||
const codeStore = useCodeStore()
|
||||
const ctx = useProblemPageContext()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
/** 协作中的老师自己还没交过:先摆学生最近一次交的 */
|
||||
const teacherCollab = useTeacherCollab()
|
||||
@@ -37,8 +41,25 @@ const teacherCollab = useTeacherCollab()
|
||||
const theme = useThemeVars()
|
||||
const { samplesRunAt } = submissionStore.trial
|
||||
|
||||
type Segment = "submit" | "samples" | "custom"
|
||||
const segments = computed<{ value: Segment; label: string; time: string }[]>(() => [
|
||||
/**
|
||||
* 手机上流程图题只能看点评、不能画(设计文档 5.6)。画不了,语言也就落不到 Flowchart 上,
|
||||
* 下面的 drawing 永远是假 —— 原来学生在电脑上画过、AI 评过的,手机上一条都看不到。
|
||||
* 这时多给一段「流程图点评」,只读。
|
||||
*/
|
||||
const flowchartReadable = computed(
|
||||
() => !isDesktop.value && !!problem.value?.allowFlowchart && ctx.value.entry !== "contest",
|
||||
)
|
||||
/** 在看「流程图点评」那一段。只在这一页记着:点运行例子、提交时照常切回对应的分段 */
|
||||
const viewingFlowchart = ref(false)
|
||||
watch(
|
||||
() => [submissionStore.resultSeq, problem.value?.id, flowchartReadable.value],
|
||||
() => {
|
||||
viewingFlowchart.value = false
|
||||
},
|
||||
)
|
||||
|
||||
type Segment = "submit" | "samples" | "custom" | "flowchart"
|
||||
const codeSegments = computed<{ value: Segment; label: string; time: string }[]>(() => [
|
||||
{
|
||||
value: "submit",
|
||||
// 语法没过就没交上去:那一段不叫「提交结果」
|
||||
@@ -64,32 +85,55 @@ const canTrial = computed(() => {
|
||||
const languages = problem.value?.languages ?? []
|
||||
return !languages.includes("SQL")
|
||||
})
|
||||
|
||||
const segments = computed(() => {
|
||||
const list = canTrial.value ? codeSegments.value : codeSegments.value.slice(0, 1)
|
||||
return flowchartReadable.value
|
||||
? [...list, { value: "flowchart" as const, label: "流程图点评", time: "" }]
|
||||
: list
|
||||
})
|
||||
/** 只有一段(SQL 题、又不是手机上的流程图题)就不摆分段了 */
|
||||
const showSegments = computed(() => segments.value.length > 1)
|
||||
|
||||
function isActive(segment: Segment) {
|
||||
if (viewingFlowchart.value) return segment === "flowchart"
|
||||
return resultSegment.value === segment
|
||||
}
|
||||
function pick(segment: Segment) {
|
||||
if (segment === "flowchart") {
|
||||
viewingFlowchart.value = true
|
||||
return
|
||||
}
|
||||
viewingFlowchart.value = false
|
||||
resultSegment.value = segment
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FlowchartResult v-if="drawing" />
|
||||
<div v-show="!drawing" class="result-pane">
|
||||
<!-- 设计稿:三个胶囊,选中的深色实心,带上那次结果的时间 -->
|
||||
<div v-if="canTrial" class="segments" role="tablist" aria-label="结果">
|
||||
<div v-if="showSegments" class="segments" role="tablist" aria-label="结果">
|
||||
<button
|
||||
v-for="segment in segments"
|
||||
:key="segment.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="segment"
|
||||
:class="{ active: resultSegment === segment.value }"
|
||||
:aria-selected="resultSegment === segment.value"
|
||||
@click="resultSegment = segment.value"
|
||||
:class="{ active: isActive(segment.value) }"
|
||||
:aria-selected="isActive(segment.value)"
|
||||
@click="pick(segment.value)"
|
||||
>
|
||||
{{ segment.label }}<template v-if="segment.time"> · {{ segment.time }}</template>
|
||||
</button>
|
||||
<span v-if="resultSegment === 'custom'" class="segment-note"
|
||||
<span v-if="!viewingFlowchart && resultSegment === 'custom'" class="segment-note"
|
||||
>自己编数据试试,这里不判对错</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<FlowchartResult v-if="viewingFlowchart && flowchartReadable" view-only />
|
||||
<!-- 提交结果用 v-show:切去看运行例子时不卸载,挂着的错误说明在编辑器里标着红 -->
|
||||
<div v-show="resultSegment === 'submit' || !canTrial">
|
||||
<div v-show="!viewingFlowchart && (resultSegment === 'submit' || !canTrial)">
|
||||
<template v-if="syntaxErrorInfo">
|
||||
<ResultHeader kind="warning" title="代码有语法错误,还没有交上去" />
|
||||
<PythonErrorExplain :err-info="syntaxErrorInfo" />
|
||||
@@ -107,7 +151,7 @@ const canTrial = computed(() => {
|
||||
description="还没有提交过。写完代码按「提交」,结果会出现在这里"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="canTrial">
|
||||
<template v-if="canTrial && !viewingFlowchart">
|
||||
<SampleRunResult v-if="resultSegment === 'samples'" />
|
||||
<CustomRun v-else-if="resultSegment === 'custom'" />
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,12 @@ import { SubmissionStatus } from "utils/constants"
|
||||
/**
|
||||
* 「结果」页签的标题:带上这一次的状态。用图标而不是一个彩色小圆点 ——
|
||||
* 机房投影、色弱的学生只靠颜色分不出对错。
|
||||
*
|
||||
* 新结果出来、学生还没点开看过:「结果」两个字跟着状态上色、加粗。看过一次就算已读,
|
||||
* 图标留着、不再强调(设计文档 5.3)。流程图评完时学生在看题目不会被强制切过去,
|
||||
* 靠的就是这一下提醒
|
||||
*/
|
||||
const props = defineProps<{ active: boolean }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const { submission, syntaxErrorInfo, judging, pending, submitting } =
|
||||
@@ -44,10 +49,29 @@ const status = computed(() => {
|
||||
return { icon: "ph:warning-bold", color: theme.value.warningColor }
|
||||
return { icon: "ph:x-bold", color: theme.value.errorColor }
|
||||
})
|
||||
|
||||
/**
|
||||
* 这一次结果的身份:哪条提交判成了什么、第几次流程图评分、哪一次语法没过。
|
||||
* 换了就是新结果,要重新提醒
|
||||
*/
|
||||
const resultKey = computed(() => {
|
||||
if (!status.value) return ""
|
||||
if (codeStore.code.language === "Flowchart")
|
||||
return `flowchart:${flowchart.evaluatedSeq.value}:${flowchart.phase.value}`
|
||||
if (syntaxErrorInfo.value) return `syntax:${syntaxErrorInfo.value}`
|
||||
return `submission:${submission.value?.id}:${submission.value?.result}`
|
||||
})
|
||||
const readKey = ref("")
|
||||
watchEffect(() => {
|
||||
if (props.active) readKey.value = resultKey.value
|
||||
})
|
||||
const unread = computed(
|
||||
() => !!status.value && !status.value.spin && !props.active && resultKey.value !== readKey.value,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="label">
|
||||
<span class="label" :class="{ unread }" :style="unread ? { color: status!.color } : undefined">
|
||||
结果
|
||||
<Icon
|
||||
v-if="status"
|
||||
@@ -66,6 +90,10 @@ const status = computed(() => {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.unread {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@@ -76,8 +76,10 @@ function settleReview() {
|
||||
|
||||
function closeCommentPanel() {
|
||||
commentPanel.value = false
|
||||
// 「下一题」在结果页签里(LessonNext),评价完回到那里 —— 学生点评之前可能切去看了题目
|
||||
submissionStore.revealResult()
|
||||
// 「下一题」在结果页签里(LessonNext),评价完回到那里 —— 学生点评之前可能切去看了题目。
|
||||
// 接着要换题的就别切:切页签会 router.replace 改 `?tab=`,排在下面那次 push 前头,
|
||||
// 把 push 当成被新导航取消,人就停在原题
|
||||
if (!navigationAfterReview) submissionStore.revealResult()
|
||||
settleReview()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Submission } from "utils/types"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useSubmissionStore } from "oj/store/submission"
|
||||
import { markdownToText } from "oj/problem/utils/plainText"
|
||||
import PythonErrorExplain from "./PythonErrorExplain.vue"
|
||||
import RuntimeErrorExplain from "./RuntimeErrorExplain.vue"
|
||||
import WrongAnswerExplain from "./WrongAnswerExplain.vue"
|
||||
@@ -219,7 +220,9 @@ const customRunHint = computed(() => {
|
||||
(sample) => `${sample.input} ${sample.output}`.match(/-?\d+(?:\.\d+)?/g) ?? [],
|
||||
),
|
||||
)
|
||||
const numbers = [...new Set(problem.description.match(/-?\d+(?:\.\d+)?/g) ?? [])]
|
||||
// 先摊成纯文字:题面是 HTML,446 道题带着 style="color: rgb(51, 51, 51)",直接取数会建议「拿 51 试试」
|
||||
const description = markdownToText(problem.description, Infinity)
|
||||
const numbers = [...new Set(description.match(/-?\d+(?:\.\d+)?/g) ?? [])]
|
||||
.filter((value) => !inSamples.has(value))
|
||||
.slice(0, 2)
|
||||
return numbers.length
|
||||
|
||||
@@ -15,7 +15,7 @@ const message = computed(() => {
|
||||
switch (props.result) {
|
||||
case SubmissionStatus.compile_error:
|
||||
return props.language === "Python"
|
||||
? "代码没能跑起来,写法有错误。按「提交代码」会先检查一遍,告诉你错在哪一行。"
|
||||
? "代码有语法错误,程序一行都没跑。最常见的是用了中文标点(,:()“”)。按「提交」会先检查一遍,告诉你错在哪一行,中文标点能一键换成英文。"
|
||||
: "代码没能编译通过,写法有错误。看看是不是漏了分号、括号没配对、变量没声明。"
|
||||
case SubmissionStatus.runtime_error:
|
||||
return "程序运行到一半出错,停下来了。检查一下输入是怎么读的、下标有没有越界、除数是不是 0。"
|
||||
|
||||
@@ -52,8 +52,11 @@ const requirement = computed(() => {
|
||||
const TEXT_LIMIT = 2000
|
||||
|
||||
const onSample = computed(() => props.check.result === SubmissionStatus.wrong_answer)
|
||||
// plain(比赛)连差异类型都不算:把空格画成「·」、把不一样的那段标红,和提示句一样是格式线索
|
||||
const kind = computed(() =>
|
||||
onSample.value ? classifyDiff(props.check.expected ?? "", props.check.output ?? "") : null,
|
||||
onSample.value && !props.plain
|
||||
? classifyDiff(props.check.expected ?? "", props.check.output ?? "")
|
||||
: null,
|
||||
)
|
||||
const hint = computed(() => {
|
||||
if (props.check.passed) return ""
|
||||
@@ -95,7 +98,7 @@ function splitDiff(expected: string, output: string) {
|
||||
}
|
||||
|
||||
const diffParts = computed(() => {
|
||||
if (!onSample.value || diffLine.value < 0) return null
|
||||
if (!onSample.value || props.plain || diffLine.value < 0) return null
|
||||
const expected = lines(props.check.expected ?? "")[diffLine.value] ?? ""
|
||||
const output = lines(props.check.output ?? "")[diffLine.value] ?? ""
|
||||
return splitDiff(expected, output)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { compressToBase64, copyToClipboard } from "utils/functions"
|
||||
import storage from "utils/storage"
|
||||
import { useDraftKey } from "./draftKey"
|
||||
import { useTeacherCollab } from "./teacherCollab"
|
||||
import { useProblemPageContext } from "./problemPageContext"
|
||||
|
||||
/**
|
||||
* 「课堂统计」弹窗开着没有。弹窗挂在题目页上(StatisticsModal),菜单可能在工具栏里、
|
||||
@@ -34,11 +35,15 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const teacherCollab = useTeacherCollab()
|
||||
const draftKey = useDraftKey()
|
||||
const ctx = useProblemPageContext()
|
||||
|
||||
const options = computed<DropdownOption[]>(() => {
|
||||
const items: DropdownOption[] = []
|
||||
// 「本题提交」挪到了「我的提交」抽屉的底部
|
||||
if (!statisticsInline.value && userStore.isTeacherOrAbove) {
|
||||
// 「本题提交」挪到了「我的提交」抽屉的底部;题单入口没有抽屉,给管理员角色留在这里
|
||||
if (ctx.value.allSubmissionsInMenu && userStore.isAdminRole) {
|
||||
items.push({ label: "本题提交", key: "allSubmissions" })
|
||||
}
|
||||
if (ctx.value.classStats && !statisticsInline.value && userStore.isTeacherOrAbove) {
|
||||
items.push({ label: "课堂统计", key: "statistics" })
|
||||
}
|
||||
if (codeStore.code.language !== "Flowchart") {
|
||||
@@ -92,6 +97,16 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
|
||||
window.open(router.resolve(url).href, "_blank")
|
||||
}
|
||||
|
||||
function goAllSubmissions() {
|
||||
const href = router.resolve({
|
||||
name: "submissions",
|
||||
query: { problem: problem.value!._id },
|
||||
}).href
|
||||
// 协作中走新标签,跳走这一页协作就断了(设计文档第 9 节)
|
||||
if (teacherCollab.value) window.open(href, "_blank")
|
||||
else router.push(href)
|
||||
}
|
||||
|
||||
/** 菜单项被点了;不是这张菜单里的 key 返回 false,调用方自己处理(页签行的抽屉) */
|
||||
function select(key: string) {
|
||||
switch (key) {
|
||||
@@ -110,6 +125,9 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
|
||||
case "edit":
|
||||
goEdit()
|
||||
return true
|
||||
case "allSubmissions":
|
||||
goAllSubmissions()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -32,6 +32,22 @@ interface EntryRules {
|
||||
backToProblemSet: boolean
|
||||
/** 举手求助 */
|
||||
help: boolean
|
||||
/**
|
||||
* 老师协作绑在这一页的编辑器上。房间只记了题号(`room.problemId` 是 displayId),
|
||||
* 比赛题的编号常是 1、2、3 —— 老师帮着学生做公开题 1 的时候打开比赛题 1,会绑上
|
||||
* 学生的文档、挂出协作条、禁掉语言选择
|
||||
*/
|
||||
collab: boolean
|
||||
/**
|
||||
* 教师的「课堂统计」。统计接口按 displayId 在公开题库里找题(`submission-statistics.ts`),
|
||||
* 比赛题号拿过去查到的是另一道公开题,或者 404
|
||||
*/
|
||||
classStats: boolean
|
||||
/**
|
||||
* 「看这道题所有人的提交」放进「⋯」(只给管理员角色)。它平时在「我的提交」抽屉底部,
|
||||
* 题单入口没有抽屉,老师就没地方点了
|
||||
*/
|
||||
allSubmissionsInMenu: boolean
|
||||
}
|
||||
|
||||
const RULES: Record<ProblemEntry, EntryRules> = {
|
||||
@@ -43,6 +59,9 @@ const RULES: Record<ProblemEntry, EntryRules> = {
|
||||
reviewAfterAccepted: true,
|
||||
backToProblemSet: false,
|
||||
help: true,
|
||||
collab: true,
|
||||
classStats: true,
|
||||
allSubmissionsInMenu: false,
|
||||
},
|
||||
// 题单里什么「以前的」都不给看:加入题单之前的提交、统计、点评都能拿来抄答案
|
||||
problemset: {
|
||||
@@ -55,6 +74,9 @@ const RULES: Record<ProblemEntry, EntryRules> = {
|
||||
reviewAfterAccepted: false,
|
||||
backToProblemSet: true,
|
||||
help: true,
|
||||
collab: true,
|
||||
classStats: true,
|
||||
allSubmissionsInMenu: true,
|
||||
},
|
||||
contest: {
|
||||
drawers: ["info", "submission"],
|
||||
@@ -66,6 +88,9 @@ const RULES: Record<ProblemEntry, EntryRules> = {
|
||||
reviewAfterAccepted: false,
|
||||
backToProblemSet: false,
|
||||
help: false,
|
||||
collab: false,
|
||||
classStats: false,
|
||||
allSubmissionsInMenu: false,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { useProblemPageContext } from "./problemPageContext"
|
||||
|
||||
/**
|
||||
* 老师正在这道题上协作:编辑器里是**学生的**代码,「我的提交」看的是学生的,
|
||||
@@ -8,11 +9,21 @@ import { useUserStore } from "shared/store/user"
|
||||
*/
|
||||
export function useTeacherCollab() {
|
||||
const userStore = useUserStore()
|
||||
const collabHere = useCollabHere()
|
||||
return computed(() => userStore.isTeacherOrAbove && collabHere.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 协作房间开在这道题上(老师、学生两边都算)。比赛入口一律不算:房间只记了题号,
|
||||
* 比赛题号和公开题撞号是常态,见 problemPageContext 的 `collab`
|
||||
*/
|
||||
export function useCollabHere() {
|
||||
const collabStore = useCollabStore()
|
||||
const problemStore = useProblemStore()
|
||||
const ctx = useProblemPageContext()
|
||||
return computed(
|
||||
() =>
|
||||
userStore.isTeacherOrAbove &&
|
||||
ctx.value.collab &&
|
||||
collabStore.room !== null &&
|
||||
collabStore.room.problemId === problemStore.problem?._id,
|
||||
)
|
||||
|
||||
@@ -18,10 +18,14 @@ export function useSubmissionMonitor() {
|
||||
// ==================== 轮询机制 ====================
|
||||
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
|
||||
async () => {
|
||||
if (!submissionId.value) return
|
||||
const id = submissionId.value
|
||||
if (!id) return
|
||||
|
||||
try {
|
||||
const res = await getSubmission(submissionId.value)
|
||||
const res = await getSubmission(id)
|
||||
// 请求在路上时换了题(reset)或者又交了一次:回来的是上一条,扔掉。
|
||||
// 不扔的话上一道题判对了,会把新题标成已解决、弹点评
|
||||
if (id !== submissionId.value) return
|
||||
submission.value = res
|
||||
|
||||
const result = res.result
|
||||
@@ -76,7 +80,9 @@ export function useSubmissionMonitor() {
|
||||
// 结果已经到手,别让重连再去重放这条早就判完的订阅
|
||||
unsubscribe()
|
||||
|
||||
getSubmission(submissionId.value).then((res) => {
|
||||
const id = submissionId.value
|
||||
getSubmission(id).then((res) => {
|
||||
if (id !== submissionId.value) return // 同上面轮询:换过题就扔掉
|
||||
submission.value = res
|
||||
// 15分钟无新提交则断开WebSocket(节省资源)
|
||||
scheduleDisconnect(15 * 60 * 1000)
|
||||
@@ -137,6 +143,9 @@ export function useSubmissionMonitor() {
|
||||
const reset = () => {
|
||||
pausePolling()
|
||||
unsubscribe()
|
||||
// 连接由 store 持有,离开题目页组件卸了它也还在。和判完之后一样,空闲 15 分钟断开;
|
||||
// 不在这里立刻断,是因为刚交的那条判完之后还可能推成就过来
|
||||
scheduleDisconnect(15 * 60 * 1000)
|
||||
submissionId.value = ""
|
||||
submission.value = undefined
|
||||
}
|
||||
|
||||
@@ -16,16 +16,27 @@ const JUDGE0_COMPILE_ERROR = 6
|
||||
const isJudge0RuntimeError = (status: number) => status >= 7 && status <= 12
|
||||
|
||||
/**
|
||||
* 把 Judge0 的状态换成判题机那套码,好直接交给 WrongAnswerExplain 和 JUDGE_STATUS。
|
||||
* 跑完、只是输出对不上的,按答案错误算。
|
||||
* Python 的语法错误。Judge0 的 Python 没有编译这一步,SyntaxError 回来的是运行时错误
|
||||
* (status 11),得看回溯的最后一行才认得出来
|
||||
*/
|
||||
export function trialStatus(status: number | null, passed: boolean) {
|
||||
const PYTHON_SYNTAX_ERROR = /^(SyntaxError|IndentationError|TabError): /m
|
||||
|
||||
/**
|
||||
* 把 Judge0 的状态换成判题机那套码,好直接交给 WrongAnswerExplain 和 JUDGE_STATUS。
|
||||
* 跑完、只是输出对不上的,按答案错误算。Python 的语法错误按编译失败算:
|
||||
* 学生最常犯的中文冒号、中文括号,原来在这里被说成「程序运行到一半出错……下标有没有越界」
|
||||
*/
|
||||
export function trialStatus(status: number | null, passed: boolean, output: string) {
|
||||
if (status === JUDGE0_ACCEPTED) {
|
||||
return passed ? SubmissionStatus.accepted : SubmissionStatus.wrong_answer
|
||||
}
|
||||
if (status === JUDGE0_TIME_LIMIT) return SubmissionStatus.real_time_limit_exceeded
|
||||
if (status === JUDGE0_COMPILE_ERROR) return SubmissionStatus.compile_error
|
||||
if (status !== null && isJudge0RuntimeError(status)) return SubmissionStatus.runtime_error
|
||||
if (status !== null && isJudge0RuntimeError(status)) {
|
||||
return PYTHON_SYNTAX_ERROR.test(output)
|
||||
? SubmissionStatus.compile_error
|
||||
: SubmissionStatus.runtime_error
|
||||
}
|
||||
return SubmissionStatus.system_error
|
||||
}
|
||||
|
||||
@@ -88,7 +99,7 @@ export function useTrialRun() {
|
||||
input: sample.input,
|
||||
expected: sample.output,
|
||||
output: run.output,
|
||||
result: trialStatus(run.status, passed),
|
||||
result: trialStatus(run.status, passed, run.output),
|
||||
}
|
||||
})
|
||||
samplesRunAt.value = new Date().toISOString()
|
||||
@@ -103,7 +114,7 @@ export function useTrialRun() {
|
||||
if (mine !== customGeneration) return
|
||||
customOutput.value = run.output
|
||||
// 自己输入的没有标准答案,跑完就算「对」,只看有没有出错
|
||||
customResult.value = trialStatus(run.status, true)
|
||||
customResult.value = trialStatus(run.status, true, run.output)
|
||||
customRunning.value = false
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,13 @@ const drawer = ref<DrawerKey | null>(null)
|
||||
const leftPane = useTemplateRef<HTMLElement>("leftPane")
|
||||
const theme = useThemeVars()
|
||||
|
||||
/**
|
||||
* 分隔条两头按像素卡,不按比例:左栏窄于 360 时题单条的「下一题」箭头被裁掉、三个抽屉按钮
|
||||
* 折成两行;右栏窄于 460 时工具栏收进「⋯」之后也放不下、语言下拉被挤成两行(原来 0.8 在 1280 屏上只给右栏 256)
|
||||
*/
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
const splitMax = computed(() => `${Math.max(360, windowWidth.value - 460)}px`)
|
||||
|
||||
// 协作中的老师看的是学生的提交
|
||||
const teacherCollab = useTeacherCollab()
|
||||
const drawerLabel = (key: DrawerKey) =>
|
||||
@@ -151,8 +158,14 @@ const dialog = useDialog()
|
||||
*/
|
||||
let approvedTarget: string | null = null
|
||||
function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNormalized) {
|
||||
if (collabStore.isTeacher || collabStore.helpStatus === "idle") return true
|
||||
if (to.name === from.name && to.params.problemID === from.params.problemID) return true
|
||||
// 协作中的老师:「页面即协作现场」,跳走这一页协作就断了、求助退回排队。所以上下文条、
|
||||
// 相似题、下一题这些站内链接一律开新标签,这一页留着(设计文档第 9 节)
|
||||
if (teacherCollab.value) {
|
||||
window.open(router.resolve(to).href, "_blank")
|
||||
return false
|
||||
}
|
||||
if (collabStore.isTeacher || collabStore.helpStatus === "idle") return true
|
||||
if (approvedTarget === to.fullPath) {
|
||||
approvedTarget = null
|
||||
return true
|
||||
@@ -187,9 +200,12 @@ watch(
|
||||
)
|
||||
|
||||
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
|
||||
const resultTab = () => h(ResultTabLabel)
|
||||
const resultTab = () => h(ResultTabLabel, { active: currentTab.value === "result" })
|
||||
|
||||
// 连着换了几次题,先发的请求后回来的话,地址栏是 C、页面却是 B:只认最后一次
|
||||
let initSeq = 0
|
||||
async function init() {
|
||||
const mine = ++initSeq
|
||||
// 「我的流程图」是上一道题的。这道题也画到了 A/S 的话,SubmitFlowchart 查完会再亮出来
|
||||
myFlowchartStore.hide()
|
||||
// 并行预取右侧编辑器 chunk(CodeMirror ~370K+),
|
||||
@@ -197,8 +213,10 @@ async function init() {
|
||||
loadProblemEditor()
|
||||
try {
|
||||
const res = await getProblem(problemID, contestID)
|
||||
if (mine !== initSeq) return
|
||||
problem.value = res
|
||||
} catch (err) {
|
||||
if (mine !== initSeq) return
|
||||
problem.value = null
|
||||
if (errorCode(err) === "contest-not-started") {
|
||||
errMsg.value = "比赛还没有开始"
|
||||
@@ -247,8 +265,8 @@ onBeforeUnmount(() => {
|
||||
class="problem-split"
|
||||
direction="horizontal"
|
||||
:default-size="0.43"
|
||||
:min="0.2"
|
||||
:max="0.8"
|
||||
min="360px"
|
||||
:max="splitMax"
|
||||
:resize-trigger-size="1"
|
||||
>
|
||||
<template #1>
|
||||
@@ -274,7 +292,7 @@ onBeforeUnmount(() => {
|
||||
:aria-selected="currentTab === 'result'"
|
||||
@click="currentTab = 'result'"
|
||||
>
|
||||
<ResultTabLabel />
|
||||
<ResultTabLabel :active="currentTab === 'result'" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="ctx.drawers.length" class="drawer-buttons">
|
||||
|
||||
@@ -13,7 +13,13 @@ export function markdownToText(markdown: string, max = 120) {
|
||||
.replace(/\$\$?([^$]*)\$\$?/g, "$1")
|
||||
.replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "")
|
||||
.replace(/(\*\*|__|\*|_|~~)(.+?)\1/g, "$2")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
return text.length > max ? `${text.slice(0, max)}…` : text
|
||||
// 题面是富文本编辑器存的 HTML:标签去掉之后还剩 " 这些实体,得换回字
|
||||
const decoded = decodeEntities(text).replace(/\s+/g, " ").trim()
|
||||
return decoded.length > max ? `${decoded.slice(0, max)}…` : decoded
|
||||
}
|
||||
|
||||
/** 走浏览器自己的 HTML 解析,标签在前面已经去掉了,这里只剩实体 */
|
||||
function decodeEntities(text: string) {
|
||||
if (!text.includes("&")) return text
|
||||
return new DOMParser().parseFromString(text, "text/html").documentElement.textContent ?? text
|
||||
}
|
||||
@@ -71,6 +71,11 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
/** 看过的几次的完整评语,按 id 缓存(评完就不会再变) */
|
||||
const details = ref<Record<string, FlowchartSubmission>>({})
|
||||
const detailLoading = ref(false)
|
||||
/**
|
||||
* 进题时历次分数没读出来。原来读失败就当没交过,结果页签写着「还没交过流程图」——
|
||||
* 学生明明交过五次,会以为记录丢了
|
||||
*/
|
||||
const scoresFailed = ref(false)
|
||||
|
||||
/** 画布本身。结果页签要拿当前画布和某一版比一比、把那一版载回去 */
|
||||
const editor = shallowRef<FlowchartEditorInstance | null>(null)
|
||||
@@ -81,6 +86,11 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
const lastSubmittedMermaidCode = ref("")
|
||||
/** 最近一次分数是哪道题的;换题后置空,下次挂载时重新拉 */
|
||||
let loadedFor: number | null = null
|
||||
/**
|
||||
* 换一次题(reset)加一。每个 await 回来都要对一下:请求在路上时学生换了题,
|
||||
* 回来的是上一道题的东西,不能落到这一道上
|
||||
*/
|
||||
let generation = 0
|
||||
|
||||
// ==================== 评分结果监听 ====================
|
||||
/**
|
||||
@@ -92,6 +102,8 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
* 有兜底轮询,流程图这边没有,丢一次消息按钮就一直转圈转到用户自己刷新。
|
||||
*/
|
||||
let monitoringId = ""
|
||||
/** 正在跟的那一次是哪道题的,结算时再对一遍 */
|
||||
let monitoringProblemId: number | null = null
|
||||
|
||||
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
|
||||
async () => {
|
||||
@@ -141,20 +153,26 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
/** 不再跟这一次评分:结算完、超时、或者换了题 */
|
||||
function stopMonitoring() {
|
||||
monitoringId = ""
|
||||
monitoringProblemId = null
|
||||
unsubscribe()
|
||||
pausePolling()
|
||||
stopPollingFallback()
|
||||
stopPollingDeadline()
|
||||
loading.value = false
|
||||
// 连接挂在 store 上,离开题目页也不会跟着组件断。不立刻断:接着还可能再交一次,
|
||||
// A/S 之后的成就也走这条连接推过来。和判题那边一样,闲 15 分钟再断
|
||||
scheduleDisconnect(15 * 60 * 1000)
|
||||
}
|
||||
|
||||
function settle(submissionId: string, outcome: Outcome) {
|
||||
// 一个学生可能同时开着几道题的页面,每条连接都订在同一个用户 topic 上,
|
||||
// 别的页面的评分结果照样会推到这里来 —— 必须认 id,不然会张冠李戴
|
||||
if (!submissionId || submissionId !== monitoringId) return
|
||||
// 换题时 reset 已经把 monitoringId 清了,这里再对一遍题号:别的题评完的分数、烟花、
|
||||
// 「这节课做完」、A/S 那张图,都不能落到眼前这道题上
|
||||
const problemId = monitoringProblemId
|
||||
stopMonitoring()
|
||||
// 评完了就不急着断:接着还可能再交一次。和判题那边一样,闲 15 分钟再断
|
||||
scheduleDisconnect(15 * 60 * 1000)
|
||||
if (!problemId || problemStore.problem?.id !== problemId) return
|
||||
|
||||
if (!outcome.ok) {
|
||||
phase.value = "failed"
|
||||
@@ -170,8 +188,22 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
if (isFlowchartPass(outcome.grade) && lastSubmittedMermaidCode.value) {
|
||||
myFlowchartStore.show(lastSubmittedMermaidCode.value)
|
||||
}
|
||||
const problemId = problemStore.problem?.id
|
||||
if (problemId) loadScores(problemId)
|
||||
// 先把这一次记进历次分数,再去拉全量:拉失败的话,结果页签照样看得到这次的分数,
|
||||
// 不会还停在上一次(第一次交时则是「还没交过流程图」)
|
||||
if (!scores.value.some((row) => row.id === submissionId)) {
|
||||
scores.value = [
|
||||
...scores.value,
|
||||
{
|
||||
id: submissionId,
|
||||
score: outcome.score,
|
||||
grade: outcome.grade,
|
||||
createTime: new Date().toISOString(),
|
||||
},
|
||||
]
|
||||
}
|
||||
loadScores(problemId).catch((error) => {
|
||||
console.error("[Flowchart] 刷新历次评分失败:", error)
|
||||
})
|
||||
}
|
||||
|
||||
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
|
||||
@@ -208,6 +240,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
return
|
||||
}
|
||||
|
||||
const gen = generation
|
||||
lastSubmittedMermaidCode.value = mermaidCode
|
||||
loading.value = true
|
||||
phase.value = "evaluating"
|
||||
@@ -226,16 +259,21 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
},
|
||||
})
|
||||
|
||||
// 请求在路上时换了题:这一次照常评完落库,回到那道题时在历次分数里看得到;
|
||||
// 但不能再跟它 —— 评完会把分数、烟花、「做完了」都落到新题上
|
||||
if (gen !== generation) return
|
||||
if (response.submissionId) {
|
||||
// 订阅提交更新,同时开启轮询兜底。connect() 幂等;没连上时 subscribe 会先记着
|
||||
cancelScheduledDisconnect()
|
||||
connect()
|
||||
monitoringId = response.submissionId
|
||||
monitoringProblemId = problem.id
|
||||
subscribe(response.submissionId)
|
||||
startPollingFallback()
|
||||
startPollingDeadline()
|
||||
}
|
||||
} catch (error) {
|
||||
if (gen !== generation) return
|
||||
loading.value = false
|
||||
phase.value = "idle"
|
||||
// 按错误码分支(见 utils/api.ts 的约定)。限流是最容易撞上的一种:
|
||||
@@ -272,9 +310,11 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
const problemId = problemStore.problem?.id
|
||||
if (!problemId || loadedFor === problemId) return
|
||||
loadedFor = problemId
|
||||
const gen = generation
|
||||
try {
|
||||
await loadScores(problemId)
|
||||
if (problemStore.problem?.id !== problemId) return
|
||||
scoresFailed.value = false
|
||||
const passed = scores.value.filter((row) => isFlowchartPass(row.grade)).at(-1)
|
||||
if (passed) {
|
||||
const detail = await fetchDetail(passed.id)
|
||||
@@ -283,17 +323,22 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
if (detail?.mermaidCode) myFlowchartStore.show(detail.mermaidCode)
|
||||
}
|
||||
} catch (error) {
|
||||
// 拉不到就当没交过;下次挂载再试
|
||||
loadedFor = null
|
||||
console.error("[Flowchart] 读取历次评分失败:", error)
|
||||
// 换过题了:这是上一道题的失败,别把新题的状态改掉
|
||||
if (gen !== generation) return
|
||||
// 下次挂载、或者结果页签里点「再读一次」时重试
|
||||
loadedFor = null
|
||||
if (!scores.value.length) scoresFailed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDetail(id: string) {
|
||||
const cached = details.value[id]
|
||||
if (cached) return cached
|
||||
const gen = generation
|
||||
const detail = await getFlowchartSubmission(id)
|
||||
details.value[id] = detail
|
||||
// 换过题就不进缓存:reset 刚清过,别把上一道题的塞回去
|
||||
if (gen === generation) details.value[id] = detail
|
||||
return detail
|
||||
}
|
||||
|
||||
@@ -302,13 +347,14 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
selectedId.value = id
|
||||
const target = id ?? scores.value.at(-1)?.id
|
||||
if (!target || details.value[target]) return
|
||||
const gen = generation
|
||||
detailLoading.value = true
|
||||
try {
|
||||
await fetchDetail(target)
|
||||
} catch {
|
||||
message.error("这一次的评语读不出来,过一会儿再试")
|
||||
if (gen === generation) message.error("这一次的评语读不出来,过一会儿再试")
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
if (gen === generation) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,6 +364,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
* 连卸载都不会 —— 不收的话上一道还在评的那次评完,会把它的图挂到新题上。
|
||||
*/
|
||||
function reset() {
|
||||
generation++
|
||||
stopMonitoring()
|
||||
latestRating.value = { score: 0, grade: "" }
|
||||
phase.value = "idle"
|
||||
@@ -325,6 +372,8 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
hiddenCount.value = 0
|
||||
selectedId.value = null
|
||||
details.value = {}
|
||||
detailLoading.value = false
|
||||
scoresFailed.value = false
|
||||
lastSubmittedMermaidCode.value = ""
|
||||
loadedFor = null
|
||||
}
|
||||
@@ -348,6 +397,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
||||
selectedId,
|
||||
details,
|
||||
detailLoading,
|
||||
scoresFailed,
|
||||
editor,
|
||||
attachEditor,
|
||||
submit,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from "pinia"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import type { LANGUAGE, ProblemDetail } from "utils/types"
|
||||
import { problemEntryOf } from "oj/problem/composables/problemPageContext"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
|
||||
export const useProblemStore = defineStore("problem", () => {
|
||||
const problem = ref<ProblemDetail | null>(null)
|
||||
@@ -59,12 +60,17 @@ export const useProblemStore = defineStore("problem", () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 这道题收不收当前语言,不收就退到它支持的第一种(SQL 题只有 "SQL",硬编码的
|
||||
* 这道题收不收当前语言,不收就退到学生习惯的那门、再退到它支持的第一种(SQL 题只有 "SQL",硬编码的
|
||||
* Python 会被后端拒绝)。编辑器每次载入代码之前都要过一遍:草稿的键里带着语言,
|
||||
* 先载入再改语言,编辑器里摆的就是另一种语言的模板。
|
||||
*/
|
||||
function supportedLanguage(current: LANGUAGE): LANGUAGE {
|
||||
if (languages.value.includes(current)) return current
|
||||
// 当前这门这道题不给,先回到学生习惯用的那门。current 是 Flowchart 时最要紧:
|
||||
// 在能画图的题上画着图换到不能画的题,直接取第一项会把偏好 C 的学生扔到 Python,
|
||||
// 而且 code store 的 watch 会把 Python 记成新的偏好
|
||||
const preferred = useCodeStore().preferredLanguage()
|
||||
if (preferred !== "Flowchart" && languages.value.includes(preferred)) return preferred
|
||||
// 兜底落在编程语言上:languages 的第一项可能是 Flowchart,不能默认把人扔到画布上
|
||||
return codeLanguages.value[0] ?? languages.value[0] ?? "Python"
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ export const useSubmissionStore = defineStore("submission", () => {
|
||||
async function submit(entry: { contestId: string; problemSetId: string }) {
|
||||
const problem = problemStore.problem
|
||||
if (!problem) return
|
||||
// 格式化、提交都要等请求回来,这中间学生可能已经点了「下一题」或课堂条上的别的题。
|
||||
// 换了题就什么都别再写:不然 A 格式化好的代码会盖掉 B 的编辑器(连带 B 的草稿),
|
||||
// 或者开始跟 A 的那条提交,A 判对了却把 B 标成已解决、弹 B 的点评
|
||||
const stale = () => problemStore.problem?.id !== problem.id
|
||||
syntaxErrorInfo.value = ""
|
||||
formattedBeforeSubmit.value = false
|
||||
|
||||
@@ -102,11 +106,13 @@ export const useSubmissionStore = defineStore("submission", () => {
|
||||
code: codeStore.code.value,
|
||||
language: formatLang,
|
||||
})
|
||||
if (stale()) return
|
||||
if (res.code !== codeStore.code.value) {
|
||||
codeStore.setCode(res.code)
|
||||
formattedBeforeSubmit.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
if (stale()) return
|
||||
if (errorCode(e) === "syntax-error") {
|
||||
// 仅 Python 会出现:message 是 CPython 的报错原文,交给 PythonErrorExplain 翻译
|
||||
syntaxErrorInfo.value = errorMessage(e)
|
||||
@@ -124,9 +130,10 @@ export const useSubmissionStore = defineStore("submission", () => {
|
||||
problemId: problem.id,
|
||||
language: codeStore.code.language,
|
||||
code: codeStore.code.value,
|
||||
// 编辑过程信号,见 utils/editTrace.ts。协作的判断和 ProblemEditor 的 collabHere 同一个口径
|
||||
// 编辑过程信号,见 utils/editTrace.ts。协作的判断和 ProblemEditor 的 collabHere
|
||||
// (teacherCollab.ts 的 useCollabHere)同一个口径:比赛入口不算协作
|
||||
trace: snapshotEditTrace(
|
||||
collabStore.room !== null && collabStore.room.problemId === problem._id,
|
||||
!entry.contestId && collabStore.room !== null && collabStore.room.problemId === problem._id,
|
||||
),
|
||||
}
|
||||
if (entry.contestId) {
|
||||
@@ -142,6 +149,8 @@ export const useSubmissionStore = defineStore("submission", () => {
|
||||
try {
|
||||
const res = await submitCode(data)
|
||||
console.log(`[Submit] 代码已提交: ID=${res.submissionId}`)
|
||||
// 这条照常判完落库、题库里查得到,只是这一页已经换了题,不再跟
|
||||
if (stale()) return
|
||||
// 交上了才清零;被限流 / 网络失败的话这一段接着记,下次提交一起报
|
||||
restartEditTrace(codeStore.code.value.length)
|
||||
|
||||
|
||||
@@ -106,6 +106,13 @@ const { query, clearQuery } = usePagination<SubmissionQuery>({
|
||||
language: useRouteQuery("language", "").value,
|
||||
today: "0",
|
||||
})
|
||||
/**
|
||||
* 从题目页协作条「看他交过什么」点进来时带着 `exactUsername=1`:用户名按整名匹配。
|
||||
* 默认是「包含」,而学号互相包含是常态(帮 ks24a1 时会混进 ks24a10–ks24a19)。
|
||||
* 只对带进来的那个用户名生效,老师在输入框里改了名字就回到原来的包含匹配
|
||||
*/
|
||||
const exactUsernameFor =
|
||||
useRouteQuery<string>("exactUsername", "").value === "1" ? query.username : ""
|
||||
const submissionID = ref("")
|
||||
const problemDisplayID = ref("")
|
||||
const [statisticPanel, toggleStatisticPanel] = useToggle(false)
|
||||
@@ -168,6 +175,9 @@ async function listSubmissions() {
|
||||
contestId: (route.params.contestID as string) ?? "",
|
||||
language: query.language,
|
||||
today: query.today,
|
||||
...(exactUsernameFor && query.username === exactUsernameFor
|
||||
? { exactUsername: "1" as const }
|
||||
: {}),
|
||||
})
|
||||
submissions.value = res.results
|
||||
total.value = res.total
|
||||
|
||||
@@ -24,7 +24,10 @@ import { zonedYear } from "utils/functions"
|
||||
|
||||
const route = useRoute()
|
||||
const { isMobile } = useBreakpoints()
|
||||
const hiddenICP = computed(() => ["problem", "contest problem"].includes(route.name as string))
|
||||
// 题目页两栏顶满整屏,三种入口都不放页脚(原来漏了题单入口,页脚压在编辑器底下)
|
||||
const hiddenICP = computed(() =>
|
||||
["problem", "contest problem", "problemset problem"].includes(route.name as string),
|
||||
)
|
||||
|
||||
// 版权年份也走东八区:站内不留任何一处按浏览器时区取时间部件的代码,
|
||||
// 免得下一个人照着抄
|
||||
|
||||
@@ -29,11 +29,13 @@ const { addEdges, removeNodes, removeEdges, fitView, onNodesInitialized } = useV
|
||||
/**
|
||||
* 进页面读回草稿、载回旧版之后缩放到正好装下整张图。原来画布停在默认的位置和缩放上,
|
||||
* 图大一点就有一半在外面,学生以为草稿丢了。只在这两种时候缩:拖进一个新节点时也会
|
||||
* 触发 nodes-initialized,那时候跟着缩放会让画布跳一下
|
||||
* 触发 nodes-initialized,那时候跟着缩放会让画布跳一下。
|
||||
* 画布是空的就不挂:空画布不会触发 nodes-initialized,挂着的这一下会一直留到学生拖进
|
||||
* 第一个节点,正好把画布拉近、对准那一个节点 —— 就是上面要躲开的那一跳
|
||||
*/
|
||||
let fitPending = false
|
||||
function fitSoon() {
|
||||
fitPending = true
|
||||
fitPending = nodes.value.length > 0
|
||||
}
|
||||
onNodesInitialized(() => {
|
||||
if (!fitPending) return
|
||||
|
||||
@@ -34,6 +34,11 @@ interface Props {
|
||||
* 切到别的题,接通了也不该把这道题的代码交出去。
|
||||
*/
|
||||
problemId?: string
|
||||
/**
|
||||
* 这个入口接不接协作。比赛入口传 false:房间只记题号,比赛题号 1、2、3 和公开题撞号,
|
||||
* 光比对题号挡不住(见 problemPageContext 的 `collab`)
|
||||
*/
|
||||
collab?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -44,6 +49,7 @@ const {
|
||||
placeholder = "",
|
||||
extraExtensions = [],
|
||||
problemId = "",
|
||||
collab = true,
|
||||
} = defineProps<Props>()
|
||||
const code = defineModel<string>("value")
|
||||
|
||||
@@ -74,7 +80,8 @@ const editorView = shallowRef<EditorView | null>(null)
|
||||
|
||||
/** 房间开着,而且开的就是这道题 */
|
||||
const roomIsHere = computed(
|
||||
() => collabStore.room !== null && (!problemId || collabStore.room.problemId === problemId),
|
||||
() =>
|
||||
collab && collabStore.room !== null && (!problemId || collabStore.room.problemId === problemId),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -224,13 +224,11 @@ export function durationToDays(start: Date | string, end: Date | string): string
|
||||
}
|
||||
|
||||
export function secondsToDuration(seconds: number): string {
|
||||
const duration = intervalToDuration({
|
||||
start: 0,
|
||||
end: seconds * 1000,
|
||||
})
|
||||
const hours = (duration.days ?? 0) * 24 + (duration.hours ?? 0)
|
||||
// 直接算,不走 date-fns 的 intervalToDuration:那个会拆出年、月,原来只把天和小时
|
||||
// 折回小时,超过一个月就把月丢了(剩 40 天 1 小时显示成 217:00:00)
|
||||
const total = Math.max(0, Math.floor(seconds))
|
||||
const pad = (n: number) => String(n).padStart(2, "0")
|
||||
return [hours, pad(duration.minutes ?? 0), pad(duration.seconds ?? 0)].join(":")
|
||||
return [Math.floor(total / 3600), pad(Math.floor(total / 60) % 60), pad(total % 60)].join(":")
|
||||
}
|
||||
|
||||
export function submissionMemoryFormat(memory: number | string | undefined) {
|
||||
|
||||
@@ -34,10 +34,17 @@ export async function createTestSubmission(code: Code, input: string) {
|
||||
params: { base64_encoded: true, wait: true },
|
||||
})
|
||||
const data = response.data
|
||||
const status: number | null = data.status?.id ?? null
|
||||
const stdout = base64DecodeUtf8(data.stdout)
|
||||
// 跑通了(3 = Accepted)就只要程序自己的输出,而且只去掉末尾的空白:
|
||||
// - C 有警告(隐式声明 sqrt、用了 gets)时 Judge0 跑通了也带回 compile_output,拼进来
|
||||
// 就成了「你的输出」的一部分,例子永远对不上;后台生成测试点时还会写进 .out
|
||||
// - 开头的空格是输出的一部分(打印菱形、三角形),原来整体 trim() 会把它削掉,
|
||||
// 对的代码被判成「只是空格不一样」
|
||||
if (status === 3) return { status, output: stdout.trimEnd() }
|
||||
// 没跑通:报错原文给人看,两段拼起来
|
||||
return {
|
||||
status: data.status && data.status.id,
|
||||
output: [base64DecodeUtf8(data.compile_output), base64DecodeUtf8(data.stdout)]
|
||||
.join("\n")
|
||||
.trim(),
|
||||
status,
|
||||
output: [base64DecodeUtf8(data.compile_output), stdout].join("\n").trim(),
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# OJ2 设计文档:题目详情页重设计
|
||||
|
||||
日期:2026-09-28
|
||||
状态:**第 14 节的 6 步和第 7 节都已实施**(2026-09-28),界面已照画布「机房(方案 B)」的画板逐块对齐过。和本文有出入的地方:协作中上下文条的跳转没有改成开新标签(老师不在课堂名单里,题库入口下这一条本来就不出现)。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
|
||||
状态:**第 14 节的 6 步和第 7 节都已实施**(2026-09-28),界面已照画布「机房(方案 B)」的画板逐块对齐过。2026-09-28 实施后又做过一轮全面审查并修掉查出的问题,和本文原稿有出入的地方都已改进正文。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
|
||||
那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。**画布管样子,本文管规则**:
|
||||
版式、颜色、文案照画板,效果图表达不了的规则写在这里。
|
||||
|
||||
@@ -169,13 +169,17 @@ B 看得到。
|
||||
「放回编辑器」后提示「已放回,按 Ctrl+Z 撤回」—— 要确认编辑器的撤销能撤回这次替换。
|
||||
- **统计**:出题人、创建时间、标签、数字卡、结果饼图、历年通过率,内容原样。
|
||||
- **点评**:和强制弹窗同一个轮盘(第 7 节),每人只能评一次。
|
||||
- 题单入口三个按钮都不出现。比赛入口没有「点评」。
|
||||
- **我的提交**列表上方另有一行「答案错误 × 3」「答案正确 × 1」(这一页各种结果各几次,和原来一样;
|
||||
被题单闸门锁住的那几次不数)。
|
||||
- 题单入口三个按钮都不出现;管理员角色的「看这道题所有人的提交」挪进「⋯」(叫「本题提交」)。
|
||||
比赛入口没有「点评」。
|
||||
|
||||
### 5.6 手机
|
||||
|
||||
三个页签:题目 / 代码 / 结果,底部固定「运行例子 / 提交」两个按钮(高 44+)。
|
||||
措辞和桌面一致(「这节课 做完 2/5」「正确输出」)。语言选择放在「代码」页签顶部;
|
||||
统计 / 点评 / 我的提交和 ⋯ 菜单里的各项放进页签行右侧的「⋯」。流程图题在手机上只能看点评、不能画(节点库是 HTML5 拖放,手机上拖不动)。
|
||||
统计 / 点评 / 我的提交和 ⋯ 菜单里的各项放进页签行右侧的「⋯」。流程图题在手机上只能看点评、不能画(节点库是 HTML5 拖放,手机上拖不动):「结果」页签多一段
|
||||
「流程图点评」,只读,没有「照着它写代码」「载回画布」。
|
||||
|
||||
## 6. 结果页签里每种情况显示什么
|
||||
|
||||
@@ -190,7 +194,7 @@ B 看得到。
|
||||
| 运行例子(试跑) | ✕ 试跑:N 个例子没对上(不算提交)+ 各例子 ✓/✕ 芯片 | 选中那个例子的差异说明;底部「例子对了也可能在别的测试点上错,提交才算数」 |
|
||||
| 自己输入 | —— | 输入框 +「用这组数据运行」+「填入例子 1 / 2」+ 输出;「这里不判对错」 |
|
||||
| 流程图评分中 | AI 正在看你的图(一般 5 秒左右) | 评的时候画布还能改,改了不影响这一次 |
|
||||
| 流程图评完 | 86 分 A 级 + 一句评语 + 历史分数条 | 四项分数与评语、改进建议;A/S 时「照着它写代码 →」;「把这一版载回画布」**只在画布改过之后**出现 |
|
||||
| 流程图评完 | 86 分 A 级 + 一句评语 + 历史分数条 | 四项分数与评语、改进建议;点历史分数条上的某一次,看那一版画的图(只读缩略图,可放大,不动画布);A/S 时「照着它写代码 →」;「把这一版载回画布」**只在画布改过之后**出现 |
|
||||
| 管理员 | —— | 测试点表格原样(只有管理员有数据) |
|
||||
|
||||
**运行例子的判定**:两边都去掉末尾空白再比(现在只 trim 了运行输出,末尾带空格的样例永远「不通过」,
|
||||
@@ -237,6 +241,8 @@ B 看得到。
|
||||
| 统计 / 点评 / 我的提交 | ✓ | 全部隐藏 | 统计、我的提交 ✓;点评 ✗ |
|
||||
| 「已解决 / 尝试过」标签 | ✓ | 隐藏 | ✓ |
|
||||
| 相似题 | ✓ | ✗(点进去会离开题单) | ✗ |
|
||||
| 老师协作 | ✓ | ✓ | ✗(房间只记题号,比赛题号 1、2、3 和公开题撞号) |
|
||||
| 课堂统计(教师) | ✓ | ✓ | ✗(统计接口按题号在公开题库里找题) |
|
||||
| SQL 题 | 隐藏「运行例子」「自己输入」(Judge0 跑不了 SQL);数据表可横向滚动 | 同左 | 同左 |
|
||||
|
||||
一道题既在这节课里、又是从题单进来的:按入口路由,显示题单条。
|
||||
@@ -248,6 +254,9 @@ B 看得到。
|
||||
(现在的「复制回到题目」走 `setCode`,协作中会经 Yjs 直接覆盖学生的代码)。
|
||||
- **协作中所有跳转开新标签页**:抽屉里的链接、班级排名旁的「查看」、上下文条都算(现在只有工具栏
|
||||
「本题提交」这么做)。「看他交过什么」要带上学生用户名,现在只带题号。
|
||||
实现:抽屉里的链接各自 `window.open`;站内的 router-link(上下文条、相似题、下一题)统一由
|
||||
`detail.vue` 的路由守卫接住,协作中离开这道题就开新标签、这一页不动。「看他交过什么」带
|
||||
`exactUsername=1`,提交列表按整名匹配(默认的「包含」会混进学号相近的同学)。
|
||||
- 老师接单后落在 `/problem/:id`,「结果」页签是空的 —— 显示学生最近一次提交的结果(这一项是新增,
|
||||
需要接口支持;做不到就显示「学生还没提交过」之类的空状态,别留白)。
|
||||
- **学生在求助排队中或已接入时换题,会断开协作**(`SyncCodeEditor` 的 detach):换题入口要先确认。
|
||||
@@ -257,8 +266,9 @@ B 看得到。
|
||||
1. **提交状态上移到 store**:当前提交、判题跟踪(WebSocket + 轮询)、AI 提示、失败计数、
|
||||
流程图评分(含 WebSocket 订阅与 3 分钟超时)都不再挂在按钮组件上。结果页签只负责显示。
|
||||
这是修掉「编辑器一卸载结果就丢」的前提。
|
||||
2. **错误标记也进 store**:`PythonErrorExplain` / `RuntimeErrorExplain` 现在挂载时标红、卸载时清掉,
|
||||
切到「题目」页签红线就没了。改成由结果决定,和显示组件解耦。
|
||||
2. **错误标记不能跟着页签消失**:`PythonErrorExplain` / `RuntimeErrorExplain` 挂载时标红、卸载时清掉,
|
||||
原来切到「题目」页签红线就没了。实际做法没挪进 store:「结果」页签第一次打开之后就一直挂着、
|
||||
只是藏起来(`detail.vue` 的 `resultMounted`),标红跟着结果走,换题时由换题重置清掉。
|
||||
3. **三种入口用同一个编辑器**:合并 `ProblemEditor` 与 `BasicEditor`,协作、错误标记、中文标点替换
|
||||
在三种入口都有;比赛里是否允许协作由第 8 节的表决定。
|
||||
4. **换题 = 完整重置**:例子、语言、流程图状态、结果页签、定时器全部重来。`1b149a4` 已经修了
|
||||
@@ -267,9 +277,12 @@ B 看得到。
|
||||
|
||||
## 11. 后端前置改动
|
||||
|
||||
1. **流程图 A/S 算完成**:提交流程图的请求带上 `problemSetId`;评分落库后,A/S 走和
|
||||
`recordSolvedProblem`(`services/problemset.ts`)同样的题单记账;课堂条的 `myStatus`
|
||||
(`classroom` 路由)把流程图 A/S 算作 accepted。
|
||||
1. **流程图 A/S 算完成**:评分落库后,A/S 走和 `recordSolvedProblem`(`services/problemset.ts`)
|
||||
同样的题单记账 —— 和判代码那一路一样,记进**所有已加入、且含这道题**的题单,所以提交请求不用带
|
||||
`problemSetId`。`classroom` 路由里「流程图 A/S 算做完、交过流程图算做过」是同一个口径,
|
||||
用在课堂条的 `myStatus`、看板的格子,也用在推断「这节课做哪几道」、「班上 M 人做完」、
|
||||
看板上「最近几节课来了几次」和猜正在上课的班(用户 2026-09-28 拍板一起改)。
|
||||
班级对比、个人资料里的通过数仍然只数代码。
|
||||
2. **流程图历史分数**:新增一个接口,一次返回这道题自己所有流程图提交的 id、分数、等级、时间
|
||||
(现在 `/flowchart/history` 一次只给一条)。
|
||||
3. **流程图历史的防抄闸门**:加入题单之前画的图,照 `canViewSubmission`(`routes/submission.ts`)
|
||||
|
||||
Reference in new issue
Block a user