feat(流程图): 「写代码 / 画流程图」变成工具栏上的切换,AI 的评分和历次分数挪进结果页签,题单里也能画
- 流程图题的工具栏最左边是「写代码 / 画流程图」。原来流程图藏在语言下拉里,叫一个名叫 Flowchart 的「语言」;画完一道题之后它还被记成语言偏好,下一道能画的题一打开就是画布 - 「交给 AI 点评」交完切到「结果」页签:评的时候写着「AI 正在看你的图」、画布照样能改; 评完是分数、评语、改进建议、四项得分,顶上一排历次分数,点哪次看哪次。 原来是提交按钮旁一个 1000px 的弹框,盖住画布,翻历次评分要一页一页点 - 画到 A / S:给「照着它写代码 →」,题单里 1.5 秒后回题单(和代码通过一样), 课堂条当场标成做完;只在这一页上刚评完的那次才算,进页面读到以前的 A 不跳 - 「把这一版载回画布」只在画布和那一版不一样时出现;载回、进页面读回草稿后都缩放到装下整张图 - 题单入口能画流程图了(原来只有题库入口能画),题单里的代码草稿、流程图草稿各存一份, 加入题单之前写的、画的不会一进题单就摆在编辑器里 - 手机上不给「画流程图」(节点库是拖放,手机上拖不动),题面里以前画好的那张照样看得到 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3fe3caa944
commit
11368813cc
16 files changed
+747
-388
No files matched your search
+1
-1
@@ -58,7 +58,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
|
|||||||
跨端的(登录、资料、标签、验证码)在 `shared/api.ts`。
|
跨端的(登录、资料、标签、验证码)在 `shared/api.ts`。
|
||||||
|
|
||||||
Shared logic lives in `shared/`:
|
Shared logic lives in `shared/`:
|
||||||
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (流程图弹窗的 mermaid 源码)
|
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码)
|
||||||
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (统计面板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
|
- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (统计面板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
|
||||||
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
||||||
- `api.ts` — shared API calls (auth, profile, tags, captcha)
|
- `api.ts` — shared API calls (auth, profile, tags, captcha)
|
||||||
|
|||||||
+4
-10
@@ -23,8 +23,7 @@ import {
|
|||||||
type YearlyAc,
|
type YearlyAc,
|
||||||
type ProblemList,
|
type ProblemList,
|
||||||
type CreateFlowchartResponse,
|
type CreateFlowchartResponse,
|
||||||
type FlowchartCurrent,
|
type FlowchartScores,
|
||||||
type FlowchartDetail,
|
|
||||||
type FlowchartList,
|
type FlowchartList,
|
||||||
type FlowchartSubmission,
|
type FlowchartSubmission,
|
||||||
type AiDetail,
|
type AiDetail,
|
||||||
@@ -437,14 +436,9 @@ export function retryFlowchartSubmission(submissionId: string) {
|
|||||||
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
|
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentProblemFlowchartSubmission(problemId: number) {
|
/** 自己在这道题上评完的每一次(早的在前),加入题单之前的已经滤掉 */
|
||||||
return api.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
|
export function getFlowchartScores(problemId: number) {
|
||||||
}
|
return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`)
|
||||||
|
|
||||||
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
|
|
||||||
return api.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
|
|
||||||
params: { page },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 题单相关API ====================
|
// ==================== 题单相关API ====================
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { storeToRefs } from "pinia"
|
|||||||
import type { RouteLocationRaw } from "vue-router"
|
import type { RouteLocationRaw } from "vue-router"
|
||||||
import { getProblemSetDetail, getProblemSetProblems } from "oj/api"
|
import { getProblemSetDetail, getProblemSetProblems } from "oj/api"
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
|
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
|
||||||
import { useLessonStore } from "oj/store/lesson"
|
import { useLessonStore } from "oj/store/lesson"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
@@ -30,7 +31,11 @@ const userStore = useUserStore()
|
|||||||
const { problem } = storeToRefs(useProblemStore())
|
const { problem } = storeToRefs(useProblemStore())
|
||||||
const currentId = computed(() => problem.value?._id ?? "")
|
const currentId = computed(() => problem.value?._id ?? "")
|
||||||
|
|
||||||
/** 刚在这一页上做出来(不是本来就做过):换题时 id 跟着变,那一次不算 */
|
/**
|
||||||
|
* 刚在这一页上做出来(不是本来就做过):代码通过,或者流程图刚评到 A / S。
|
||||||
|
* 换题时 id 跟着变,那一次不算;流程图只认这一页上刚评完的(evaluatedSeq)
|
||||||
|
*/
|
||||||
|
const flowchartStore = useFlowchartStore()
|
||||||
function onSolvedHere(callback: () => void) {
|
function onSolvedHere(callback: () => void) {
|
||||||
watch(
|
watch(
|
||||||
() => [problem.value?.id, problem.value?.myStatus] as const,
|
() => [problem.value?.id, problem.value?.myStatus] as const,
|
||||||
@@ -38,6 +43,12 @@ function onSolvedHere(callback: () => void) {
|
|||||||
if (id !== undefined && id === previousId && status === 0 && previousStatus !== 0) callback()
|
if (id !== undefined && id === previousId && status === 0 && previousStatus !== 0) callback()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
watch(
|
||||||
|
() => flowchartStore.evaluatedSeq,
|
||||||
|
() => {
|
||||||
|
if (isFlowchartPass(flowchartStore.latestRating.grade)) callback()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 课堂 ====================
|
// ==================== 课堂 ====================
|
||||||
@@ -124,13 +135,18 @@ const setBar = computed(() => {
|
|||||||
params: { problemSetId: ctx.value.problemSetId, problemID: item.problem._id },
|
params: { problemSetId: ctx.value.problemSetId, problemID: item.problem._id },
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
const done = list.filter((item) => item.isCompleted).length
|
// 进度只数必做题,和题单页的「完成进度 2 / 8(另有 3 道选做)」同一个口径;
|
||||||
|
// 一道必做都没有的题单才数全部
|
||||||
|
const required = list.some((item) => item.isRequired)
|
||||||
|
? list.filter((item) => item.isRequired)
|
||||||
|
: list
|
||||||
|
const done = required.filter((item) => item.isCompleted).length
|
||||||
return {
|
return {
|
||||||
title: problemSet.value.title,
|
title: problemSet.value.title,
|
||||||
home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } },
|
home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } },
|
||||||
done,
|
done,
|
||||||
total: list.length,
|
total: required.length,
|
||||||
percentage: list.length ? (done / list.length) * 100 : 0,
|
percentage: required.length ? (done / required.length) * 100 : 0,
|
||||||
position: index >= 0 ? index + 1 : null,
|
position: index >= 0 ? index + 1 : null,
|
||||||
previous: index >= 0 ? link(list[index - 1]) : null,
|
previous: index >= 0 ? link(list[index - 1]) : null,
|
||||||
next: index >= 0 ? link(list[index + 1]) : null,
|
next: index >= 0 ? link(list[index + 1]) : null,
|
||||||
|
|||||||
@@ -44,7 +44,20 @@ const ctx = useProblemPageContext()
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const codeStore = useCodeStore()
|
const codeStore = useCodeStore()
|
||||||
const problemStore = useProblemStore()
|
const problemStore = useProblemStore()
|
||||||
const { problem, languages } = storeToRefs(problemStore)
|
const { problem, codeLanguages, canDraw } = storeToRefs(problemStore)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「写代码 / 画流程图」是两种作业,不是两门语言。原来流程图藏在语言下拉里,叫一个名叫
|
||||||
|
* Flowchart 的「语言」—— 有的题流程图交了几百次、代码个位数,入口却在下拉的第一项里。
|
||||||
|
*/
|
||||||
|
const drawing = computed(() => codeStore.code.language === "Flowchart")
|
||||||
|
const mode = computed({
|
||||||
|
get: () => (drawing.value ? "draw" : "code"),
|
||||||
|
set: (value: "draw" | "code") => {
|
||||||
|
if (value === "draw") problemStore.switchLanguage("Flowchart")
|
||||||
|
else problemStore.switchLanguage(problemStore.supportedLanguage(codeStore.preferredLanguage()))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
@@ -183,7 +196,7 @@ const handleMenuSelect = (key: string) => {
|
|||||||
|
|
||||||
// computed:换题时组件复用,选项得跟着新题的语言走
|
// computed:换题时组件复用,选项得跟着新题的语言走
|
||||||
const languageOptions = computed<DropdownOption[]>(() =>
|
const languageOptions = computed<DropdownOption[]>(() =>
|
||||||
languages.value.map((it) => ({
|
codeLanguages.value.map((it) => ({
|
||||||
label: () =>
|
label: () =>
|
||||||
h(NFlex, { align: "center" }, () => [
|
h(NFlex, { align: "center" }, () => [
|
||||||
h(Icon, {
|
h(Icon, {
|
||||||
@@ -239,8 +252,15 @@ const goEdit = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
|
<!-- 协作中的老师不会落到画图:求助入口本身就排掉了流程图 -->
|
||||||
|
<n-radio-group v-if="canDraw && !showCollabBar" v-model:value="mode" :size="buttonSize">
|
||||||
|
<n-radio-button value="code">写代码</n-radio-button>
|
||||||
|
<n-radio-button value="draw">画流程图</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
|
||||||
<!-- 协作中编辑器的语言跟着学生走,这个选择器改了也不会生效,索性禁掉 -->
|
<!-- 协作中编辑器的语言跟着学生走,这个选择器改了也不会生效,索性禁掉 -->
|
||||||
<n-select
|
<n-select
|
||||||
|
v-if="!drawing"
|
||||||
v-model:value="codeStore.code.language"
|
v-model:value="codeStore.code.language"
|
||||||
style="width: 120px"
|
style="width: 120px"
|
||||||
:size="buttonSize"
|
:size="buttonSize"
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { storeToRefs } from "pinia"
|
||||||
|
import { useCodeStore } from "oj/store/code"
|
||||||
|
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
|
||||||
|
import { useProblemStore } from "oj/store/problem"
|
||||||
|
import { sortFlowchartCriteria } from "utils/constants"
|
||||||
|
import { decompressFromBase64, parseTime } from "utils/functions"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「结果」页签里流程图作业的那一块:AI 在评 / 评完了几分 / 历次分数 / 评语和改进建议。
|
||||||
|
*
|
||||||
|
* 原来评分详情是提交按钮旁一个 1000px 的弹框:盖住画布,学生没法一边看改进建议一边改图;
|
||||||
|
* 翻历次评分要一页一页点分页器。现在摆在左栏,画布整列都在右边(设计文档第 6 节)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const flowchartStore = useFlowchartStore()
|
||||||
|
const { phase, scores, hiddenCount, selectedId, details, detailLoading, editor } =
|
||||||
|
storeToRefs(flowchartStore)
|
||||||
|
const problemStore = useProblemStore()
|
||||||
|
const codeStore = useCodeStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
/** 正在看的那一次:点过哪次就是哪次,否则是最新的 */
|
||||||
|
const current = computed(() => {
|
||||||
|
const id = selectedId.value ?? scores.value.at(-1)?.id
|
||||||
|
return id ? (scores.value.find((row) => row.id === id) ?? null) : null
|
||||||
|
})
|
||||||
|
const currentIndex = computed(() =>
|
||||||
|
current.value ? scores.value.findIndex((row) => row.id === current.value!.id) : -1,
|
||||||
|
)
|
||||||
|
const detail = computed(() => (current.value ? details.value[current.value.id] : undefined))
|
||||||
|
const viewingLatest = computed(() => selectedId.value === null)
|
||||||
|
|
||||||
|
// 评完的那次、点到的那次,评语都要现拉(历次分数里只有分数)
|
||||||
|
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 (grade === "S" || grade === "A") return "success"
|
||||||
|
if (grade === "B") return "warning"
|
||||||
|
return "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentType(ratio: number) {
|
||||||
|
if (ratio >= 0.8) return "success"
|
||||||
|
if (ratio >= 0.6) return "info"
|
||||||
|
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>
|
||||||
|
<div class="flowchart-result">
|
||||||
|
<!-- 正在评:评的是最新这一次,所以只在看最新时显示 -->
|
||||||
|
<n-alert
|
||||||
|
v-if="phase === 'evaluating' && viewingLatest"
|
||||||
|
type="info"
|
||||||
|
:show-icon="false"
|
||||||
|
class="block"
|
||||||
|
>
|
||||||
|
<n-flex align="center" :size="10" :wrap="false">
|
||||||
|
<n-spin :size="18" />
|
||||||
|
<div>
|
||||||
|
<b>AI 正在看你的图</b>(一般 5 秒左右)
|
||||||
|
<div class="note">评的时候画布还能改,改了不影响这一次</div>
|
||||||
|
</div>
|
||||||
|
</n-flex>
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-alert
|
||||||
|
v-else-if="phase === 'failed' && viewingLatest"
|
||||||
|
type="warning"
|
||||||
|
:show-icon="false"
|
||||||
|
class="block"
|
||||||
|
>
|
||||||
|
AI 这次没评出来,再点一次「交给 AI 点评」试试
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-empty
|
||||||
|
v-if="!scores.length && phase !== 'evaluating'"
|
||||||
|
class="empty"
|
||||||
|
description="还没交过流程图。画好之后点「交给 AI 点评」,评分和建议会出现在这里"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="scores.length">
|
||||||
|
<!-- 历次分数:一眼看出是不是越改越好 -->
|
||||||
|
<div class="history" role="list" aria-label="历次分数">
|
||||||
|
<button
|
||||||
|
v-for="(row, index) in scores"
|
||||||
|
:key="row.id"
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
class="attempt"
|
||||||
|
:class="[gradeType(row.grade), { active: current?.id === row.id }]"
|
||||||
|
:title="parseTime(row.createTime, 'MM-DD HH:mm')"
|
||||||
|
@click="flowchartStore.select(index === scores.length - 1 ? null : row.id)"
|
||||||
|
>
|
||||||
|
<span class="attempt-no">第 {{ index + 1 }} 次</span>
|
||||||
|
<b>{{ row.score }}</b>
|
||||||
|
<span>{{ row.grade }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="hiddenCount" class="note">
|
||||||
|
另外 {{ hiddenCount }} 次是加入题单之前画的,先藏起来了:在题单里画到 A 或 S 就解锁
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section v-if="current" class="block">
|
||||||
|
<n-flex align="baseline" :size="10">
|
||||||
|
<n-text :type="gradeType(current.grade)" class="score">
|
||||||
|
{{ current.score }} 分 · {{ current.grade }} 级
|
||||||
|
</n-text>
|
||||||
|
<n-text depth="3" class="note">
|
||||||
|
第 {{ currentIndex + 1 }} 次 · {{ parseTime(current.createTime, "MM-DD HH:mm") }}
|
||||||
|
</n-text>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-flex v-if="isFlowchartPass(current.grade)" align="center" class="pass" :size="12">
|
||||||
|
<span>画到 {{ current.grade }} 级了,这道题的流程图算做完 ✓</span>
|
||||||
|
<n-button v-if="canCode" size="small" type="primary" @click="goCode">
|
||||||
|
照着它写代码 →
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-flex v-if="detailLoading && !detail" justify="center" class="pad">
|
||||||
|
<n-spin size="small" />
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<template v-if="detail">
|
||||||
|
<p v-if="detail.aiFeedback" class="feedback">{{ detail.aiFeedback }}</p>
|
||||||
|
|
||||||
|
<div v-if="suggestions.length" class="suggestions">
|
||||||
|
<h4>可以这样改</h4>
|
||||||
|
<ol>
|
||||||
|
<li v-for="(line, index) in suggestions" :key="index">{{ line }}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="criteria.length" class="criteria">
|
||||||
|
<h4>四项得分</h4>
|
||||||
|
<div v-for="[name, item] in criteria" :key="name" class="criterion">
|
||||||
|
<n-flex justify="space-between" align="center">
|
||||||
|
<b>{{ name }}</b>
|
||||||
|
<n-tag :type="percentType(item.score / item.max)" size="small" round>
|
||||||
|
{{ item.score || 0 }} / {{ item.max }}
|
||||||
|
</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
<n-text v-if="item.comment" depth="3" class="comment">{{ item.comment }}</n-text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-button v-if="canvasDiffers" secondary class="load" @click="loadToCanvas">
|
||||||
|
把这一版载回画布
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.flowchart-result {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin-top: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 2px 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 58px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt b {
|
||||||
|
font-size: 17px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt-no {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt.success b {
|
||||||
|
color: #18a058;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt.warning b {
|
||||||
|
color: #f0a020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt.error b {
|
||||||
|
color: #d03050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attempt.active {
|
||||||
|
border-color: #2080f0;
|
||||||
|
box-shadow: 0 0 0 1px #2080f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pass {
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: rgba(24, 160, 88, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback {
|
||||||
|
margin: 12px 0;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.4em;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.criterion {
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.criterion + .criterion {
|
||||||
|
border-top: 1px dashed rgba(128, 128, 128, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -22,7 +22,7 @@ const MyFlowchart = defineAsyncComponent(() => import("./MyFlowchart.vue"))
|
|||||||
|
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
const problemStore = useProblemStore()
|
const problemStore = useProblemStore()
|
||||||
const { problem, languages } = storeToRefs(problemStore)
|
const { problem } = storeToRefs(problemStore)
|
||||||
const ctx = useProblemPageContext()
|
const ctx = useProblemPageContext()
|
||||||
|
|
||||||
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
|
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
|
||||||
@@ -103,11 +103,17 @@ const flowchartStore = useFlowchartStore()
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const myFlowchartZoom = ref(false)
|
const myFlowchartZoom = ref(false)
|
||||||
|
|
||||||
// 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查
|
// 这道题能画流程图,就去查画到 A/S 没有 —— 原来要先切到「流程图」这门「语言」才查。
|
||||||
|
// 不看 canDraw:手机上画不了,但以前在电脑上画好的那张照样该摆出来
|
||||||
watch(
|
watch(
|
||||||
() => [problem.value?.id, languages.value.includes("Flowchart"), userStore.isAuthed] as const,
|
() =>
|
||||||
([id, canDraw, authed]) => {
|
[
|
||||||
if (id && canDraw && authed) flowchartStore.ensureLoaded()
|
problem.value?.id,
|
||||||
|
!!problem.value?.allowFlowchart && ctx.value.entry !== "contest",
|
||||||
|
userStore.isAuthed,
|
||||||
|
] as const,
|
||||||
|
([id, drawable, authed]) => {
|
||||||
|
if (id && drawable && authed) flowchartStore.ensureLoaded()
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { LANGUAGE } from "utils/types"
|
|||||||
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
|
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
|
||||||
import { errorMarkExtensions } from "oj/problem/utils/errorMark"
|
import { errorMarkExtensions } from "oj/problem/utils/errorMark"
|
||||||
import EditorToolbar from "./EditorToolbar.vue"
|
import EditorToolbar from "./EditorToolbar.vue"
|
||||||
|
import { useFlowchartStore } from "oj/store/flowchart"
|
||||||
|
|
||||||
const FlowchartEditor = defineAsyncComponent(
|
const FlowchartEditor = defineAsyncComponent(
|
||||||
() => import("shared/components/FlowchartEditor/index.vue"),
|
() => import("shared/components/FlowchartEditor/index.vue"),
|
||||||
@@ -84,8 +85,16 @@ const { isDesktop } = useBreakpoints()
|
|||||||
const editorExtensions = [...editTraceExtensions, ...errorMarkExtensions]
|
const editorExtensions = [...editTraceExtensions, ...errorMarkExtensions]
|
||||||
|
|
||||||
const contestID = route.params.contestID || null
|
const contestID = route.params.contestID || null
|
||||||
const storageKey = computed(
|
const problemSetId = route.params.problemSetId || null
|
||||||
() => `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
/**
|
||||||
|
* 本地草稿的键。题单入口单独一份:原来题库和题单共用 `problem_题号_contest_null_语言`,
|
||||||
|
* 加入题单之前在题库里写对的代码,一进题单就摆在编辑器里(设计文档第 11 节 4)。
|
||||||
|
* 键的最后一段必须是语言,changeLanguage 靠它判断草稿是不是这门语言的。
|
||||||
|
*/
|
||||||
|
const storageKey = computed(() =>
|
||||||
|
problemSetId
|
||||||
|
? `problem_${problem.value!._id}_problemset_${problemSetId}_lang_${codeStore.code.language}`
|
||||||
|
: `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
const editorHeight = computed(() =>
|
const editorHeight = computed(() =>
|
||||||
@@ -130,7 +139,28 @@ const changeLanguage = (v: LANGUAGE) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提供FlowchartEditor的ref给子组件
|
/**
|
||||||
|
* 换语言的请求:工具栏的「写代码 / 画流程图」、结果页签里的「照着它写代码」。
|
||||||
|
* 和语言下拉走同一条路(存语言偏好 → 读那门语言的草稿)。
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => problemStore.languageRequest,
|
||||||
|
(language) => {
|
||||||
|
if (!language) return
|
||||||
|
problemStore.languageRequest = null
|
||||||
|
if (language === codeStore.code.language) return
|
||||||
|
codeStore.setLanguage(language)
|
||||||
|
changeLanguage(language)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 流程图编辑器交给 store:结果页签(在左栏,不在编辑器底下)要拿当前画布比一比、
|
||||||
|
// 把旧版本载回去。子组件 SubmitFlowchart 还是从 inject 拿
|
||||||
|
const flowchartStore = useFlowchartStore()
|
||||||
|
watch(flowchartEditorRef, (editor) => flowchartStore.attachEditor(editor ?? null), {
|
||||||
|
immediate: true,
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => flowchartStore.attachEditor(null))
|
||||||
provide("flowchartEditorRef", flowchartEditorRef)
|
provide("flowchartEditorRef", flowchartEditorRef)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useSubmissionStore } from "oj/store/submission"
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
|
import { useCodeStore } from "oj/store/code"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 左栏「结果」页签:三段 —— 提交结果 / 运行例子 / 自己输入。
|
* 左栏「结果」页签:三段 —— 提交结果 / 运行例子 / 自己输入。
|
||||||
@@ -17,10 +18,15 @@ const SubmissionResult = defineAsyncComponent(() => import("./SubmissionResult.v
|
|||||||
const PythonErrorExplain = defineAsyncComponent(() => import("./PythonErrorExplain.vue"))
|
const PythonErrorExplain = defineAsyncComponent(() => import("./PythonErrorExplain.vue"))
|
||||||
const SampleRunResult = defineAsyncComponent(() => import("./SampleRunResult.vue"))
|
const SampleRunResult = defineAsyncComponent(() => import("./SampleRunResult.vue"))
|
||||||
const CustomRun = defineAsyncComponent(() => import("./CustomRun.vue"))
|
const CustomRun = defineAsyncComponent(() => import("./CustomRun.vue"))
|
||||||
|
const FlowchartResult = defineAsyncComponent(() => import("./FlowchartResult.vue"))
|
||||||
|
|
||||||
const submissionStore = useSubmissionStore()
|
const submissionStore = useSubmissionStore()
|
||||||
const { submission, syntaxErrorInfo, resultSegment } = storeToRefs(submissionStore)
|
const { submission, syntaxErrorInfo, resultSegment } = storeToRefs(submissionStore)
|
||||||
const { problem } = storeToRefs(useProblemStore())
|
const { problem } = storeToRefs(useProblemStore())
|
||||||
|
const codeStore = useCodeStore()
|
||||||
|
|
||||||
|
/** 正在画流程图:结果就是 AI 的点评,没有例子可跑 */
|
||||||
|
const drawing = computed(() => codeStore.code.language === "Flowchart")
|
||||||
|
|
||||||
// 试跑走 Judge0,它跑不了 SQL;流程图也没得跑。这两种只留「提交结果」
|
// 试跑走 Judge0,它跑不了 SQL;流程图也没得跑。这两种只留「提交结果」
|
||||||
const canTrial = computed(() => {
|
const canTrial = computed(() => {
|
||||||
@@ -30,7 +36,8 @@ const canTrial = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="result-pane">
|
<FlowchartResult v-if="drawing" />
|
||||||
|
<div v-show="!drawing" class="result-pane">
|
||||||
<n-radio-group v-if="canTrial" v-model:value="resultSegment" size="small" class="segments">
|
<n-radio-group v-if="canTrial" v-model:value="resultSegment" size="small" class="segments">
|
||||||
<n-radio-button value="submit">提交结果</n-radio-button>
|
<n-radio-button value="submit">提交结果</n-radio-button>
|
||||||
<n-radio-button value="samples">运行例子</n-radio-button>
|
<n-radio-button value="samples">运行例子</n-radio-button>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Icon } from "@iconify/vue"
|
|||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import { useSubmissionStore } from "oj/store/submission"
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
|
import { useCodeStore } from "oj/store/code"
|
||||||
|
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
|
||||||
import { SubmissionStatus } from "utils/constants"
|
import { SubmissionStatus } from "utils/constants"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +16,23 @@ const theme = useThemeVars()
|
|||||||
const { submission, syntaxErrorInfo, judging, pending, submitting } =
|
const { submission, syntaxErrorInfo, judging, pending, submitting } =
|
||||||
storeToRefs(useSubmissionStore())
|
storeToRefs(useSubmissionStore())
|
||||||
|
|
||||||
|
const codeStore = useCodeStore()
|
||||||
|
const flowchart = storeToRefs(useFlowchartStore())
|
||||||
|
|
||||||
|
/** 画流程图时:评中转圈,A/S 打勾,B/C 是「还没过关」的 !,评失败是 ✕ */
|
||||||
|
const flowchartStatus = computed(() => {
|
||||||
|
const phase = flowchart.phase.value
|
||||||
|
if (phase === "evaluating")
|
||||||
|
return { icon: "ph:circle-notch-bold", color: theme.value.infoColor, spin: true }
|
||||||
|
if (phase === "failed") return { icon: "ph:x-bold", color: theme.value.errorColor }
|
||||||
|
if (phase !== "done") return null
|
||||||
|
return isFlowchartPass(flowchart.latestRating.value.grade)
|
||||||
|
? { icon: "ph:check-bold", color: theme.value.successColor }
|
||||||
|
: { icon: "ph:warning-bold", color: theme.value.warningColor }
|
||||||
|
})
|
||||||
|
|
||||||
const status = computed(() => {
|
const status = computed(() => {
|
||||||
|
if (codeStore.code.language === "Flowchart") return flowchartStatus.value
|
||||||
if (syntaxErrorInfo.value) return { icon: "ph:warning-bold", color: theme.value.warningColor }
|
if (syntaxErrorInfo.value) return { icon: "ph:warning-bold", color: theme.value.warningColor }
|
||||||
if (!submission.value) return null
|
if (!submission.value) return null
|
||||||
if (judging.value || pending.value || submitting.value)
|
if (judging.value || pending.value || submitting.value)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useProblemStore } from "oj/store/problem"
|
|||||||
import { useSubmissionStore } from "oj/store/submission"
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
import { useFireworks } from "oj/problem/composables/useFireworks"
|
import { useFireworks } from "oj/problem/composables/useFireworks"
|
||||||
import { useLessonStore } from "oj/store/lesson"
|
import { useLessonStore } from "oj/store/lesson"
|
||||||
|
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
|
||||||
import { SubmissionStatus } from "utils/constants"
|
import { SubmissionStatus } from "utils/constants"
|
||||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||||
|
|
||||||
@@ -225,6 +226,19 @@ watch(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ==================== 流程图画到 A / S ====================
|
||||||
|
// 题单里流程图作业拿到 A 或 S 就算这道题做完(后端评分那一路已经记了账),
|
||||||
|
// 和代码通过一样 1.5 秒后回题单(设计文档第 3 节决定 4、5)。只认这一页上刚评完的那一次
|
||||||
|
const flowchartStore = useFlowchartStore()
|
||||||
|
watch(
|
||||||
|
() => flowchartStore.evaluatedSeq,
|
||||||
|
() => {
|
||||||
|
if (!isFlowchartPass(flowchartStore.latestRating.grade)) return
|
||||||
|
celebrate()
|
||||||
|
if (ctx.value.backToProblemSet) goToProblemSetDelayed()
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,346 +1,54 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
|
import { compressToBase64 } from "utils/functions"
|
||||||
// 工具函数
|
|
||||||
import { decompressFromBase64, compressToBase64 } from "utils/functions"
|
|
||||||
import { sortFlowchartCriteria } from "utils/constants"
|
|
||||||
|
|
||||||
// 组合式函数
|
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useMermaid } from "shared/composables/useMermaid"
|
|
||||||
import { useMermaidConverter } from "../composables/useMermaidConverter"
|
import { useMermaidConverter } from "../composables/useMermaidConverter"
|
||||||
|
|
||||||
// API 和状态管理
|
|
||||||
import { getFlowchartSubmissionDetail } from "oj/api"
|
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useFlowchartStore, type FlowchartRating } from "oj/store/flowchart"
|
import { useFlowchartStore, type FlowchartEditorInstance } from "oj/store/flowchart"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「提交流程图」按钮、分数按钮和评分详情弹框。
|
* 「交给 AI 点评」按钮。
|
||||||
*
|
*
|
||||||
* 提交和评分(WebSocket + 轮询 + 超时、评完的提示、A/S 亮到「我的流程图」)都在
|
* 提交和评分(WebSocket + 轮询 + 超时)在 flowchart store;评分结果、历次分数、载回旧版
|
||||||
* flowchart store:这个组件只在语言选成 Flowchart 时才挂载,切去写代码它就卸载,
|
* 都在左栏「结果」页签(FlowchartResult)。原来这里还有一个分数按钮和 1000px 的评分弹框,
|
||||||
* 正在评的那次不能跟着没。这里只剩从编辑器里取图、以及翻看历次评分的弹框。
|
* 弹框盖住画布,学生没法一边看改进建议一边改图。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ==================== 类型定义 ====================
|
|
||||||
interface Evaluation extends FlowchartRating {
|
|
||||||
feedback: string
|
|
||||||
suggestions: string
|
|
||||||
criteria_details: {
|
|
||||||
[key: string]: { score: number; max: number; comment: string }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FlowchartEditorInstance {
|
|
||||||
getFlowchartData: () => { nodes: unknown[]; edges: unknown[] }
|
|
||||||
setFlowchartData: (data: { nodes: unknown[]; edges: unknown[] }) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通过inject获取FlowchartEditor组件的引用
|
|
||||||
const flowchartEditorRef = inject<Ref<FlowchartEditorInstance | null>>("flowchartEditorRef")
|
const flowchartEditorRef = inject<Ref<FlowchartEditorInstance | null>>("flowchartEditorRef")
|
||||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
|
||||||
|
|
||||||
// 基础组合式函数
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const problemStore = useProblemStore()
|
const { problem } = storeToRefs(useProblemStore())
|
||||||
const { problem } = storeToRefs(problemStore)
|
|
||||||
const flowchartStore = useFlowchartStore()
|
const flowchartStore = useFlowchartStore()
|
||||||
const { loading, latestRating, submissionCount } = storeToRefs(flowchartStore)
|
const { loading } = storeToRefs(flowchartStore)
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
const { convertToMermaid } = useMermaidConverter()
|
const { convertToMermaid } = useMermaidConverter()
|
||||||
const { renderError, renderFlowchart } = useMermaid()
|
|
||||||
|
|
||||||
// 评分详情弹框
|
|
||||||
const rendering = ref(false)
|
|
||||||
const modalRating = ref<FlowchartRating>({ score: 0, grade: "" })
|
|
||||||
const myFlowchartZippedStr = ref("")
|
|
||||||
const myMermaidCode = ref("")
|
|
||||||
const showDetailModal = ref(false)
|
|
||||||
const evaluation = ref<Evaluation>({
|
|
||||||
score: 0,
|
|
||||||
grade: "",
|
|
||||||
feedback: "",
|
|
||||||
suggestions: "",
|
|
||||||
criteria_details: {},
|
|
||||||
})
|
|
||||||
const page = ref(1)
|
|
||||||
const suggestionLines = computed(() => splitSuggestionLines(evaluation.value.suggestions))
|
|
||||||
|
|
||||||
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
|
|
||||||
const sortedCriteria = computed(() => sortFlowchartCriteria(evaluation.value.criteria_details))
|
|
||||||
|
|
||||||
function splitSuggestionLines(suggestions?: string | null) {
|
|
||||||
return suggestions
|
|
||||||
? suggestions
|
|
||||||
.split("\n")
|
|
||||||
.map((suggestion) => suggestion.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 提交 ====================
|
|
||||||
function submit() {
|
function submit() {
|
||||||
if (!flowchartEditorRef?.value) return
|
const editor = flowchartEditorRef?.value
|
||||||
|
if (!editor) return
|
||||||
// 获取流程图的JSON数据
|
const flowchartData = editor.getFlowchartData()
|
||||||
const flowchartData = flowchartEditorRef.value.getFlowchartData()
|
|
||||||
|
|
||||||
if (!flowchartData?.nodes?.length || !flowchartData?.edges?.length) {
|
if (!flowchartData?.nodes?.length || !flowchartData?.edges?.length) {
|
||||||
message.error("流程图节点或边不能为空")
|
message.warning("画布上还没有图:从左边拖几个框进来,再用线连起来")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
flowchartStore.submit(
|
flowchartStore.submit(
|
||||||
convertToMermaid(flowchartData),
|
convertToMermaid(flowchartData),
|
||||||
compressToBase64(JSON.stringify(flowchartData)),
|
compressToBase64(JSON.stringify(flowchartData)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 评分详情 ====================
|
// 进题、换题都读一次历次分数(store 里同一道题只读一次)
|
||||||
async function getSubmission(submissionPage = 0) {
|
watch(() => problem.value?.id, flowchartStore.ensureLoaded, { immediate: true })
|
||||||
const problemId = problem.value?.id
|
|
||||||
if (!problemId) return
|
|
||||||
const data = await getFlowchartSubmissionDetail(problemId, submissionPage)
|
|
||||||
if (problem.value?.id !== problemId) return
|
|
||||||
submissionCount.value = data.count
|
|
||||||
const submission = data.submission
|
|
||||||
// 翻到没有提交的页时后端返回 null(契约里 submission 是 nullable)——
|
|
||||||
// 原来的 any 让这里看起来非空,真翻到那一页会直接抛
|
|
||||||
if (!submission) {
|
|
||||||
myFlowchartZippedStr.value = ""
|
|
||||||
myMermaidCode.value = ""
|
|
||||||
modalRating.value = { score: 0, grade: "" }
|
|
||||||
evaluation.value = {
|
|
||||||
score: 0,
|
|
||||||
grade: "",
|
|
||||||
feedback: "",
|
|
||||||
suggestions: "",
|
|
||||||
criteria_details: {},
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
myFlowchartZippedStr.value = String(submission.flowchartData.data ?? "")
|
|
||||||
myMermaidCode.value = submission.mermaidCode || ""
|
|
||||||
modalRating.value = {
|
|
||||||
score: submission.aiScore ?? 0,
|
|
||||||
grade: submission.aiGrade ?? "",
|
|
||||||
}
|
|
||||||
evaluation.value = {
|
|
||||||
score: submission.aiScore ?? 0,
|
|
||||||
grade: submission.aiGrade ?? "",
|
|
||||||
feedback: submission.aiFeedback ?? "",
|
|
||||||
suggestions: submission.aiSuggestions ?? "",
|
|
||||||
criteria_details: submission.aiCriteriaDetails as Evaluation["criteria_details"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 请求失败时 rendering 必须复位:拦截器对普通业务错误是静默 reject 的,
|
|
||||||
// 少了这个 finally,弹框就会永远停在转圈状态,也没有任何提示
|
|
||||||
async function updatePage(val: number) {
|
|
||||||
page.value = val
|
|
||||||
rendering.value = true
|
|
||||||
try {
|
|
||||||
await getSubmission(val)
|
|
||||||
// 等待 DOM 更新
|
|
||||||
await nextTick()
|
|
||||||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
|
||||||
} catch (error) {
|
|
||||||
message.error("加载这次提交失败,请稍后重试")
|
|
||||||
console.error("加载流程图提交失败:", error)
|
|
||||||
} finally {
|
|
||||||
rendering.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openDetailModal() {
|
|
||||||
showDetailModal.value = true
|
|
||||||
rendering.value = true
|
|
||||||
try {
|
|
||||||
await getSubmission()
|
|
||||||
page.value = submissionCount.value
|
|
||||||
// 等待 DOM 更新,确保弹框已经渲染
|
|
||||||
await nextTick()
|
|
||||||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
|
||||||
} catch (error) {
|
|
||||||
message.error("加载评分详情失败,请稍后重试")
|
|
||||||
console.error("加载流程图评分详情失败:", error)
|
|
||||||
} finally {
|
|
||||||
rendering.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
|
||||||
showDetailModal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadToEditor() {
|
|
||||||
if (myFlowchartZippedStr.value) {
|
|
||||||
// 老提交的压缩数据可能是坏的(格式换过、存了一半),
|
|
||||||
// 不兜住的话 decompressFromBase64/JSON.parse 直接抛,按钮点了毫无反应
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(decompressFromBase64(myFlowchartZippedStr.value))
|
|
||||||
flowchartEditorRef?.value?.setFlowchartData({
|
|
||||||
nodes: json.nodes || [],
|
|
||||||
edges: json.edges || [],
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
message.error("这份流程图数据已损坏,无法加载到编辑器")
|
|
||||||
console.error("解析流程图数据失败:", error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
closeModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 工具函数 ====================
|
|
||||||
const getGradeType = (grade: string) => {
|
|
||||||
if (grade === "S") return "primary"
|
|
||||||
if (grade === "A") return "info"
|
|
||||||
if (grade === "B") return "warning"
|
|
||||||
return "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPercentType = (percent: number) => {
|
|
||||||
if (percent >= 0.8) return "primary"
|
|
||||||
else if (percent >= 0.6) return "info"
|
|
||||||
else if (percent >= 0.4) return "warning"
|
|
||||||
return "error"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 生命周期 ====================
|
|
||||||
onMounted(flowchartStore.ensureLoaded)
|
|
||||||
|
|
||||||
// 换题:分数和还在评的那次由 store 自己收(见 flowchart store 的 reset),
|
|
||||||
// 这里只关掉弹框,再拉新题的最近一次分数
|
|
||||||
watch(
|
|
||||||
() => problem.value?.id,
|
|
||||||
(next, previous) => {
|
|
||||||
if (!previous || next === previous) return
|
|
||||||
showDetailModal.value = false
|
|
||||||
page.value = 1
|
|
||||||
flowchartStore.ensureLoaded()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 主要操作区域 -->
|
<n-button
|
||||||
<n-flex align="center">
|
:size="isDesktop ? 'medium' : 'small'"
|
||||||
<!-- 提交按钮 -->
|
type="primary"
|
||||||
<n-button
|
:loading="loading"
|
||||||
:size="isDesktop ? 'medium' : 'small'"
|
:disabled="loading"
|
||||||
type="primary"
|
@click="submit"
|
||||||
:loading="loading"
|
>
|
||||||
:disabled="loading"
|
{{ loading ? "AI 正在看…" : "交给 AI 点评" }}
|
||||||
@click="submit"
|
</n-button>
|
||||||
>
|
|
||||||
{{ loading ? "AI 点评中..." : "提交流程图" }}
|
|
||||||
</n-button>
|
|
||||||
|
|
||||||
<!-- 评分结果按钮 -->
|
|
||||||
<n-button
|
|
||||||
secondary
|
|
||||||
v-if="latestRating.grade"
|
|
||||||
@click="openDetailModal"
|
|
||||||
:type="getGradeType(latestRating.grade)"
|
|
||||||
>
|
|
||||||
{{ latestRating.score }}分 {{ latestRating.grade }}级
|
|
||||||
</n-button>
|
|
||||||
|
|
||||||
<!-- 流程图评分详情模态框 -->
|
|
||||||
<n-modal v-model:show="showDetailModal" preset="card" style="width: 1000px">
|
|
||||||
<template #header>
|
|
||||||
<n-flex align="center">
|
|
||||||
<n-text>流程图评分详情</n-text>
|
|
||||||
<n-text :type="getGradeType(modalRating.grade)">
|
|
||||||
{{ modalRating.score }}分 {{ modalRating.grade }}级
|
|
||||||
</n-text>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
<n-grid :cols="5" :x-gap="16">
|
|
||||||
<!-- 左侧:流程图预览区域 -->
|
|
||||||
<n-gi :span="3">
|
|
||||||
<div class="flowchart">
|
|
||||||
<n-spin :show="rendering">
|
|
||||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
|
||||||
{{ renderError }}
|
|
||||||
</n-alert>
|
|
||||||
<div class="flowchart" v-show="!renderError" ref="mermaidContainer"></div>
|
|
||||||
</n-spin>
|
|
||||||
</div>
|
|
||||||
<!-- 加载到编辑器按钮 -->
|
|
||||||
<n-flex style="margin-top: 16px" justify="center">
|
|
||||||
<n-button @click="loadToEditor" type="primary"> 加载到流程图编辑器 </n-button>
|
|
||||||
</n-flex>
|
|
||||||
</n-gi>
|
|
||||||
|
|
||||||
<!-- 右侧:评分详情区域 -->
|
|
||||||
<n-gi :span="2" style="max-height: 550px; overflow: auto">
|
|
||||||
<!-- AI反馈 -->
|
|
||||||
<n-card
|
|
||||||
v-if="evaluation.feedback"
|
|
||||||
size="small"
|
|
||||||
title="AI反馈"
|
|
||||||
style="margin-bottom: 16px"
|
|
||||||
>
|
|
||||||
<n-text>{{ evaluation.feedback }}</n-text>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- 改进建议 -->
|
|
||||||
<n-card
|
|
||||||
v-if="suggestionLines.length"
|
|
||||||
size="small"
|
|
||||||
title="改进建议"
|
|
||||||
style="margin-bottom: 16px"
|
|
||||||
>
|
|
||||||
<n-flex vertical :size="6">
|
|
||||||
<n-text
|
|
||||||
v-for="(suggestion, index) in suggestionLines"
|
|
||||||
:key="`${index}-${suggestion}`"
|
|
||||||
>
|
|
||||||
{{ suggestion }}
|
|
||||||
</n-text>
|
|
||||||
</n-flex>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- 详细评分 -->
|
|
||||||
<n-card v-if="sortedCriteria.length" size="small" title="详细评分">
|
|
||||||
<div v-for="[key, detail] in sortedCriteria" :key="key" style="margin-bottom: 12px">
|
|
||||||
<!-- 评分项标题和分数 -->
|
|
||||||
<n-flex justify="space-between" align="center" style="margin-bottom: 4px">
|
|
||||||
<n-text strong>{{ key }}</n-text>
|
|
||||||
<n-tag :type="getPercentType(detail.score / detail.max)" size="small" round>
|
|
||||||
{{ detail.score || 0 }}分 / {{ detail.max }}分
|
|
||||||
</n-tag>
|
|
||||||
</n-flex>
|
|
||||||
<!-- 评分项详细说明 -->
|
|
||||||
<n-text v-if="detail.comment" depth="3" style="font-size: 12px">
|
|
||||||
{{ detail.comment }}
|
|
||||||
</n-text>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</n-gi>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<!-- 分页组件 -->
|
|
||||||
<n-flex justify="center" style="margin-top: 24px" v-if="submissionCount > 1">
|
|
||||||
<n-pagination v-model:page="page" :page-count="submissionCount" @update-page="updatePage" />
|
|
||||||
</n-flex>
|
|
||||||
</n-modal>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
|
||||||
/* ==================== 流程图样式 ==================== */
|
|
||||||
.flowchart {
|
|
||||||
height: 500px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 确保 SVG 图表占满容器 */
|
|
||||||
:deep(.flowchart > svg) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -8,13 +8,19 @@ import type { Code, LANGUAGE } from "utils/types"
|
|||||||
* 代码编辑器状态管理 Store
|
* 代码编辑器状态管理 Store
|
||||||
* 管理全局的代码、输入、输出状态
|
* 管理全局的代码、输入、输出状态
|
||||||
*/
|
*/
|
||||||
|
function savedCodeLanguage() {
|
||||||
|
const saved = normalizeLanguage(storage.get(STORAGE_KEY.LANGUAGE))
|
||||||
|
return saved && saved !== "Flowchart" ? saved : null
|
||||||
|
}
|
||||||
|
|
||||||
export const useCodeStore = defineStore("code", () => {
|
export const useCodeStore = defineStore("code", () => {
|
||||||
// ==================== 状态 ====================
|
// ==================== 状态 ====================
|
||||||
const code = reactive<Code>({
|
const code = reactive<Code>({
|
||||||
value: "",
|
value: "",
|
||||||
// 过一道 normalizeLanguage:上线那一刻学生浏览器的 localStorage 里存的还是
|
// 过一道 normalizeLanguage:上线那一刻学生浏览器的 localStorage 里存的还是
|
||||||
// 旧值 Python3,直接拿来用会被后端的契约挡掉(而且报错看不出是这个原因)
|
// 旧值 Python3,直接拿来用会被后端的契约挡掉(而且报错看不出是这个原因)
|
||||||
language: normalizeLanguage(storage.get(STORAGE_KEY.LANGUAGE)) ?? "Python",
|
// 以前的版本会把 Flowchart 也记成语言偏好,那种老值在这里一并不认
|
||||||
|
language: savedCodeLanguage() ?? "Python",
|
||||||
})
|
})
|
||||||
|
|
||||||
const input = ref("")
|
const input = ref("")
|
||||||
@@ -36,7 +42,6 @@ export const useCodeStore = defineStore("code", () => {
|
|||||||
*/
|
*/
|
||||||
function setLanguage(language: LANGUAGE) {
|
function setLanguage(language: LANGUAGE) {
|
||||||
code.language = language
|
code.language = language
|
||||||
storage.set(STORAGE_KEY.LANGUAGE, language)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,14 +74,23 @@ export const useCodeStore = defineStore("code", () => {
|
|||||||
output.value = ""
|
output.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听语言变化,保存到本地存储
|
/**
|
||||||
|
* 语言变了就记下来,下一道题默认用它。**不记 Flowchart**:那是「画流程图」这种作业,
|
||||||
|
* 不是编程语言偏好 —— 记下来的话,画完一道流程图题,下一道能画的题一打开就是画布,
|
||||||
|
* 而学生这节课要写的是代码。切回「写代码」时用的也是这份偏好。
|
||||||
|
*/
|
||||||
watch(
|
watch(
|
||||||
() => code.language,
|
() => code.language,
|
||||||
(newLanguage) => {
|
(newLanguage) => {
|
||||||
storage.set(STORAGE_KEY.LANGUAGE, newLanguage)
|
if (newLanguage !== "Flowchart") storage.set(STORAGE_KEY.LANGUAGE, newLanguage)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 学生习惯用的编程语言(从来不会是 Flowchart) */
|
||||||
|
function preferredLanguage(): LANGUAGE {
|
||||||
|
return savedCodeLanguage() ?? "Python"
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
code,
|
code,
|
||||||
@@ -89,6 +103,7 @@ export const useCodeStore = defineStore("code", () => {
|
|||||||
// 操作
|
// 操作
|
||||||
setCode,
|
setCode,
|
||||||
setLanguage,
|
setLanguage,
|
||||||
|
preferredLanguage,
|
||||||
setInput,
|
setInput,
|
||||||
setOutput,
|
setOutput,
|
||||||
reset,
|
reset,
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { createDiscreteApi } from "naive-ui"
|
import { createDiscreteApi } from "naive-ui"
|
||||||
import { defineStore } from "pinia"
|
import { defineStore } from "pinia"
|
||||||
import { errorCode } from "utils/api"
|
import { errorCode } from "utils/api"
|
||||||
import {
|
import type { FlowchartScores, FlowchartSubmission } from "@oj2/contract"
|
||||||
getCurrentProblemFlowchartSubmission,
|
import type { Edge, Node } from "@vue-flow/core"
|
||||||
getFlowchartSubmission,
|
import { getFlowchartScores, getFlowchartSubmission, submitFlowchart } from "oj/api"
|
||||||
getFlowchartSubmissionDetail,
|
|
||||||
submitFlowchart,
|
|
||||||
} from "oj/api"
|
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
|
import { useSubmissionStore } from "oj/store/submission"
|
||||||
import { useFlowchartWebSocket, type FlowchartEvaluationUpdate } from "shared/composables/websocket"
|
import { useFlowchartWebSocket, type FlowchartEvaluationUpdate } from "shared/composables/websocket"
|
||||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
@@ -22,18 +20,35 @@ export interface FlowchartRating {
|
|||||||
|
|
||||||
type Outcome = { ok: true; score: number; grade: string } | { ok: false; error?: string }
|
type Outcome = { ok: true; score: number; grade: string } | { ok: false; error?: string }
|
||||||
|
|
||||||
|
/** 编辑器对外的两个方法(FlowchartEditor 的 defineExpose) */
|
||||||
|
export interface FlowchartEditorInstance {
|
||||||
|
getFlowchartData: () => { nodes: unknown[]; edges: unknown[] }
|
||||||
|
setFlowchartData: (data: { nodes: Node[]; edges: Edge[] }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 画到这两档算这道题做完(后端 flowchart/grade.ts 的 FLOWCHART_PASS_GRADES) */
|
||||||
|
export function isFlowchartPass(grade: string | null | undefined) {
|
||||||
|
return grade === "S" || grade === "A"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这一页上最近一次交的图走到哪了。idle = 这一页还没交过(历史分数照样有)
|
||||||
|
*/
|
||||||
|
export type FlowchartPhase = "idle" | "evaluating" | "done" | "failed"
|
||||||
|
|
||||||
/** AI 评分比判题慢得多,轮询放缓一点 */
|
/** AI 评分比判题慢得多,轮询放缓一点 */
|
||||||
const POLL_INTERVAL = 3000
|
const POLL_INTERVAL = 3000
|
||||||
/** 到点还没结果就收手,别无限轮询下去 */
|
/** 到点还没结果就收手,别无限轮询下去 */
|
||||||
const POLL_TIMEOUT = 3 * 60 * 1000
|
const POLL_TIMEOUT = 3 * 60 * 1000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 流程图提交与 AI 评分:正在评的那一次、最近一次的分数、提交次数。
|
* 流程图提交与 AI 评分:正在评的那一次、历次分数、结果页签正在看哪一次。
|
||||||
*
|
*
|
||||||
* 原来都在提交按钮那个组件(SubmitFlowchart)里,而它只在语言选成 Flowchart 时才挂载 ——
|
* 原来都在提交按钮那个组件(SubmitFlowchart)里,而它只在语言选成 Flowchart 时才挂载 ——
|
||||||
* 交完切到写代码,组件一卸载就断开 WebSocket,正在评的那次结果就丢了(评分中位 4.8 秒、
|
* 交完切到写代码,组件一卸载就断开 WebSocket,正在评的那次结果就丢了(评分中位 4.8 秒、
|
||||||
* 最长二十几秒,足够学生切走)。挪到 store 之后,切走了照样评完、照样提示、照样把
|
* 最长二十几秒,足够学生切走)。挪到 store 之后,切走了照样评完、照样提示、照样把
|
||||||
* A/S 的图亮到「我的流程图」。评分详情弹框只是展示,还留在组件里。
|
* A/S 的图亮到「你画的流程图」。评分详情从 1000px 的弹框挪进了左栏「结果」页签
|
||||||
|
* (FlowchartResult),那边只管显示。
|
||||||
*/
|
*/
|
||||||
export const useFlowchartStore = defineStore("flowchart", () => {
|
export const useFlowchartStore = defineStore("flowchart", () => {
|
||||||
const problemStore = useProblemStore()
|
const problemStore = useProblemStore()
|
||||||
@@ -41,7 +56,27 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const latestRating = ref<FlowchartRating>({ score: 0, grade: "" })
|
const latestRating = ref<FlowchartRating>({ score: 0, grade: "" })
|
||||||
const submissionCount = ref(0)
|
const phase = ref<FlowchartPhase>("idle")
|
||||||
|
/**
|
||||||
|
* 这一页上**刚刚**评完一次就加一(不含进页面时读到的历史)。课堂条标做完、题单里
|
||||||
|
* 1.5 秒后跳回题单,都只认这一下 —— 进页面读到以前的 A 不该再跳一次
|
||||||
|
*/
|
||||||
|
const evaluatedSeq = ref(0)
|
||||||
|
/** 这道题评完的每一次,早的在前;hidden = 加入题单之前、被藏起来的次数 */
|
||||||
|
const scores = ref<FlowchartScores["scores"]>([])
|
||||||
|
const hiddenCount = ref(0)
|
||||||
|
const submissionCount = computed(() => scores.value.length)
|
||||||
|
/** 结果页签正在看哪一次;null = 最新那次 */
|
||||||
|
const selectedId = ref<string | null>(null)
|
||||||
|
/** 看过的几次的完整评语,按 id 缓存(评完就不会再变) */
|
||||||
|
const details = ref<Record<string, FlowchartSubmission>>({})
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
|
||||||
|
/** 画布本身。结果页签要拿当前画布和某一版比一比、把那一版载回去 */
|
||||||
|
const editor = shallowRef<FlowchartEditorInstance | null>(null)
|
||||||
|
function attachEditor(instance: FlowchartEditorInstance | null) {
|
||||||
|
editor.value = instance
|
||||||
|
}
|
||||||
/** 最近一次交上去的那张图,评到 A/S 时亮到「我的流程图」 */
|
/** 最近一次交上去的那张图,评到 A/S 时亮到「我的流程图」 */
|
||||||
const lastSubmittedMermaidCode = ref("")
|
const lastSubmittedMermaidCode = ref("")
|
||||||
/** 最近一次分数是哪道题的;换题后置空,下次挂载时重新拉 */
|
/** 最近一次分数是哪道题的;换题后置空,下次挂载时重新拉 */
|
||||||
@@ -122,16 +157,21 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
scheduleDisconnect(15 * 60 * 1000)
|
scheduleDisconnect(15 * 60 * 1000)
|
||||||
|
|
||||||
if (!outcome.ok) {
|
if (!outcome.ok) {
|
||||||
message.error(
|
phase.value = "failed"
|
||||||
outcome.error ? `流程图评分失败: ${outcome.error}` : "流程图评分失败,请稍后重试",
|
message.error("AI 这次没评出来,再交一次试试")
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
latestRating.value = { score: outcome.score, grade: outcome.grade }
|
latestRating.value = { score: outcome.score, grade: outcome.grade }
|
||||||
message.success(`流程图评分完成!得分: ${outcome.score}分 (${outcome.grade}级)`)
|
phase.value = "done"
|
||||||
if ((outcome.grade === "A" || outcome.grade === "S") && lastSubmittedMermaidCode.value) {
|
selectedId.value = null
|
||||||
|
evaluatedSeq.value++
|
||||||
|
// 学生这会儿可能在看题目:只提示一句、页签上亮状态,不强行把他切过去(设计文档 5.3)
|
||||||
|
message.success(`AI 点评完了:${outcome.score} 分,${outcome.grade} 级`)
|
||||||
|
if (isFlowchartPass(outcome.grade) && lastSubmittedMermaidCode.value) {
|
||||||
myFlowchartStore.show(lastSubmittedMermaidCode.value)
|
myFlowchartStore.show(lastSubmittedMermaidCode.value)
|
||||||
}
|
}
|
||||||
|
const problemId = problemStore.problem?.id
|
||||||
|
if (problemId) loadScores(problemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
|
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
|
||||||
@@ -162,7 +202,9 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
|
|
||||||
lastSubmittedMermaidCode.value = mermaidCode
|
lastSubmittedMermaidCode.value = mermaidCode
|
||||||
loading.value = true
|
loading.value = true
|
||||||
latestRating.value = { score: 0, grade: "" }
|
phase.value = "evaluating"
|
||||||
|
// 交上去就切到「结果」页签,和交代码一样
|
||||||
|
useSubmissionStore().revealResult()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await submitFlowchart({
|
const response = await submitFlowchart({
|
||||||
@@ -183,10 +225,9 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
startPollingFallback()
|
startPollingFallback()
|
||||||
startPollingDeadline()
|
startPollingDeadline()
|
||||||
}
|
}
|
||||||
|
|
||||||
message.success("流程图已提交,请耐心等待评分")
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
phase.value = "idle"
|
||||||
// 按错误码分支(见 utils/api.ts 的约定)。限流是最容易撞上的一种:
|
// 按错误码分支(见 utils/api.ts 的约定)。限流是最容易撞上的一种:
|
||||||
// 只说「提交失败」的话,学生会以为是自己的图有问题,然后反复点,越点越久
|
// 只说「提交失败」的话,学生会以为是自己的图有问题,然后反复点,越点越久
|
||||||
if (errorCode(error) === "too-many-submissions") {
|
if (errorCode(error) === "too-many-submissions") {
|
||||||
@@ -200,32 +241,64 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 最近一次的分数 ====================
|
// ==================== 历次分数 ====================
|
||||||
|
async function loadScores(problemId: number) {
|
||||||
|
const data = await getFlowchartScores(problemId)
|
||||||
|
// 换题之后才回来的是上一道题的,别盖掉新题的
|
||||||
|
if (problemStore.problem?.id !== problemId) return
|
||||||
|
scores.value = data.scores
|
||||||
|
hiddenCount.value = data.hidden
|
||||||
|
const latest = data.scores.at(-1)
|
||||||
|
if (latest && phase.value !== "evaluating") {
|
||||||
|
latestRating.value = { score: latest.score, grade: latest.grade }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这道题最近一次的分数;画到了 A/S 的话,把那张图亮到「我的流程图」。
|
* 进这道题时读一次历次分数;画到过 A/S 的话,把最近那张 A/S 亮到题面的「你画的流程图」。
|
||||||
* 同一道题只拉一次(组件卸了再挂回来不重拉),换题后 loadedFor 清空。
|
* 同一道题只读一次(组件卸了再挂回来不重读),换题后 loadedFor 清空。
|
||||||
*/
|
*/
|
||||||
async function ensureLoaded() {
|
async function ensureLoaded() {
|
||||||
const problemId = problemStore.problem?.id
|
const problemId = problemStore.problem?.id
|
||||||
if (!problemId || loadedFor === problemId) return
|
if (!problemId || loadedFor === problemId) return
|
||||||
loadedFor = problemId
|
loadedFor = problemId
|
||||||
try {
|
try {
|
||||||
const data = await getCurrentProblemFlowchartSubmission(problemId)
|
await loadScores(problemId)
|
||||||
// 换题之后才回来的是上一道题的分数,别盖掉新题的
|
|
||||||
if (problemStore.problem?.id !== problemId) return
|
if (problemStore.problem?.id !== problemId) return
|
||||||
submissionCount.value = data.count
|
const passed = scores.value.filter((row) => isFlowchartPass(row.grade)).at(-1)
|
||||||
latestRating.value = { score: data.score, grade: data.grade }
|
if (passed) {
|
||||||
const grade = data.grade
|
const detail = await fetchDetail(passed.id)
|
||||||
if ((grade === "A" || grade === "S") && data.count > 0) {
|
|
||||||
const detail = await getFlowchartSubmissionDetail(problemId, data.count)
|
|
||||||
// 查的这一会儿又换了题,这张图不是新题的
|
// 查的这一会儿又换了题,这张图不是新题的
|
||||||
if (problemStore.problem?.id !== problemId) return
|
if (problemStore.problem?.id !== problemId) return
|
||||||
if (detail.submission?.mermaidCode) myFlowchartStore.show(detail.submission.mermaidCode)
|
if (detail?.mermaidCode) myFlowchartStore.show(detail.mermaidCode)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 拉不到就当没交过;下次挂载再试
|
// 拉不到就当没交过;下次挂载再试
|
||||||
loadedFor = null
|
loadedFor = null
|
||||||
console.error("[Flowchart] 读取最近一次评分失败:", error)
|
console.error("[Flowchart] 读取历次评分失败:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDetail(id: string) {
|
||||||
|
const cached = details.value[id]
|
||||||
|
if (cached) return cached
|
||||||
|
const detail = await getFlowchartSubmission(id)
|
||||||
|
details.value[id] = detail
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结果页签里点了某一次(null = 回到最新):把那次的完整评语拉回来 */
|
||||||
|
async function select(id: string | null) {
|
||||||
|
selectedId.value = id
|
||||||
|
const target = id ?? scores.value.at(-1)?.id
|
||||||
|
if (!target || details.value[target]) return
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
await fetchDetail(target)
|
||||||
|
} catch {
|
||||||
|
message.error("这一次的评语读不出来,过一会儿再试")
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +310,11 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
function reset() {
|
function reset() {
|
||||||
stopMonitoring()
|
stopMonitoring()
|
||||||
latestRating.value = { score: 0, grade: "" }
|
latestRating.value = { score: 0, grade: "" }
|
||||||
submissionCount.value = 0
|
phase.value = "idle"
|
||||||
|
scores.value = []
|
||||||
|
hiddenCount.value = 0
|
||||||
|
selectedId.value = null
|
||||||
|
details.value = {}
|
||||||
lastSubmittedMermaidCode.value = ""
|
lastSubmittedMermaidCode.value = ""
|
||||||
loadedFor = null
|
loadedFor = null
|
||||||
}
|
}
|
||||||
@@ -253,9 +330,19 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
latestRating,
|
latestRating,
|
||||||
|
phase,
|
||||||
|
evaluatedSeq,
|
||||||
|
scores,
|
||||||
|
hiddenCount,
|
||||||
submissionCount,
|
submissionCount,
|
||||||
|
selectedId,
|
||||||
|
details,
|
||||||
|
detailLoading,
|
||||||
|
editor,
|
||||||
|
attachEditor,
|
||||||
submit,
|
submit,
|
||||||
ensureLoaded,
|
ensureLoaded,
|
||||||
|
select,
|
||||||
reset,
|
reset,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { defineStore } from "pinia"
|
import { defineStore } from "pinia"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import type { LANGUAGE, ProblemDetail } from "utils/types"
|
import type { LANGUAGE, ProblemDetail } from "utils/types"
|
||||||
|
import { problemEntryOf } from "oj/problem/composables/problemPageContext"
|
||||||
|
|
||||||
export const useProblemStore = defineStore("problem", () => {
|
export const useProblemStore = defineStore("problem", () => {
|
||||||
const problem = ref<ProblemDetail | null>(null)
|
const problem = ref<ProblemDetail | null>(null)
|
||||||
@@ -25,20 +27,46 @@ export const useProblemStore = defineStore("problem", () => {
|
|||||||
*/
|
*/
|
||||||
const failCount = computed(() => (problem.value?.myFailedCount ?? 0) + sessionFailCount.value)
|
const failCount = computed(() => (problem.value?.myFailedCount ?? 0) + sessionFailCount.value)
|
||||||
|
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这道题能不能画流程图。比赛里不给(入口差异表);题单里原来也不给,现在给了,画到 A/S
|
||||||
|
* 算完成(设计文档第 3 节决定 4)。手机上不给:节点库是 HTML5 拖放,手机上拖不动。
|
||||||
|
*/
|
||||||
|
const canDraw = computed(
|
||||||
|
() => !!problem.value?.allowFlowchart && problemEntryOf(route) !== "contest" && isDesktop.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑器能切到的全部「语言」。流程图在内部仍是一门叫 Flowchart 的语言(提交、草稿、编辑器
|
||||||
|
* 切换都按它分支),但界面上不再混在语言下拉里 —— 那是工具栏最左边的「写代码 / 画流程图」。
|
||||||
|
*/
|
||||||
const languages = computed<LANGUAGE[]>(() => {
|
const languages = computed<LANGUAGE[]>(() => {
|
||||||
if (route.name === "problem" && problem.value?.allowFlowchart) {
|
const own = problem.value?.languages ?? []
|
||||||
return ["Flowchart", ...problem.value.languages]
|
return canDraw.value ? ["Flowchart", ...own] : own
|
||||||
}
|
|
||||||
return problem.value?.languages ?? []
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 语言下拉里的:真正的编程语言 */
|
||||||
|
const codeLanguages = computed(() => languages.value.filter((it) => it !== "Flowchart"))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请编辑器换语言,并读回那门语言的草稿(没有就是模板)。只有编辑器(ProblemEditor)知道
|
||||||
|
* 草稿存在哪,所以这里只留一个请求,由它接住 —— 结果页签里的「照着它写代码」不在编辑器里。
|
||||||
|
*/
|
||||||
|
const languageRequest = ref<LANGUAGE | null>(null)
|
||||||
|
function switchLanguage(language: LANGUAGE) {
|
||||||
|
languageRequest.value = language
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这道题收不收当前语言,不收就退到它支持的第一种(SQL 题只有 "SQL",硬编码的
|
* 这道题收不收当前语言,不收就退到它支持的第一种(SQL 题只有 "SQL",硬编码的
|
||||||
* Python 会被后端拒绝)。编辑器每次载入代码之前都要过一遍:草稿的键里带着语言,
|
* Python 会被后端拒绝)。编辑器每次载入代码之前都要过一遍:草稿的键里带着语言,
|
||||||
* 先载入再改语言,编辑器里摆的就是另一种语言的模板。
|
* 先载入再改语言,编辑器里摆的就是另一种语言的模板。
|
||||||
*/
|
*/
|
||||||
function supportedLanguage(current: LANGUAGE): LANGUAGE {
|
function supportedLanguage(current: LANGUAGE): LANGUAGE {
|
||||||
return languages.value.includes(current) ? current : (languages.value[0] ?? "Python")
|
if (languages.value.includes(current)) return current
|
||||||
|
// 兜底落在编程语言上:languages 的第一项可能是 Flowchart,不能默认把人扔到画布上
|
||||||
|
return codeLanguages.value[0] ?? languages.value[0] ?? "Python"
|
||||||
}
|
}
|
||||||
|
|
||||||
function incrementFailCount() {
|
function incrementFailCount() {
|
||||||
@@ -55,7 +83,11 @@ export const useProblemStore = defineStore("problem", () => {
|
|||||||
return {
|
return {
|
||||||
problem,
|
problem,
|
||||||
failCount,
|
failCount,
|
||||||
|
canDraw,
|
||||||
languages,
|
languages,
|
||||||
|
codeLanguages,
|
||||||
|
languageRequest,
|
||||||
|
switchLanguage,
|
||||||
supportedLanguage,
|
supportedLanguage,
|
||||||
incrementFailCount,
|
incrementFailCount,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,22 @@ interface Props {
|
|||||||
const { height = "calc(100vh - 133px)" } = defineProps<Props>()
|
const { height = "calc(100vh - 133px)" } = defineProps<Props>()
|
||||||
|
|
||||||
// Vue Flow 实例
|
// Vue Flow 实例
|
||||||
const { addEdges, removeNodes, removeEdges } = useVueFlow()
|
const { addEdges, removeNodes, removeEdges, fitView, onNodesInitialized } = useVueFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进页面读回草稿、载回旧版之后缩放到正好装下整张图。原来画布停在默认的位置和缩放上,
|
||||||
|
* 图大一点就有一半在外面,学生以为草稿丢了。只在这两种时候缩:拖进一个新节点时也会
|
||||||
|
* 触发 nodes-initialized,那时候跟着缩放会让画布跳一下
|
||||||
|
*/
|
||||||
|
let fitPending = false
|
||||||
|
function fitSoon() {
|
||||||
|
fitPending = true
|
||||||
|
}
|
||||||
|
onNodesInitialized(() => {
|
||||||
|
if (!fitPending) return
|
||||||
|
fitPending = false
|
||||||
|
fitView({ padding: 0.15, maxZoom: 1.2 })
|
||||||
|
})
|
||||||
|
|
||||||
// 节点和边的响应式数据
|
// 节点和边的响应式数据
|
||||||
// 显式标注成 Ref<Node[]>,不走 ref<T>() 的 UnwrapRef 推导 —— vue-flow 的
|
// 显式标注成 Ref<Node[]>,不走 ref<T>() 的 UnwrapRef 推导 —— vue-flow 的
|
||||||
@@ -37,12 +52,18 @@ const { canUndo, canRedo, resetHistory, saveState, undo, redo } = useHistory()
|
|||||||
|
|
||||||
const problemStore = useProblemStore()
|
const problemStore = useProblemStore()
|
||||||
const { problem } = storeToRefs(problemStore)
|
const { problem } = storeToRefs(problemStore)
|
||||||
// 缓存管理:用 computed key 支持题目 ID 异步加载后自动切换到正确的 storage
|
const route = useRoute()
|
||||||
const cacheKey = computed(() =>
|
/**
|
||||||
problem.value?._id
|
* 缓存管理:用 computed key 支持题目 ID 异步加载后自动切换到正确的 storage。
|
||||||
? `flowchart-editor-data-problem-${problem.value!._id}`
|
* 题单入口单独一份草稿:加入题单之前在题库里画到 A 的那张,不能一进题单就摆在画布上
|
||||||
: "flowchart-editor-data",
|
* (设计文档第 11 节 3,和代码草稿同理)
|
||||||
)
|
*/
|
||||||
|
const cacheKey = computed(() => {
|
||||||
|
if (!problem.value?._id) return "flowchart-editor-data"
|
||||||
|
const base = `flowchart-editor-data-problem-${problem.value._id}`
|
||||||
|
const problemSetId = route.params.problemSetId
|
||||||
|
return problemSetId ? `${base}-problemset-${problemSetId}` : base
|
||||||
|
})
|
||||||
const { isSaving, lastSaved, hasUnsavedChanges, saveToCache, loadFromCache, clearCache } = useCache(
|
const { isSaving, lastSaved, hasUnsavedChanges, saveToCache, loadFromCache, clearCache } = useCache(
|
||||||
nodes,
|
nodes,
|
||||||
edges,
|
edges,
|
||||||
@@ -51,6 +72,7 @@ const { isSaving, lastSaved, hasUnsavedChanges, saveToCache, loadFromCache, clea
|
|||||||
// 换题后画布已经被换成新题的草稿,历史必须跟着重建,
|
// 换题后画布已经被换成新题的草稿,历史必须跟着重建,
|
||||||
// 否则一次撤销就会把上一题的图还原到这一题里
|
// 否则一次撤销就会把上一题的图还原到这一题里
|
||||||
resetHistory(nodes.value, edges.value)
|
resetHistory(nodes.value, edges.value)
|
||||||
|
fitSoon()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -152,6 +174,7 @@ onMounted(() => {
|
|||||||
// 否则第一步操作没有可回退的目标,撤销按钮一直是灰的
|
// 否则第一步操作没有可回退的目标,撤销按钮一直是灰的
|
||||||
loadFromCache()
|
loadFromCache()
|
||||||
resetHistory(nodes.value, edges.value)
|
resetHistory(nodes.value, edges.value)
|
||||||
|
fitSoon()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -177,6 +200,7 @@ const setFlowchartData = (data: { nodes: Node[]; edges: Edge[] }) => {
|
|||||||
nodes.value = processedNodes
|
nodes.value = processedNodes
|
||||||
edges.value = processedEdges
|
edges.value = processedEdges
|
||||||
saveState(nodes.value, edges.value)
|
saveState(nodes.value, edges.value)
|
||||||
|
fitSoon()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# OJ2 设计文档:题目详情页重设计
|
# OJ2 设计文档:题目详情页重设计
|
||||||
|
|
||||||
日期:2026-09-28
|
日期:2026-09-28
|
||||||
状态:**设计已定,第 14 节的 1–4 步和第 7 节已实施**。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
|
状态:**设计已定,第 14 节的 1–5 步和第 7 节已实施**(剩第 6 步:协作的几处规则、手机版)。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
|
||||||
那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。效果图表达不了的规则都写在这里,
|
那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。效果图表达不了的规则都写在这里,
|
||||||
**实施时以本文为准**。
|
**实施时以本文为准**。
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user