Files
OJ2/apps/web/src/oj/problem/components/FlowchartResult.vue
T
xuyueandClaude Opus 5.5 e7b95d211d 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>
2026-09-29 00:07:16 +08:00

617 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
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"
/**
* 「结果」页签里流程图作业的那一块:AI 在评 / 评完了几分 / 历次分数 / 评语和改进建议。
*
* 原来评分详情是提交按钮旁一个 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, 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()
// 暗色主题的成功色是浅薄荷绿,上面的白字看不清
const isDark = useDark()
const passText = computed(() => (isDark.value ? "rgba(0, 0, 0, 0.85)" : "#fff"))
/** 正在看的那一次:点过哪次就是哪次,否则是最新的 */
const current = computed(() => {
const id = selectedId.value ?? scores.value.at(-1)?.id
return id ? (scores.value.find((row) => row.id === id) ?? null) : null
})
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,
(id) => {
if (id && !details.value[id]) flowchartStore.select(selectedId.value)
},
{ immediate: true },
)
const criteria = computed(() =>
sortFlowchartCriteria(
(detail.value?.aiCriteriaDetails ?? {}) as Record<
string,
{ score: number; max: number; comment?: string }
>,
),
)
const suggestions = computed(() =>
(detail.value?.aiSuggestions ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean),
)
function gradeType(grade: string) {
if (isFlowchartPass(grade)) return "success"
if (grade === "B") return "warning"
return "error"
}
function percentType(ratio: number) {
if (ratio >= 0.6) return "success"
if (ratio >= 0.4) return "warning"
return "error"
}
// ==================== 载回画布 ====================
/** 这一版的画布数据(压缩前的 JSON 字符串);老提交的压缩数据可能是坏的 */
const versionJson = computed(() => {
const data = detail.value?.flowchartData?.data
if (typeof data !== "string" || !data) return null
try {
return decompressFromBase64(data)
} catch {
return null
}
})
/**
* 一张图「长什么样」:节点的 id / 类型 / data(字写在里面)/ 位置,连线的两端和标签。不直接比 JSON 字符串 ——
* 载回画布时编辑器会补上默认字段(sourceHandle: null 之类),老提交还带着 vue-flow 的运行时
* 字段,字符串永远对不上,按钮就一直在。
*/
type Shape = { nodes?: unknown[]; edges?: unknown[] }
function signature(data: Shape) {
const nodes = (data.nodes ?? []).map((raw) => {
const node = raw as {
id?: string
type?: string
data?: unknown
position?: { x?: number; y?: number }
}
return [
node.id,
node.type,
// 节点上的字(customLabel)和原始类型都在 data 里,整个带上
JSON.stringify(node.data ?? {}),
Math.round(node.position?.x ?? 0),
Math.round(node.position?.y ?? 0),
].join("|")
})
const edges = (data.edges ?? []).map((raw) => {
const edge = raw as {
source?: string
target?: string
sourceHandle?: string | null
targetHandle?: string | null
label?: unknown
}
return [
edge.source,
edge.target,
edge.sourceHandle ?? "",
edge.targetHandle ?? "",
edge.label ?? "",
].join("|")
})
return JSON.stringify([nodes.sort(), edges.sort()])
}
const versionSignature = computed(() => {
if (!versionJson.value) return null
try {
return signature(JSON.parse(versionJson.value) as Shape)
} catch {
return null
}
})
/**
* 只有画布和这一版不一样时才给「载回画布」:刚交完、还没动过的时候,这个按钮点了什么
* 都不会变,只会让人以为它坏了。读 getFlowchartData() 会顺带跟踪画布的节点和边。
*/
const canvasDiffers = computed(() => {
if (!editor.value || !versionSignature.value) return false
return signature(editor.value.getFlowchartData()) !== versionSignature.value
})
function loadToCanvas() {
if (!editor.value || !versionJson.value) return
try {
const json = JSON.parse(versionJson.value)
editor.value.setFlowchartData({ nodes: json.nodes || [], edges: json.edges || [] })
message.success("已载回画布,按 Ctrl+Z 可以撤回")
} catch {
message.error("这一版的图数据坏了,载不回画布")
}
}
/** A / S 之后:照着图写代码。切回学生习惯的那门语言 */
const canCode = computed(() => problemStore.codeLanguages.length > 0)
function goCode() {
problemStore.switchLanguage(problemStore.supportedLanguage(codeStore.preferredLanguage()))
}
</script>
<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-else-if="!scores.length && phase !== 'evaluating'"
class="empty"
:description="
viewOnly
? '还没交过流程图。流程图要在电脑上画(手机上拖不动),交给 AI 点评之后,评分和建议在这里也看得到'
: '还没交过流程图。画好之后点「交给 AI 点评」,评分和建议会出现在这里'
"
/>
<!-- 历次分数连成一条:一眼看出是不是越改越好;正在评的那次是虚线框 -->
<div v-if="scores.length || phase === 'evaluating'" class="history">
<span class="history-lead"
>AI 点评 · 画过 {{ scores.length + (phase === "evaluating" ? 1 : 0) }} 次:</span
>
<template v-for="(row, index) in scores" :key="row.id">
<span v-if="index > 0" class="arrow" aria-hidden="true">→</span>
<button
type="button"
class="attempt"
:class="{
pass: isFlowchartPass(row.grade),
active: current?.id === row.id && !(phase === 'evaluating' && viewingLatest),
}"
:title="`第 ${index + 1} 次 · ${parseTime(row.createTime, 'MM-DD HH:mm')}`"
@click="flowchartStore.select(index === scores.length - 1 ? null : row.id)"
>
{{ row.grade }} {{ row.score }}
</button>
</template>
<template v-if="phase === 'evaluating'">
<span v-if="scores.length" class="arrow" aria-hidden="true">→</span>
<span class="attempt pending">第 {{ scores.length + 1 }} 次</span>
</template>
</div>
<p v-if="hiddenCount" class="note">
另外 {{ hiddenCount }} 次是加入题单之前画的,先藏起来了:在题单里画到 A 或 S 就解锁
</p>
<!-- 正在评:评的是最新这一次,所以只在看最新时显示 -->
<template v-if="phase === 'evaluating' && viewingLatest">
<div class="grading">
<n-spin :size="26" />
<div>
<div class="grading-title">AI 正在看你的图</div>
<div class="grading-note">一般 5 秒左右。可以先去看题目,评完这里会亮起来</div>
</div>
</div>
<p class="note">评的时候画布上还能改,改了不影响这一次的分数</p>
</template>
<div v-else-if="phase === 'failed' && viewingLatest" class="failed">
AI 这次没评出来,再点一次「交给 AI 点评」试试
</div>
<section v-else-if="current" class="current">
<div class="score-line">
<span class="score" :class="gradeType(current.grade)">{{ current.score }}</span>
<span class="score-unit">分</span>
<span class="grade" :class="gradeType(current.grade)">{{ current.grade }} 级</span>
<span v-if="detail?.aiFeedback || isFlowchartPass(current.grade)" class="feedback">
{{ detail?.aiFeedback ?? ""
}}{{ isFlowchartPass(current.grade) ? " 这道题算做完了。" : "" }}
</span>
</div>
<n-flex v-if="detailLoading && !detail" justify="center" class="pad">
<n-spin size="small" />
</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">
<b>{{ name }}</b>
<span class="criterion-score">{{ item.score || 0 }} / {{ item.max }}</span>
</div>
<div class="bar">
<div
class="bar-fill"
:class="percentType(item.score / item.max)"
:style="{ width: `${Math.min(100, ((item.score || 0) / item.max) * 100)}%` }"
/>
</div>
<div v-if="item.comment" class="comment">{{ item.comment }}</div>
</div>
</div>
<div v-if="suggestions.length" class="suggestions">
<div class="suggestions-title">还可以改进</div>
<div v-for="(line, index) in suggestions" :key="index" class="suggestion">
<span v-if="index === 0" class="tag">重点</span>
<span>{{ line }}</span>
</div>
</div>
<div v-if="!viewOnly" class="actions">
<n-button
v-if="isFlowchartPass(current.grade) && canCode"
type="primary"
size="large"
@click="goCode"
>
照着它写代码 →
</n-button>
<n-button v-if="canvasDiffers" secondary @click="loadToCanvas">把这一版载回画布</n-button>
</div>
</template>
</section>
</div>
</template>
<style scoped>
.flowchart-result {
padding-bottom: 16px;
}
.empty {
margin-top: 48px;
}
.note {
font-size: 13px;
color: v-bind("theme.textColor3");
margin: 0 0 10px;
}
.pad {
padding: 16px 0;
}
.history {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
font-size: 13px;
}
.history-lead {
color: v-bind("theme.textColor2");
}
.arrow {
color: v-bind("theme.textColor3");
}
.attempt {
height: 26px;
box-sizing: border-box;
padding: 0 9px;
border-radius: 13px;
border: 1px solid transparent;
background-color: rgba(128, 128, 128, 0.12);
color: v-bind("theme.textColor2");
font: inherit;
font-size: 13px;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.attempt.active {
background-color: v-bind("theme.textColor2");
color: v-bind("theme.cardColor");
font-weight: 600;
}
.attempt.pass.active {
background-color: v-bind("theme.successColor");
color: v-bind("passText");
}
.attempt.pending {
display: inline-flex;
align-items: center;
cursor: default;
background: transparent;
border: 1px dashed v-bind("theme.successColor");
color: v-bind("theme.successColorPressed");
}
.grading {
display: flex;
align-items: center;
gap: 14px;
padding: 18px;
margin-bottom: 10px;
border-radius: 8px;
background-color: rgba(24, 160, 88, 0.07);
border: 1px solid rgba(24, 160, 88, 0.25);
}
.grading-title {
font-size: 16px;
font-weight: 700;
}
.grading-note {
margin-top: 2px;
font-size: 13px;
color: v-bind("theme.textColor2");
}
.failed {
padding: 12px 14px;
border-radius: 6px;
background-color: rgba(240, 160, 32, 0.1);
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;
flex-wrap: wrap;
gap: 4px 8px;
margin-bottom: 14px;
}
.score {
font-size: 34px;
font-weight: 700;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.score.success {
color: v-bind("theme.successColorPressed");
}
.score.warning {
color: v-bind("theme.warningColorPressed");
}
.score.error {
color: v-bind("theme.errorColorPressed");
}
.score-unit {
color: v-bind("theme.textColor2");
}
.grade {
align-self: center;
padding: 2px 8px;
border-radius: 4px;
font-size: 13px;
font-weight: 700;
color: #fff;
background-color: v-bind("theme.errorColor");
}
.grade.success {
background-color: v-bind("theme.successColor");
color: v-bind("passText");
}
.grade.warning {
background-color: v-bind("theme.warningColor");
}
.feedback {
flex: 1 1 200px;
align-self: center;
margin-left: 6px;
font-size: 14px;
line-height: 1.6;
}
/* 四项得分:两列,每项一条进度 */
.criteria {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
margin-bottom: 14px;
}
.criterion-head {
display: flex;
justify-content: space-between;
font-size: 14px;
}
.criterion-score {
font-variant-numeric: tabular-nums;
color: v-bind("theme.textColor2");
}
.bar {
height: 6px;
margin: 4px 0;
border-radius: 3px;
background-color: rgba(128, 128, 128, 0.14);
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 3px;
background-color: v-bind("theme.successColor");
}
.bar-fill.warning {
background-color: v-bind("theme.warningColor");
}
.bar-fill.error {
background-color: v-bind("theme.errorColor");
}
.comment {
font-size: 13px;
color: v-bind("theme.textColor2");
}
.suggestions {
padding: 12px 14px;
margin-bottom: 14px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
border: 1px solid v-bind("theme.dividerColor");
}
.suggestions-title {
font-weight: 600;
margin-bottom: 6px;
}
.suggestion {
display: flex;
align-items: baseline;
gap: 8px;
line-height: 1.7;
}
.tag {
flex: none;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
background-color: rgba(240, 160, 32, 0.16);
color: v-bind("theme.warningColorPressed");
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
</style>