feat(流程图): AI 点评改成拿例子和测试数据在图上走一遍,同一张图不能再原样重交抽等级

原来让 AI 按逻辑 40 / 完整 30 / 规范 20 / 清晰 10 打总分:同一张图交两次能差 35 分
(有一张 C 再交变 S),22% 的提交和上一次一模一样;评语里写 node_9 这种学生看不到的编号。
照画布稿 FlowResFail / FlowResPass:
- AI 拿题目的例子和最多 3 组测试数据,从开始照着图走到结束,逐组说输出什么、对不对、
  最先错在哪个框;全对 A,全对且写法挑不出毛病 S,走对一半以上 B,否则 C
- 结果页签:「3 组走对 2 组」,逐组 ✓ / ✗,点开看「你的图输出 / 应该输出」,画布上按先后
  编号亮出这一组走过的框、错的那个标红;图一改就不亮,载回那一版又能亮
- 画布和上次评完的那张一模一样时「交给 AI 点评」灰掉;后端也兜着:同一道题一模一样的图
  直接照搬上次走例子的结果,不再问 AI
- 题目一组例子、测试数据都没有时退回老的四项分;老提交照旧按四项分显示
- 历次等级、提交列表的胶囊只写等级,不写分数

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 21:08:42 +08:00
1 parent 09f8a56913
commit 099c6d7ba6
17 files changed
+969 -77

No files matched your search

@@ -9,10 +9,12 @@ import { parseTime } from "utils/functions"
* 原来这几个和节点库挤在画布右上角的面板里,盖住画好的框;保存状态只是一个 20px 的圆点。
* 「清空画布」收进了「⋯」。
*/
const { editor } = storeToRefs(useFlowchartStore())
const { editor, unchanged } = storeToRefs(useFlowchartStore())
const theme = useThemeVars()
const saved = computed(() => {
// 「交给 AI 点评」灰着的时候说一句为什么(设计稿 FlowResPass)
if (unchanged.value) return "这一版已点评"
const at = editor.value?.savedAt
return at ? `已保存 ${parseTime(at, "HH:mm")}` : ""
})
@@ -6,8 +6,10 @@ 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"
import { parseDoc, signature } from "shared/components/FlowchartEditor/model"
import { parseTime } from "utils/functions"
import { mermaidOrder, signature } from "shared/components/FlowchartEditor/model"
import FlowchartCases from "shared/components/FlowchartCases.vue"
import { caseHeadline, caseReport } from "utils/flowchart"
/**
* 「结果」页签里流程图作业的那一块:AI 在评 / 评完了几分 / 历次分数 / 评语和改进建议。
@@ -107,27 +109,22 @@ function percentType(ratio: number) {
// ==================== 载回画布 ====================
/**
* 这一版的图(结构树)。2026-10 换成插入式编辑器之前交的是 vue-flow 的格式,读不出来就是
* null:那些只能看上面的图,载不回画布。老提交的压缩数据也可能是坏的
* null:那些只能看上面的图,载不回画布
*/
const versionDoc = computed(() => {
const data = detail.value?.flowchartData?.data
if (typeof data !== "string" || !data) return null
try {
return parseDoc(JSON.parse(decompressFromBase64(data)))
} catch {
return null
}
})
const versionDoc = computed(() => flowchartStore.versionDoc(detail.value))
const versionSignature = computed(() => (versionDoc.value ? signature(versionDoc.value) : null))
/**
* 只有画布和这一版不一样时才给「载回画布」:刚交完、还没动过的时候,这个按钮点了什么
* 都不会变,只会让人以为它坏了。比的是结构和字,不比 id。
* 读 getDoc() 会顺带跟踪画布的数据
* 画布和这一版一样不一样。不一样才给「载回画布」(刚交完还没动过时点了什么都不会变,
* 只会让人以为它坏了);一样才在画布上亮路径。读 getDoc() 会顺带跟踪画布的数据
*/
const canvasDiffers = computed(() => {
if (!editor.value || !versionDoc.value) return false
return signature(editor.value.getDoc()) !== signature(versionDoc.value)
const canvasSame = computed(() => {
if (!editor.value || !versionSignature.value) return false
return signature(editor.value.getDoc()) === versionSignature.value
})
const canvasDiffers = computed(
() => !!editor.value && !!versionSignature.value && !canvasSame.value,
)
function loadToCanvas() {
if (!editor.value || !versionDoc.value) return
@@ -135,6 +132,46 @@ function loadToCanvas() {
message.success("已载回画布,按 Ctrl+Z 可以撤回")
}
// ==================== 走例子 ====================
/** 2026-10 起的评法:逐组走一遍。老提交是 null,照旧显示四项分 */
const report = computed(() => caseReport(detail.value?.aiCriteriaDetails))
/** 点开的是哪一组;换一次提交就默认点开第一组没走对的(设计稿 FlowResFail) */
const selectedCase = ref<number | null>(null)
watch(
() => [current.value?.id, report.value] as const,
([, next]) => {
const firstWrong = next?.cases.findIndex((row) => !row.ok) ?? -1
selectedCase.value = firstWrong >= 0 ? firstWrong : null
},
{ immediate: true },
)
/**
* 点开的那一组在画布上走过的路。AI 报的是 Mermaid 的框号(n3),按交上去那一版的结构对回
* 画布上的框 —— 所以只在画布还是那一版时亮,改过了就不亮(载回画布之后又能亮)
*/
watch(
() => [selectedCase.value, report.value, canvasSame.value, editor.value] as const,
([index, next, same, instance]) => {
if (!instance || viewOnly) return
const row = index === null ? undefined : next?.cases[index]
if (!row || !same || !versionDoc.value) {
instance.setHighlight(null)
return
}
const order = mermaidOrder(versionDoc.value)
const toStep = (id: string) => order[Number(id.slice(1))]
instance.setHighlight({
order: row.path.map(toStep).filter((id): id is string => !!id),
bad: row.wrongAt ? (toStep(row.wrongAt) ?? null) : null,
})
},
// 切回「结果」页签时组件重新挂载,这些值早就齐了,不会再变一次
{ immediate: true },
)
onBeforeUnmount(() => editor.value?.setHighlight(null))
/** A / S 之后:照着图写代码。切回学生习惯的那门语言 */
const canCode = computed(() => problemStore.codeLanguages.length > 0)
function goCode() {
@@ -176,7 +213,7 @@ function goCode() {
:title="`第 ${index + 1} 次 · ${parseTime(row.createTime, 'MM-DD HH:mm')}`"
@click="flowchartStore.select(index === scores.length - 1 ? null : row.id)"
>
{{ row.grade }} {{ row.score }}
{{ row.grade }}
</button>
</template>
<template v-if="phase === 'evaluating'">
@@ -206,7 +243,13 @@ function goCode() {
</div>
<section v-else-if="current" class="current">
<div class="score-line">
<!-- 设计稿 FlowResFail / FlowResPass:等级 + 几组走对,下面逐组 -->
<div v-if="report" class="headline">
<span class="grade" :class="gradeType(current.grade)">{{ current.grade }} 级</span>
<span class="headline-text">{{ caseHeadline(report) }}</span>
<span v-if="isFlowchartPass(current.grade)" class="headline-note">这道题算做完了</span>
</div>
<div v-else 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>
@@ -220,8 +263,17 @@ function goCode() {
<n-spin size="small" />
</n-flex>
<template v-if="detail && report">
<FlowchartCases v-model:selected="selectedCase" :report="report" class="block" />
<p v-if="detail.aiFeedback" class="tip">{{ detail.aiFeedback }}</p>
<div v-if="detail.aiSuggestions" class="better">
<div class="better-title">还可以更好</div>
<p>{{ detail.aiSuggestions }}</p>
</div>
</template>
<template v-if="detail">
<div v-if="detail.mermaidCode" class="version">
<div v-if="detail.mermaidCode && (!report || canvasDiffers || viewOnly)" class="version">
<div class="version-head">
<span class="version-title">{{ versionTitle }}</span>
<n-button text type="primary" size="small" @click="versionZoom = true">
@@ -250,7 +302,7 @@ function goCode() {
</n-modal>
</div>
<div v-if="criteria.length" class="criteria">
<div v-if="!report && criteria.length" class="criteria">
<div v-for="[name, item] in criteria" :key="name" class="criterion">
<div class="criterion-head">
<b>{{ name }}</b>
@@ -267,7 +319,7 @@ function goCode() {
</div>
</div>
<div v-if="suggestions.length" class="suggestions">
<div v-if="!report && 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>
@@ -483,6 +535,58 @@ function goCode() {
line-height: 1.6;
}
.headline {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 10px;
margin-bottom: 12px;
}
.headline .grade {
font-size: 16px;
padding: 2px 10px;
}
.headline-text {
font-size: 22px;
font-weight: 700;
}
.headline-note {
font-size: 14px;
color: v-bind("theme.textColor2");
}
.block {
margin-bottom: 12px;
}
.tip {
margin: 0 0 14px;
padding: 10px 12px;
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.08);
font-size: 14px;
line-height: 1.7;
}
.better {
margin-bottom: 14px;
font-size: 14px;
line-height: 1.7;
}
.better-title {
font-weight: 600;
font-size: 15px;
margin-bottom: 4px;
}
.better p {
margin: 0;
}
/* 四项得分:两列,每项一条进度 */
.criteria {
display: grid;
@@ -1,8 +1,7 @@
<script lang="ts" setup>
import { storeToRefs } from "pinia"
import { compressToBase64 } from "utils/functions"
import { useBreakpoints } from "shared/composables/breakpoints"
import { countBlank, toMermaid } from "shared/components/FlowchartEditor/model"
import { countBlank } from "shared/components/FlowchartEditor/model"
import { useProblemStore } from "oj/store/problem"
import { useFlowchartStore, type FlowchartEditorInstance } from "oj/store/flowchart"
@@ -19,7 +18,7 @@ const flowchartEditorRef = inject<Ref<FlowchartEditorInstance | null>>("flowchar
const message = useMessage()
const { problem } = storeToRefs(useProblemStore())
const flowchartStore = useFlowchartStore()
const { loading } = storeToRefs(flowchartStore)
const { loading, unchanged } = storeToRefs(flowchartStore)
const { isDesktop } = useBreakpoints()
function submit() {
@@ -36,7 +35,7 @@ function submit() {
message.warning(`有 ${blank} 个框还没写字,点一下框就能写`)
return
}
flowchartStore.submit(toMermaid(doc), compressToBase64(JSON.stringify(doc)))
flowchartStore.submit(doc)
}
// 进题、换题都读一次历次分数(store 里同一道题只读一次)
@@ -48,7 +47,8 @@ watch(() => problem.value?.id, flowchartStore.ensureLoaded, { immediate: true })
:size="isDesktop ? 'medium' : 'small'"
type="primary"
:loading="loading"
:disabled="loading"
:disabled="loading || unchanged"
:title="unchanged && !loading ? '这一版已经点评过了,改一改再交' : undefined"
@click="submit"
>
{{ loading ? "AI 点评中…" : "交给 AI 点评" }}
+49 -4
View File
@@ -2,7 +2,14 @@ import { createDiscreteApi } from "naive-ui"
import { defineStore } from "pinia"
import { errorCode } from "utils/api"
import { isFlowchartPass, type FlowchartScores, type FlowchartSubmission } from "@oj2/contract"
import type { FlowDoc } from "shared/components/FlowchartEditor/model"
import {
parseDoc,
signature,
toMermaid,
type FlowDoc,
type FlowHighlight,
} from "shared/components/FlowchartEditor/model"
import { compressToBase64, decompressFromBase64 } from "utils/functions"
import { getFlowchartScores, getFlowchartSubmission, submitFlowchart } from "oj/api"
import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
@@ -29,6 +36,7 @@ export interface FlowchartEditorInstance {
undo: () => void
redo: () => void
clear: () => void
setHighlight: (highlight: FlowHighlight | null) => void
canUndo: boolean
canRedo: boolean
savedAt: Date | null
@@ -90,6 +98,28 @@ export const useFlowchartStore = defineStore("flowchart", () => {
}
/** 最近一次交上去的那张图,评到 A/S 时亮到「我的流程图」 */
const lastSubmittedMermaidCode = ref("")
/**
* 最近一次**评完**的那张图长什么样(signature)。画布和它一模一样时不让再交 ——
* 原来同一张图连点能抽出不同的等级,22% 的提交和上一次一模一样(后端也照搬上一次的结果)。
* 评失败的那次不算:那种得让学生原样再交一次
*/
const lastSignature = ref<string | null>(null)
let pendingSignature: string | null = null
const unchanged = computed(() => {
if (!editor.value || !lastSignature.value) return false
return signature(editor.value.getDoc()) === lastSignature.value
})
/** 某一次交上去的图(结构树);2026-10 之前的 vue-flow 格式读不出来 */
function versionDoc(detail: FlowchartSubmission | undefined): FlowDoc | null {
const data = detail?.flowchartData?.data
if (typeof data !== "string" || !data) return null
try {
return parseDoc(JSON.parse(decompressFromBase64(data)))
} catch {
return null
}
}
/** 最近一次分数是哪道题的;换题后置空,下次挂载时重新拉 */
let loadedFor: number | null = null
/**
@@ -186,11 +216,12 @@ export const useFlowchartStore = defineStore("flowchart", () => {
return
}
latestRating.value = { score: outcome.score, grade: outcome.grade }
lastSignature.value = pendingSignature
phase.value = "done"
selectedId.value = null
evaluatedSeq.value++
// 学生这会儿可能在看题目:只提示一句、页签上亮状态,不强行把他切过去(设计文档 5.3)
message.success(`AI 点评完了:${outcome.score} 分,${outcome.grade} 级`)
message.success(`AI 点评完了:${outcome.grade} 级`)
if (isFlowchartPass(outcome.grade) && lastSubmittedMermaidCode.value) {
myFlowchartStore.show(lastSubmittedMermaidCode.value)
}
@@ -236,8 +267,10 @@ export const useFlowchartStore = defineStore("flowchart", () => {
useFlowchartWebSocket(handleWebSocketMessage)
// ==================== 提交 ====================
/** 交一张图。图的两种形态(Mermaid 源码、压缩过的编辑器数据)由组件从编辑器里取出来给 */
async function submit(mermaidCode: string, compressedData: string) {
/** 交一张图:Mermaid 给 AI 看、给缩略图画,结构树压缩后存着(载回画布、亮路径用) */
async function submit(doc: FlowDoc) {
const mermaidCode = toMermaid(doc)
const compressedData = compressToBase64(JSON.stringify(doc))
const problem = problemStore.problem
if (!problem) return
// 原来没有这一道:没登录也能点,请求发出去才被拦下,学生只看到一句「提交失败」
@@ -248,6 +281,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
const gen = generation
lastSubmittedMermaidCode.value = mermaidCode
pendingSignature = signature(doc)
loading.value = true
phase.value = "evaluating"
// 结果页签回到最新这一次:刚才可能在翻以前的某一次,那样就看不到「AI 正在看你的图」
@@ -321,6 +355,13 @@ export const useFlowchartStore = defineStore("flowchart", () => {
await loadScores(problemId)
if (problemStore.problem?.id !== problemId) return
scoresFailed.value = false
const latest = scores.value.at(-1)
if (latest) {
const doc = versionDoc(await fetchDetail(latest.id))
if (problemStore.problem?.id !== problemId) return
// 这一会儿学生已经交了新的一张,别拿旧的盖掉
if (doc && phase.value === "idle") lastSignature.value = signature(doc)
}
const passed = scores.value.filter((row) => isFlowchartPass(row.grade)).at(-1)
if (passed) {
const detail = await fetchDetail(passed.id)
@@ -381,6 +422,8 @@ export const useFlowchartStore = defineStore("flowchart", () => {
detailLoading.value = false
scoresFailed.value = false
lastSubmittedMermaidCode.value = ""
lastSignature.value = null
pendingSignature = null
loadedFor = null
}
@@ -406,6 +449,8 @@ export const useFlowchartStore = defineStore("flowchart", () => {
scoresFailed,
editor,
attachEditor,
unchanged,
versionDoc,
submit,
ensureLoaded,
select,
@@ -10,6 +10,8 @@ import { useTone } from "../composables/tone"
import { submissionTimeText } from "../utils"
import FlowchartState from "./FlowchartState.vue"
import UserName from "shared/components/UserName.vue"
import FlowchartCases from "shared/components/FlowchartCases.vue"
import { caseHeadline, caseReport } from "utils/flowchart"
/**
* 流程图模式的右栏:左边画出来的图,右边四项评分和建议。原来要点编号再开一个
@@ -124,6 +126,14 @@ function barColor(score: number, max: number) {
return tone("error").solid
}
/** 2026-10 起的「走例子」报告;老提交是 null,照旧显示四项分 */
const report = computed(() => caseReport(shown.value?.aiCriteriaDetails))
const openCase = ref<number | null>(null)
watch(
() => shown.value?.id,
() => (openCase.value = null),
)
const suggestions = computed(() =>
(shown.value?.aiSuggestions ?? "")
.split("\n")
@@ -193,7 +203,7 @@ const canRetry = computed(
</div>
<div class="meta">
<FlowchartState :status="row.status" :grade="row.aiGrade" :score="row.aiScore" />
<FlowchartState :status="row.status" :grade="row.aiGrade" />
<span>{{ submissionTimeText(row.createTime, true) }}</span>
<span v-if="row.processingTime">评分用时 {{ row.processingTime.toFixed(1) }} 秒</span>
</div>
@@ -232,6 +242,15 @@ const canRetry = computed(
<template v-else-if="row.status === FlowchartSubmissionStatus.FAILED">
<span class="muted">这次评分失败了{{ teacher ? ",可以点「重新评分」" : "" }}</span>
</template>
<template v-else-if="shown && report">
<b class="headline">{{ caseHeadline(report) }}</b>
<FlowchartCases v-model:selected="openCase" :report="report" />
<div v-if="shown.aiFeedback" class="note">{{ shown.aiFeedback }}</div>
<div v-if="shown.aiSuggestions" class="note">
<b>还可以更好:</b>
<div>{{ shown.aiSuggestions }}</div>
</div>
</template>
<template v-else-if="shown">
<div v-for="[name, item] in criteria" :key="name" class="crit">
<div class="crit-head">
@@ -442,6 +461,10 @@ const canRetry = computed(
max-height: 100%;
}
.headline {
font-size: 16px;
}
.score-box {
flex: 1 1 0;
min-width: 0;
@@ -3,13 +3,12 @@ import { FlowchartSubmissionStatus } from "utils/types"
import { useTone, type Tone } from "../composables/tone"
/**
* 流程图提交在列表里的那个胶囊:评完的直接是「A 级 86 分」,没评完的是
* 流程图提交在列表里的那个胶囊:评完的直接是「A 级」,没评完的是
* 排队中 / 评分中 / 评分失败。原来等级、分数、状态分三列,老师要横着对。
*/
const props = defineProps<{
status: number
grade: string | null
score: number | null
}>()
const tone = useTone()
@@ -35,7 +34,7 @@ const colors = computed(() => tone(view.value.tone))
<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>
<b>{{ view.grade }} 级</b>
</template>
<template v-else>{{ view.text }}</template>
</span>
@@ -54,8 +53,4 @@ const colors = computed(() => tone(view.value.tone))
white-space: nowrap;
flex: none;
}
.score {
font-weight: 400;
}
</style>
+2 -10
View File
@@ -902,22 +902,14 @@ function dayBreak(index: number) {
<span class="pid">{{ row.problemDisplayId }}</span> {{ row.problemTitle }}
</span>
<span class="c-state">
<FlowchartState
:status="row.status"
:grade="row.aiGrade"
:score="row.aiScore"
/>
<FlowchartState :status="row.status" :grade="row.aiGrade" />
</span>
</template>
<template v-else>
<div class="m-line">
<UserName :username="row.username" />
<div class="spacer"></div>
<FlowchartState
:status="row.status"
:grade="row.aiGrade"
:score="row.aiScore"
/>
<FlowchartState :status="row.status" :grade="row.aiGrade" />
</div>
<div class="m-line sub">
<span>{{ submissionClockText(row.createTime) }}</span>