Files
OJ2/apps/web/src/oj/problem/components/FlowchartResult.vue
T
xuyueandClaude Opus 5.5 1b703d13ae
Deploy / deploy (push) Canceled after 0s
style(专题): 「题单」改名「专题」,顶栏、列表、详情、后台、成就说法统一
只改界面文字和注释:网址 /problemset、接口、表名、字段名都不动,老链接照常能打开。
设计稿页名「题单重设计」和已执行的迁移文件保留原样。
线上 3 个成就(题单收藏家 / 入门 / 通关)已同步改名。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:12:18 +08:00

625 lines
20 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 { useLessonStore } from 'shared/store/lesson'
import { useUserStore } from 'shared/store/user'
import { sortFlowchartCriteria } from 'utils/constants'
import { parseTime } from 'utils/functions'
import { mermaidOrder, signature } from 'shared/components/FlowchartEditor/model'
import FlowchartNote from 'shared/components/FlowchartNote.vue'
import { caseReport, flowchartVerdict } from 'utils/flowchart'
/**
* 「结果」页签里流程图作业的那一块:AI 在评 / 评完了几分 / 历次分数 / 评语和改进建议。
*
* 原来评分详情是提交按钮旁一个 1000px 的弹框:盖住画布,学生没法一边看改进建议一边改图;
* 翻历次评分要一页一页点分页器。现在摆在左栏,画布整列都在右边(设计文档第 6 节)。
*
*/
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'
}
// ==================== 载回画布 ====================
/**
* 这一版的图(结构树)。2026-10 换成插入式编辑器之前交的是 vue-flow 的格式,读不出来就是
* null:那些只能看上面的图,载不回画布
*/
const versionDoc = computed(() => flowchartStore.versionDoc(detail.value))
const versionSignature = computed(() => (versionDoc.value ? signature(versionDoc.value) : null))
/**
* 画布和这一版一样不一样。不一样才给「载回画布」(刚交完还没动过时点了什么都不会变,
* 只会让人以为它坏了);一样才在画布上亮路径。读 getDoc() 会顺带跟踪画布的数据
*/
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
editor.value.setDoc(versionDoc.value)
message.success('已载回画布,按 Ctrl+Z 可以撤回')
}
// ==================== 走例子 ====================
/** 2026-10 起的评法:逐组走一遍。老提交是 null,照旧显示四项分 */
const report = computed(() => caseReport(detail.value?.aiCriteriaDetails))
/**
* 逐组数据默认收着、一组也不点开(设计稿 FlowNoteFail:学生不关心过了几组,先看哪里错、怎么改)。
* 换一次提交就收回去
*/
const selectedCase = ref<number | null>(null)
const casesOpen = ref(false)
watch(
() => current.value?.id,
() => {
selectedCase.value = null
casesOpen.value = false
},
)
/**
* 画布上标什么。没点开哪一组:只把要改的那个框标红、旁边写「要改这个框」,别的框照常;
* 点开了一组:亮出它走过的路。AI 报的是 Mermaid 的框号(n3),按交上去那一版的结构对回
* 画布上的框 —— 所以只在画布还是那一版时亮,改过了就不亮(载回画布之后又能亮)
*/
watch(
() => [selectedCase.value, report.value, canvasSame.value, editor.value] as const,
([index, next, same, instance]) => {
if (!instance) return
if (!next || !same || !versionDoc.value) {
instance.setHighlight(null)
return
}
const order = mermaidOrder(versionDoc.value)
const toStep = (id: string | null) => (id ? (order[Number(id.slice(1))] ?? null) : null)
// 要改的框:AI 对着答案指出的(错的根子),没有才退回程序找到的第一处出错的框
const focus = next.focus ?? next.cases.find((row) => !row.ok)?.wrongAt ?? null
const row = index === null ? undefined : next.cases[index]
if (!row) {
const bad = toStep(focus)
instance.setHighlight(bad ? { order: [], bad, label: '要改这个框' } : null)
return
}
const bad = row.ok ? null : toStep(focus)
instance.setHighlight({
order: row.path.map(toStep).filter((id): id is string => !!id),
bad,
})
},
// 切回「结果」页签时组件重新挂载,这些值早就齐了,不会再变一次
{ immediate: true },
)
onBeforeUnmount(() => editor.value?.setHighlight(null))
/** A / S 之后:照着图写代码。切回学生习惯的那门语言 */
/**
* 「照着它写代码」:只画流程图的课里不给(老师这节课只要画),这道题在要写代码的课里就换到
* 布置的语言,否则回到他习惯的那门
*/
const lessonStore = useLessonStore()
const lessonItem = computed(() => (problemStore.problem ? lessonStore.find(problemStore.problem._id) : undefined))
const canCode = computed(() => problemStore.codeLanguages.length > 0 && !(lessonItem.value?.draw && !lessonItem.value.code))
/** 课上这道题要写代码、还没交对:画对了不算做完 */
const codeStillNeeded = computed(() => !!lessonItem.value && lessonItem.value.code !== null && lessonItem.value.code !== 'accepted')
function goCode() {
const language = lessonStore.activity?.language
if (lessonItem.value?.code && language && problemStore.codeLanguages.includes(language)) problemStore.switchLanguage(language)
else 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="还没交过流程图。画好之后点「交给 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 }}
</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">
<!-- 设计稿 FlowResFail / FlowResPass:等级 + 几组走对,下面逐组 -->
<div v-if="report" class="headline">
<span class="grade" :class="gradeType(current.grade)">{{ current.grade }} 级</span>
<span class="headline-text">{{ flowchartVerdict(report, current.grade) }}</span>
<span v-if="isFlowchartPass(current.grade)" class="headline-note">{{ codeStillNeeded ? '接着写代码' : '这道题算做完了' }}</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>
<span v-if="detail?.aiFeedback || isFlowchartPass(current.grade)" class="feedback">
{{ detail?.aiFeedback ?? '' }}{{ isFlowchartPass(current.grade) ? (codeStillNeeded ? ' 接着写代码吧。' : ' 这道题算做完了。') : '' }}
</span>
</div>
<n-flex v-if="detailLoading && !detail" justify="center" class="pad">
<n-spin size="small" />
</n-flex>
<template v-if="detail && report">
<n-button v-if="isFlowchartPass(current.grade) && canCode" class="code-btn" type="primary" size="large" @click="goCode"> 照着它写代码 → </n-button>
<FlowchartNote
v-model:selected="selectedCase"
v-model:open="casesOpen"
:report="report"
:feedback="detail.aiFeedback"
:suggestions="detail.aiSuggestions"
:previous="detail.previousCases"
class="block"
/>
</template>
<template v-if="detail">
<div v-if="detail.mermaidCode && (!report || canvasDiffers)" 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 type="primary" 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="!report && 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="!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>
<span>{{ line }}</span>
</div>
</div>
<div class="actions">
<n-button v-if="!report && 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;
}
.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;
}
.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;
}
.code-btn {
margin-bottom: 14px;
}
/* 四项得分:两列,每项一条进度 */
.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>