Compare commits

..
4 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 972dac3776 feat(题目): 答错时说清楚「输出了什么、应该是什么」,交对后给相似题,零碎几处对齐设计
Deploy / deploy (push) Canceled after 0s
- 答案错误里「格式和内容都不一样」那一类(样例上错的 44%):原来只说「对照一下,找找
  从哪里开始不一样」,现在是「你的程序输出了「E」,正确的是「D」。拿例子 2 的输入「60」,
  一行一行走一遍你的程序,看它走进了哪个分支」;多行的说第几行开始不一样
- 不在这节课里的题交对之后,结果页签里给「再练几道相似的」(原来只在题面最底下)
- 提交前自动排版改了代码的,结果上面提一句「编辑器里的代码也跟着变了,按 Ctrl+Z 能撤回」
- 交对之后「提交」降成描边按钮,「下一题」是唯一的主按钮
- 「重置代码」先确认(原来点一下就把代码和本地草稿一起清掉了)
- AI 提示的档位写「第 1 档 · 反问」,不写「L0 反问」
- 成就弹窗从右下角挪到右上角,别和结果、点评弹窗叠在一起

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:39:05 +08:00
xuyueandClaude Opus 5.5 9110c678fc feat(题目): 手机上「运行例子 / 提交」固定在屏幕底部,看着题目也能直接交
- 两个 44px 高的大按钮固定在底部,三个页签下都在;原来它们在「代码」页签顶上,
  和语言、更多挤成两行,看着题面想交一次还得先切回代码页签
- 「代码」页签的编辑器一直挂着(原来切走就卸载):没打开过代码页签也能交,撤销历史也不丢
- 编辑器高度让出底部那条,最后几行不再被按钮盖住

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:33:17 +08:00
xuyueandClaude Opus 5.5 0c2ff68e9b feat(协作): 老师协作时右栏顶上一条协作条,结果页签先摆学生最近一次交的,抽屉变成「他的提交」
- 协作条:「正在帮 谁 · 语言 · 看他交过什么 ↗ · 结束协作」,从工具栏里挪出来,一眼看得出
  编辑器里是学生的代码。「看他交过什么」带上学生用户名、开新标签(原来只带题号,还得再筛一遍)
- 老师接单后「结果」页签原来是空的:现在摆学生在这道题上最近一次交的结果,可刷新;
  不给 AI 提示(那是按登录的人自己的失败次数解锁的)
- 抽屉「我的提交」在协作中变成「他的提交」,列的是学生的提交,只能复制、不能放回编辑器
  (放回去会经协作通道直接盖掉学生正在写的代码),也不显示老师自己的排名
- 学生排队求助中、或者老师正在帮他时换题:先确认一句。换题会断开协作 / 撤掉举手,
  原来一点「下一题」就把正在帮他的老师踢出去了
- 求助按钮文案:「举手求助」→「已举手 · 前面 2 人」→「某某 老师在帮你」
- 提交列表加 exactUsername=1:用户名精确匹配。原来只有「包含」,ks24a1 会把 ks24a10 的提交
  也捞进来

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:28:53 +08:00
xuyueandClaude Opus 5.5 11368813cc feat(流程图): 「写代码 / 画流程图」变成工具栏上的切换,AI 的评分和历次分数挪进结果页签,题单里也能画
- 流程图题的工具栏最左边是「写代码 / 画流程图」。原来流程图藏在语言下拉里,叫一个名叫
  Flowchart 的「语言」;画完一道题之后它还被记成语言偏好,下一道能画的题一打开就是画布
- 「交给 AI 点评」交完切到「结果」页签:评的时候写着「AI 正在看你的图」、画布照样能改;
  评完是分数、评语、改进建议、四项得分,顶上一排历次分数,点哪次看哪次。
  原来是提交按钮旁一个 1000px 的弹框,盖住画布,翻历次评分要一页一页点
- 画到 A / S:给「照着它写代码 →」,题单里 1.5 秒后回题单(和代码通过一样),
  课堂条当场标成做完;只在这一页上刚评完的那次才算,进页面读到以前的 A 不跳
- 「把这一版载回画布」只在画布和那一版不一样时出现;载回、进页面读回草稿后都缩放到装下整张图
- 题单入口能画流程图了(原来只有题库入口能画),题单里的代码草稿、流程图草稿各存一份,
  加入题单之前写的、画的不会一进题单就摆在编辑器里
- 手机上不给「画流程图」(节点库是拖放,手机上拖不动),题面里以前画好的那张照样看得到

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:19:16 +08:00
33 changed files with 1333 additions and 533 deletions

No files matched your search

@@ -296,6 +296,19 @@ async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
* 快照实测 count 65ms → 0.6ms。`ks2` 这种匹配上千个账号的宽前缀退回扫表,30~50ms, * 快照实测 count 65ms → 0.6ms。`ks2` 这种匹配上千个账号的宽前缀退回扫表,30~50ms,
* 和原来持平。 * 和原来持平。
*/ */
/**
* 用户名**精确**匹配。usernameFilter 是「包含」,学号互相包含是常态(ks24a1 包含在 ks24a10 里),
* 协作中老师看「这个学生」的提交时不能把别人的也捞进来。查无此人留恒假条件
*/
export async function exactUsernameFilter(username: string) {
const [row] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
return row ? eq(schema.submission.userId, row.id) : sql`false`
}
export async function usernameFilter(username: string) { export async function usernameFilter(username: string) {
const like = `%${username}%` const like = `%${username}%`
const users = await db const users = await db
+11 -2
View File
@@ -32,7 +32,12 @@ import { problemSetJoinTimes } from "../services/problemset"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { dayStart } from "../time" import { dayStart } from "../time"
import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers" import { asFilterValue, asRecord, isAdminRole, queryInteger } from "./helpers"
import { problemFilter, submissionStatisticsRoutes, usernameFilter } from "./submission-statistics" import {
exactUsernameFilter,
problemFilter,
submissionStatisticsRoutes,
usernameFilter,
} from "./submission-statistics"
export const submissionRoutes = new Hono<ContestEnv>() export const submissionRoutes = new Hono<ContestEnv>()
@@ -489,7 +494,11 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
filters.push( filters.push(
...(await Promise.all([ ...(await Promise.all([
displayId ? problemFilter(displayId, null) : undefined, displayId ? problemFilter(displayId, null) : undefined,
username ? usernameFilter(username) : undefined, username
? c.req.query("exactUsername") === "1"
? exactUsernameFilter(username)
: usernameFilter(username)
: undefined,
])), ])),
) )
if (myself) filters.push(eq(schema.submission.userId, myself.id)) if (myself) filters.push(eq(schema.submission.userId, myself.id))
+1 -1
View File
@@ -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
View File
@@ -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 ====================
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
/**
* 老师协作时右栏顶上的一条:正在帮谁、他用什么语言、看他交过什么、结束协作。
*
* 原来这几样挤在工具栏里(一个标签 + 一个按钮),和老师自己的「提交」「课堂统计」混在一行,
* 一眼看不出「现在编辑器里是学生的代码」。学生那边不加横幅,省下编辑器的 40px。
*/
const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore())
const router = useRouter()
/**
* 「看他交过什么」:只看这个学生、这道题。开新标签 —— 跳走这一页协作就断了。
* 原来工具栏的「本题提交」只带题号,老师得再手动筛一遍用户名
*/
function openHisSubmissions() {
const room = collabStore.room
if (!room || !problem.value) return
const target = router.resolve({
name: "submissions",
query: { problem: problem.value._id, username: room.peerName },
})
window.open(target.href, "_blank")
}
</script>
<template>
<div v-if="collabStore.room" class="collab-bar" role="status">
<span class="who">
正在帮 <b>{{ collabStore.room.peerName }}</b> ·
{{ LANGUAGE_SHOW_VALUE[collabStore.room.language] }}
</span>
<n-button text class="link" @click="openHisSubmissions">看他交过什么 ↗</n-button>
<!-- 显式的「结束」:求助记录一并清掉。跳走页面发的是 leave("left"),
那边只是退回排队 —— 见 store 里 leave 的注释 -->
<n-button size="small" class="end" @click="collabStore.leave('done')">结束协作</n-button>
</div>
</template>
<style scoped>
.collab-bar {
height: 40px;
flex: none;
display: flex;
align-items: center;
gap: 14px;
padding: 0 12px;
border-radius: 6px;
background-color: #2080f0;
color: #fff;
font-size: 14px;
white-space: nowrap;
}
.who {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.link {
--n-text-color: #fff !important;
--n-text-color-hover: rgba(255, 255, 255, 0.85) !important;
--n-text-color-pressed: rgba(255, 255, 255, 0.7) !important;
--n-text-color-focus: #fff !important;
text-decoration: underline;
}
.end {
margin-left: auto;
}
</style>
@@ -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()
@@ -95,14 +108,14 @@ const showCollabBar = computed(() => collabHere.value && userStore.isTeacherOrAb
const helpButtonText = computed(() => { const helpButtonText = computed(() => {
if (collabStore.helpStatus === "active") { if (collabStore.helpStatus === "active") {
const name = collabStore.teacherName const name = collabStore.teacherName
return name ? `${name} 老师帮你中` : "老师正在帮你" return name ? `${name} 老师在帮你` : "老师正在帮你"
} }
if (collabStore.helpStatus === "pending") { if (collabStore.helpStatus === "pending") {
return collabStore.queueAhead > 0 return collabStore.queueAhead > 0
? `已求助 · 前面 ${collabStore.queueAhead} 人` ? `已举手 · 前面 ${collabStore.queueAhead} 人`
: "已求助 · 待接入" : "已举手 · 老师马上来"
} }
return "求助" return "举手求助"
}) })
const helpButtonType = computed(() => { const helpButtonType = computed(() => {
@@ -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, {
@@ -201,12 +214,22 @@ const copy = async () => {
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`) message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
} }
// 重置会把编辑器里的代码换成模板、连本地草稿一起删掉,点错一下写了半节课的代码就没了,先问一句
const dialog = useDialog()
const reset = () => { const reset = () => {
codeStore.setCode( dialog.warning({
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language], title: "重置代码",
) content: "编辑器里的代码会换回题目给的模板,存着的草稿也会删掉。确定吗?",
storage.remove(storageKey) positiveText: "重置",
message.success("代码重置成功") negativeText: "再想想",
onPositiveClick: () => {
codeStore.setCode(
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
)
storage.remove(storageKey)
message.success("已换回模板,按 Ctrl+Z 可以撤回")
},
})
} }
const changeLanguage = (v: LANGUAGE) => { const changeLanguage = (v: LANGUAGE) => {
@@ -239,8 +262,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"
@@ -249,8 +279,9 @@ const goEdit = () => {
@update:value="changeLanguage" @update:value="changeLanguage"
/> />
<!-- 手机上「运行例子」「提交」在屏幕底部那条(MobileActionBar),三个页签都在 -->
<n-button <n-button
v-if="canRunSamples" v-if="canRunSamples && isDesktop"
:size="buttonSize" :size="buttonSize"
:loading="samplesRunning" :loading="samplesRunning"
:disabled="!codeStore.code.value.trim()" :disabled="!codeStore.code.value.trim()"
@@ -261,7 +292,7 @@ const goEdit = () => {
<SubmitFlowchart v-if="codeStore.code.language === 'Flowchart'" /> <SubmitFlowchart v-if="codeStore.code.language === 'Flowchart'" />
<SubmitCode v-else /> <SubmitCode v-else-if="isDesktop" />
<n-button <n-button
v-if="isDesktop && userStore.isTeacherOrAbove" v-if="isDesktop && userStore.isTeacherOrAbove"
@@ -281,18 +312,6 @@ const goEdit = () => {
<n-button :size="buttonSize">更多</n-button> <n-button :size="buttonSize">更多</n-button>
</n-dropdown> </n-dropdown>
<template v-if="showCollabBar">
<n-tag type="success" :size="buttonSize">
正在帮 {{ collabStore.room!.peerName }} ·
{{ LANGUAGE_SHOW_VALUE[collabStore.room!.language] }}
</n-tag>
<!-- 显式的「结束」:求助记录一并清掉。跳走页面发的是 leave("left"),
那边只是退回排队 —— 见 store 里 leave 的注释 -->
<n-button :size="buttonSize" type="primary" @click="collabStore.leave('done')">
结束协作
</n-button>
</template>
<n-button <n-button
v-if="showHelpButton" v-if="showHelpButton"
: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>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
import SubmitCode from "./SubmitCode.vue"
/**
* 手机上屏幕底部固定的「运行例子 / 提交」(设计文档 5.6)。原来这两个按钮在「代码」页签
* 顶上,和语言、更多挤成两行;看着题面想交一次,还得先切回代码页签。
* 提交状态、代码都在 store 里,编辑器没挂着也能交。
*/
const theme = useThemeVars()
const codeStore = useCodeStore()
const submissionStore = useSubmissionStore()
const { samplesRunning } = submissionStore.trial
const { problem } = storeToRefs(useProblemStore())
// 和工具栏同一个条件:Judge0 跑不了 SQL,流程图没得跑,没有例子的题不给
const canRunSamples = computed(
() =>
!!problem.value?.samples.length &&
codeStore.code.language !== "Flowchart" &&
codeStore.code.language !== "SQL",
)
</script>
<template>
<div class="mobile-actions">
<n-button
v-if="canRunSamples"
size="large"
class="action"
:loading="samplesRunning"
:disabled="!codeStore.code.value.trim()"
@click="submissionStore.runSamples()"
>
运行例子
</n-button>
<div class="action">
<SubmitCode size="large" block />
</div>
</div>
</template>
<style scoped>
.mobile-actions {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
display: flex;
gap: 10px;
padding: 8px 16px calc(8px + env(safe-area-inset-bottom));
background-color: v-bind("theme.bodyColor");
border-top: 1px solid rgba(128, 128, 128, 0.2);
backdrop-filter: blur(8px);
}
.action {
flex: 1;
min-width: 0;
}
.mobile-actions :deep(.n-button) {
height: 44px;
}
</style>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { getSubmission, getSubmissions } from "oj/api"
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { parseTime } from "utils/functions"
import type { Submission } from "utils/types"
/**
* 老师接单之后落在题目页,「结果」页签原来是空的 —— 学生多半是交了几次没过才举的手,
* 老师最先想知道的就是「他交上去是什么结果」。这里摆出学生最近一次交的(设计文档第 9 节)。
* 管理员角色本来就能看任何人的提交,不需要新接口。
*/
const SubmissionResult = defineAsyncComponent(() => import("./SubmissionResult.vue"))
const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore())
const loading = ref(false)
const failed = ref(false)
const latest = ref<Submission | null>(null)
const checked = ref(false)
const peer = computed(() => collabStore.room?.peerName ?? "")
async function load() {
if (!peer.value || !problem.value) return
loading.value = true
failed.value = false
try {
const list = await getSubmissions({
username: peer.value,
exactUsername: "1",
problemDisplayId: problem.value._id,
limit: 1,
offset: 0,
})
const row = list.results[0]
latest.value = row ? await getSubmission(row.id) : null
checked.value = true
} catch {
failed.value = true
} finally {
loading.value = false
}
}
watch([peer, () => problem.value?._id], load, { immediate: true })
</script>
<template>
<div class="peer-latest">
<n-flex align="center" justify="space-between" class="head">
<span>
<b>{{ peer }}</b> 最近一次交的
<n-text v-if="latest" depth="3">· {{ parseTime(latest.createTime, "HH:mm:ss") }}</n-text>
</span>
<n-button size="small" :loading="loading" @click="load">刷新</n-button>
</n-flex>
<n-text v-if="failed" type="error">读不出他的提交,点「刷新」再试</n-text>
<SubmissionResult v-else-if="latest" :submission="latest" peer />
<n-empty v-else-if="checked" class="empty" description="他还没交过这道题" />
</div>
</template>
<style scoped>
.head {
margin-bottom: 12px;
}
.empty {
margin-top: 32px;
}
</style>
@@ -5,13 +5,12 @@ import { useProblemStore } from "oj/store/problem"
import { DIFFICULTY } from "utils/constants" import { DIFFICULTY } from "utils/constants"
import { getTagColor } from "utils/functions" import { getTagColor } from "utils/functions"
import { useSubmissionStore } from "oj/store/submission" import { useSubmissionStore } from "oj/store/submission"
import type { ProblemRow } from "utils/types"
import Copy from "shared/components/Copy.vue" import Copy from "shared/components/Copy.vue"
import { useDark } from "@vueuse/core" import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css" import "md-editor-v3/lib/preview.css"
import { getSimilarProblems } from "oj/api"
import SQLDataTable from "./SQLDataTable.vue" import SQLDataTable from "./SQLDataTable.vue"
import SimilarProblems from "./SimilarProblems.vue"
import { useFlowchartStore } from "oj/store/flowchart" import { useFlowchartStore } from "oj/store/flowchart"
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,7 +21,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 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
@@ -37,42 +36,9 @@ const sqlChangedTables = computed(() => {
return exp && "changed_tables" in exp ? exp.changed_tables : [] return exp && "changed_tables" in exp ? exp.changed_tables : []
}) })
const router = useRouter() // 相似题推荐:做对了、或者错了 3 次以上才给(比赛、题单里不给,见 problemPageContext)
const showSimilar = computed(
// 相似题目推荐 () => ctx.value.similar && (problem.value?.myStatus === 0 || problemStore.failCount >= 3),
const similarProblems = ref<ProblemRow[]>([])
const similarLoaded = ref(false)
async function loadSimilarProblems() {
if (similarLoaded.value || !problem.value) return
// 比赛、题单里不推荐(理由见 problemPageContext 那张表)
if (!ctx.value.similar) return
try {
similarProblems.value = await getSimilarProblems(problem.value._id)
} catch {
similarProblems.value = []
}
similarLoaded.value = true
}
// 切换题目时重置相似推荐状态
watch(
() => problem.value?._id,
() => {
similarProblems.value = []
similarLoaded.value = false
},
)
// AC 或失败次数 >= 3 时加载推荐
watch(
() => [problem.value?._id, problem.value?.myStatus, problemStore.failCount],
([, status, failCount]) => {
if (status === 0 || (failCount as number) >= 3) {
loadSimilarProblems()
}
},
{ immediate: true },
) )
/** /**
@@ -103,11 +69,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 },
) )
@@ -346,49 +318,7 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
/> />
</div> </div>
<!-- 相似题目推荐 --> <SimilarProblems v-if="showSimilar" title="相似题目推荐" />
<div v-if="ctx.similar && similarProblems.length > 0">
<n-divider />
<h3 class="title">
<Icon icon="streamline-ultimate-color:like"></Icon>
相似题目推荐
</h3>
<n-list bordered>
<n-list-item v-for="sp in similarProblems" :key="sp._id">
<n-flex align="center" justify="space-between">
<n-flex align="center">
<n-tag size="small">{{ sp._id }}</n-tag>
<n-button
text
type="info"
@click="
router.push({
name: 'problem',
params: { problemID: sp._id },
})
"
>
{{ sp.title }}
</n-button>
</n-flex>
<!-- getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High -->
<n-tag
v-if="sp.difficulty"
size="small"
:type="
sp.difficulty === '简单'
? 'success'
: sp.difficulty === '困难'
? 'error'
: 'warning'
"
>
{{ sp.difficulty }}
</n-tag>
</n-flex>
</n-list-item>
</n-list>
</div>
</div> </div>
</template> </template>
@@ -1,13 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { import {
DRAWER_TITLE, DRAWER_TITLE,
useProblemPageContext, useProblemPageContext,
type ProblemDrawer, type ProblemDrawer,
} from "../composables/problemPageContext" } from "../composables/problemPageContext"
import { useTeacherCollab } from "../composables/teacherCollab"
/** /**
* 统计 / 点评 / 我的提交。原来是左栏的三个页签,课上几乎没人点,却和「题目」挤在一排; * 统计 / 点评 / 我的提交。原来是左栏的三个页签,课上几乎没人点,却和「题目」挤在一排;
@@ -30,7 +30,6 @@ const ctx = useProblemPageContext()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore()) const { problem } = storeToRefs(useProblemStore())
const show = computed({ const show = computed({
@@ -55,6 +54,14 @@ watch(
}, },
) )
// 协作中的老师看的是学生的提交(ProblemSubmission 里按学生用户名查)
const teacherCollab = useTeacherCollab()
const drawerTitle = computed(() =>
lastOpened.value === "submission" && teacherCollab.value
? "他的提交"
: DRAWER_TITLE[lastOpened.value],
)
/** /**
* 「看这道题所有人的提交」,原来是工具栏上的「本题提交」。可见条件照旧: * 「看这道题所有人的提交」,原来是工具栏上的「本题提交」。可见条件照旧:
* 比赛里总给(看的是这场比赛的提交),题库里要管理员或者开着提交列表。 * 比赛里总给(看的是这场比赛的提交),题库里要管理员或者开着提交列表。
@@ -72,11 +79,7 @@ function goAllSubmissions() {
// 协作中走新标签:教师端「页面即协作现场」,跳走这一页协作就结束了 // 协作中走新标签:教师端「页面即协作现场」,跳走这一页协作就结束了
// (求助会退回排队,但老师还得再接一次)。而「看看这学生都交了什么」恰好是 // (求助会退回排队,但老师还得再接一次)。而「看看这学生都交了什么」恰好是
// 协作时最常点的一个 // 协作时最常点的一个
const collabHere = if (teacherCollab.value) {
collabStore.room !== null &&
collabStore.room.problemId === problem.value?._id &&
userStore.isTeacherOrAbove
if (collabHere) {
window.open(router.resolve(target).href, "_blank") window.open(router.resolve(target).href, "_blank")
return return
} }
@@ -94,7 +97,7 @@ function goAllSubmissions() {
:trap-focus="false" :trap-focus="false"
:block-scroll="!props.to" :block-scroll="!props.to"
> >
<n-drawer-content :title="DRAWER_TITLE[lastOpened]" closable :native-scrollbar="false"> <n-drawer-content :title="drawerTitle" closable :native-scrollbar="false">
<ProblemInfo v-if="lastOpened === 'info'" /> <ProblemInfo v-if="lastOpened === 'info'" />
<ProblemReaction v-else-if="lastOpened === 'reaction'" /> <ProblemReaction v-else-if="lastOpened === 'reaction'" />
<template v-else> <template v-else>
@@ -11,6 +11,8 @@ 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 CollabBar from "./CollabBar.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,13 +86,24 @@ 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(() => // 协作条 40px + 和工具栏之间的 12px 间距,要从编辑器里让出来,不然页面底下多出一截滚动条。
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)", // 手机上:顶栏两行 + 课堂条 + 页签 + 工具栏约 236px,底部还有「运行例子 / 提交」那条约 64px
) const editorHeight = computed(() => {
const base = isDesktop.value ? 133 : 300
return `calc(100vh - ${base + (teacherCollab.value ? 52 : 0)}px)`
})
function loadCode() { function loadCode() {
codeStore.code.language = problemStore.supportedLanguage(codeStore.code.language) codeStore.code.language = problemStore.supportedLanguage(codeStore.code.language)
@@ -130,12 +143,34 @@ 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>
<template> <template>
<n-flex vertical> <n-flex vertical>
<CollabBar v-if="teacherCollab" />
<EditorToolbar :storage-key="storageKey" @change-language="changeLanguage" /> <EditorToolbar :storage-key="storageKey" @change-language="changeLanguage" />
<!-- <!--
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的, 协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
@@ -1,8 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { storeToRefs } from "pinia"
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api" import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import Pagination from "shared/components/Pagination.vue" import Pagination from "shared/components/Pagination.vue"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue" import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useCollabStore } from "shared/store/collab" import { useCollabStore } from "shared/store/collab"
@@ -12,6 +10,7 @@ import { copyToClipboard, parseTime } from "utils/functions"
import type { ProblemRank } from "@oj2/contract" import type { ProblemRank } from "@oj2/contract"
import type { LANGUAGE, SubmissionListItem } from "utils/types" import type { LANGUAGE, SubmissionListItem } from "utils/types"
import { useProblemPageContext } from "../composables/problemPageContext" import { useProblemPageContext } from "../composables/problemPageContext"
import { useTeacherCollab } from "../composables/teacherCollab"
/** /**
* 「我的提交」抽屉:这道题自己交过的每一次。 * 「我的提交」抽屉:这道题自己交过的每一次。
@@ -24,7 +23,6 @@ import { useProblemPageContext } from "../composables/problemPageContext"
const userStore = useUserStore() const userStore = useUserStore()
const codeStore = useCodeStore() const codeStore = useCodeStore()
const collabStore = useCollabStore() const collabStore = useCollabStore()
const { problem } = storeToRefs(useProblemStore())
const ctx = useProblemPageContext() const ctx = useProblemPageContext()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -36,12 +34,10 @@ const problemDisplayId = computed(() => String(route.params.problemID ?? ""))
* 协作中的教师:编辑器里是学生的代码,「放回编辑器」会经 Yjs 直接盖掉学生正在写的 * 协作中的教师:编辑器里是学生的代码,「放回编辑器」会经 Yjs 直接盖掉学生正在写的
* 那一份,所以不给(设计文档第 9 节)。 * 那一份,所以不给(设计文档第 9 节)。
*/ */
const teacherCollab = computed( const teacherCollab = useTeacherCollab()
() =>
userStore.isTeacherOrAbove && /** 协作中看的是学生的提交(设计文档第 9 节):房间里的 peerName 就是学生的用户名 */
collabStore.room !== null && const peer = computed(() => (teacherCollab.value ? (collabStore.room?.peerName ?? null) : null))
collabStore.room.problemId === problem.value?._id,
)
// ==================== 班上第几个做对的 ==================== // ==================== 班上第几个做对的 ====================
// 只在题库入口给:比赛有自己的排名,题单里整个抽屉都不出现 // 只在题库入口给:比赛有自己的排名,题单里整个抽屉都不出现
@@ -98,7 +94,9 @@ const blocked = computed(() => {
async function listSubmissions() { async function listSubmissions() {
const res = await getSubmissions({ const res = await getSubmissions({
...query, ...query,
myself: "1", ...(peer.value
? { username: peer.value, exactUsername: "1" as const }
: { myself: "1" as const }),
offset: query.limit * (query.page - 1), offset: query.limit * (query.page - 1),
problemDisplayId: problemDisplayId.value, problemDisplayId: problemDisplayId.value,
contestId: ctx.value.contestId, contestId: ctx.value.contestId,
@@ -165,7 +163,8 @@ function openDetail(id: string) {
onMounted(() => { onMounted(() => {
if (blocked.value) return if (blocked.value) return
listSubmissions() listSubmissions()
if (ctx.value.entry === "problem") { // 排名是按当前登录的人算的,协作中那是老师自己的,不给
if (ctx.value.entry === "problem" && !peer.value) {
getRankOfProblem(problemDisplayId.value) getRankOfProblem(problemDisplayId.value)
.then((res) => { .then((res) => {
rank.value = res rank.value = res
@@ -190,7 +189,11 @@ watch(query, () => {
</n-button> </n-button>
</div> </div>
<n-empty v-if="listed && !submissions.length" class="empty" description="这道题你还没有交过" /> <n-empty
v-if="listed && !submissions.length"
class="empty"
:description="peer ? '他还没有交过这道题' : '这道题你还没有交过'"
/>
<ul v-else class="list"> <ul v-else class="list">
<li v-for="(row, index) in submissions" :key="row.id" class="item"> <li v-for="(row, index) in submissions" :key="row.id" class="item">
@@ -2,6 +2,8 @@
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"
import { useTeacherCollab } from "../composables/teacherCollab"
/** /**
* 左栏「结果」页签:三段 —— 提交结果 / 运行例子 / 自己输入。 * 左栏「结果」页签:三段 —— 提交结果 / 运行例子 / 自己输入。
@@ -17,10 +19,20 @@ 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 PeerLatest = defineAsyncComponent(() => import("./PeerLatest.vue"))
const submissionStore = useSubmissionStore() const submissionStore = useSubmissionStore()
const { submission, syntaxErrorInfo, resultSegment } = storeToRefs(submissionStore) const { submission, syntaxErrorInfo, resultSegment, formattedBeforeSubmit } =
storeToRefs(submissionStore)
const { problem } = storeToRefs(useProblemStore()) const { problem } = storeToRefs(useProblemStore())
const codeStore = useCodeStore()
/** 协作中的老师自己还没交过:先摆学生最近一次交的 */
const teacherCollab = useTeacherCollab()
/** 正在画流程图:结果就是 AI 的点评,没有例子可跑 */
const drawing = computed(() => codeStore.code.language === "Flowchart")
// 试跑走 Judge0,它跑不了 SQL;流程图也没得跑。这两种只留「提交结果」 // 试跑走 Judge0,它跑不了 SQL;流程图也没得跑。这两种只留「提交结果」
const canTrial = computed(() => { const canTrial = computed(() => {
@@ -30,7 +42,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>
@@ -43,7 +56,13 @@ const canTrial = computed(() => {
<n-alert type="warning" title="代码有语法错误,还没有提交" /> <n-alert type="warning" title="代码有语法错误,还没有提交" />
<PythonErrorExplain :err-info="syntaxErrorInfo" /> <PythonErrorExplain :err-info="syntaxErrorInfo" />
</n-flex> </n-flex>
<SubmissionResult v-else-if="submission" :submission="submission" /> <template v-else-if="submission">
<p v-if="formattedBeforeSubmit" class="formatted">
提交之前自动整理了代码格式(缩进、空格),编辑器里的代码也跟着变了,按 Ctrl+Z 能撤回
</p>
<SubmissionResult :submission="submission" />
</template>
<PeerLatest v-else-if="teacherCollab" />
<n-empty <n-empty
v-else v-else
class="empty" class="empty"
@@ -66,6 +85,12 @@ const canTrial = computed(() => {
margin-bottom: 14px; margin-bottom: 14px;
} }
.formatted {
margin: 0 0 10px;
font-size: 13px;
opacity: 0.7;
}
.empty { .empty {
margin-top: 48px; margin-top: 48px;
} }
@@ -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)
@@ -0,0 +1,97 @@
<script lang="ts">
import type { ProblemRow } from "utils/types"
/**
* 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了
* 每个实例各一份
*/
const cache = new Map<string, Promise<ProblemRow[]>>()
</script>
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { getSimilarProblems } from "oj/api"
import { useProblemStore } from "oj/store/problem"
/**
* 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后)
* 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。
*/
const props = defineProps<{ title: string }>()
const { problem } = storeToRefs(useProblemStore())
const list = ref<ProblemRow[]>([])
watch(
() => problem.value?._id,
async (id) => {
list.value = []
if (!id) return
if (!cache.has(id))
cache.set(
id,
getSimilarProblems(id).catch(() => []),
)
const rows = await cache.get(id)!
if (problem.value?._id === id) list.value = rows
},
{ immediate: true },
)
// getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High
function difficultyType(difficulty: string) {
if (difficulty === "简单") return "success"
if (difficulty === "困难") return "error"
return "warning"
}
</script>
<template>
<section v-if="list.length" class="similar">
<h3 class="title">{{ props.title }}</h3>
<n-list bordered>
<n-list-item v-for="sp in list" :key="sp._id">
<n-flex align="center" justify="space-between" :wrap="false">
<router-link :to="{ name: 'problem', params: { problemID: sp._id } }" class="link">
<n-tag size="small" :bordered="false">{{ sp._id }}</n-tag>
<span class="name">{{ sp.title }}</span>
</router-link>
<n-tag v-if="sp.difficulty" size="small" :type="difficultyType(sp.difficulty)">
{{ sp.difficulty }}
</n-tag>
</n-flex>
</n-list-item>
</n-list>
</section>
</template>
<style scoped>
.similar {
margin-top: 20px;
}
.title {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px;
}
.link {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
color: inherit;
text-decoration: none;
}
.link:hover .name {
text-decoration: underline;
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -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>
@@ -19,6 +19,8 @@ import PythonErrorExplain from "./PythonErrorExplain.vue"
import RuntimeErrorExplain from "./RuntimeErrorExplain.vue" import RuntimeErrorExplain from "./RuntimeErrorExplain.vue"
import WrongAnswerExplain from "./WrongAnswerExplain.vue" import WrongAnswerExplain from "./WrongAnswerExplain.vue"
import LessonNext from "./LessonNext.vue" import LessonNext from "./LessonNext.vue"
import SimilarProblems from "./SimilarProblems.vue"
import { useLessonStore } from "oj/store/lesson"
import { useProblemPageContext } from "../composables/problemPageContext" import { useProblemPageContext } from "../composables/problemPageContext"
import { MdPreview } from "md-editor-v3" import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css" import "md-editor-v3/lib/preview.css"
@@ -26,6 +28,11 @@ import { useDark } from "@vueuse/core"
const props = defineProps<{ const props = defineProps<{
submission?: Submission submission?: Submission
/**
* 看的是别人的提交(协作中的老师看学生最近一次交的):不给 AI 提示 —— 那是按登录的人
* 自己的失败次数解锁的 —— 也不给「这节课的下一题」
*/
peer?: boolean
}>() }>()
const isDark = useDark() const isDark = useDark()
@@ -89,7 +96,22 @@ const sampleCheck = computed(() => {
*/ */
const ctx = useProblemPageContext() const ctx = useProblemPageContext()
const showLessonNext = computed( const showLessonNext = computed(
() => props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext, () =>
!props.peer && props.submission?.result === SubmissionStatus.accepted && ctx.value.lessonNext,
)
/**
* 通过了、但这道题不在这节课里(没有「下一题」可给):给几道相似题接着练。
* 原来相似题只在题面最底下,交完不会有人再滚回去看
*/
const lessonStore = useLessonStore()
const showSimilar = computed(
() =>
!props.peer &&
props.submission?.result === SubmissionStatus.accepted &&
ctx.value.similar &&
!!problemStore.problem &&
!lessonStore.includes(problemStore.problem._id),
) )
const msg = computed(() => { const msg = computed(() => {
@@ -130,7 +152,7 @@ const partialCases = computed(() => submissionPartialCases(props.submission))
// system_error 也要排掉:那是判题机自己崩了,学生代码没毛病,让 AI 去分析 // system_error 也要排掉:那是判题机自己崩了,学生代码没毛病,让 AI 去分析
// 只会瞎编一通,后端的失败计数同样不认这个状态。 // 只会瞎编一通,后端的失败计数同样不认这个状态。
const showAIHint = computed(() => { const showAIHint = computed(() => {
if (!props.submission) return false if (!props.submission || props.peer) return false
// 比赛题不给提示,和「求助」按钮一致。用 problem.contestId 而不是路由参数: // 比赛题不给提示,和「求助」按钮一致。用 problem.contestId 而不是路由参数:
// 带 contestId 的题目只可能从比赛入口进来(题库列表按 contest_id is null 过滤)。 // 带 contestId 的题目只可能从比赛入口进来(题库列表按 contest_id is null 过滤)。
if (problemStore.problem?.contestId != null) return false if (problemStore.problem?.contestId != null) return false
@@ -210,6 +232,7 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
v-if="showLessonNext && problemStore.problem" v-if="showLessonNext && problemStore.problem"
:problem-display-id="problemStore.problem._id" :problem-display-id="problemStore.problem._id"
/> />
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
<n-flex <n-flex
vertical vertical
v-if=" v-if="
@@ -5,6 +5,7 @@ import { useSubmissionStore } from "oj/store/submission"
import { getSubmitButtonState } from "./submitButtonState" import { getSubmitButtonState } from "./submitButtonState"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { SubmissionStatus } from "utils/constants"
/** /**
* 提交按钮。只把点击交给 store:提交的状态在 submission store,结果显示在左栏的 * 提交按钮。只把点击交给 store:提交的状态在 submission store,结果显示在左栏的
@@ -12,6 +13,9 @@ import { useUserStore } from "shared/store/user"
* 这个组件在编辑器的工具栏里,编辑器卸载时它也跟着卸载,所以什么都不能只存在这里。 * 这个组件在编辑器的工具栏里,编辑器卸载时它也跟着卸载,所以什么都不能只存在这里。
*/ */
/** 手机底部那条操作栏里用大号、占满半宽 */
const props = defineProps<{ size?: "small" | "medium" | "large"; block?: boolean }>()
const userStore = useUserStore() const userStore = useUserStore()
const codeStore = useCodeStore() const codeStore = useCodeStore()
const submissionStore = useSubmissionStore() const submissionStore = useSubmissionStore()
@@ -30,6 +34,14 @@ const buttonState = computed(() =>
}), }),
) )
/**
* 这道题刚交对了:「提交」降成描边按钮,让结果页签里的「下一题」是唯一的主按钮
* (设计文档 5.4)。改了代码想再交一次照样能交
*/
const justAccepted = computed(
() => submissionStore.submission?.result === SubmissionStatus.accepted,
)
function submit() { function submit() {
if (buttonState.value.disabled) return if (buttonState.value.disabled) return
submissionStore.submit({ submissionStore.submit({
@@ -41,8 +53,9 @@ function submit() {
<template> <template>
<n-button <n-button
:size="isDesktop ? 'medium' : 'small'" :size="props.size ?? (isDesktop ? 'medium' : 'small')"
type="primary" :block="props.block"
:type="justAccepted ? 'default' : 'primary'"
:disabled="buttonState.disabled" :disabled="buttonState.disabled"
@click="submit" @click="submit"
> >
@@ -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>
@@ -0,0 +1,19 @@
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import { useUserStore } from "shared/store/user"
/**
* 老师正在这道题上协作:编辑器里是**学生的**代码,「我的提交」看的是学生的,
* 跳走这一页协作就断了(所以跳转都开新标签,设计文档第 9 节)。
*/
export function useTeacherCollab() {
const userStore = useUserStore()
const collabStore = useCollabStore()
const problemStore = useProblemStore()
return computed(
() =>
userStore.isTeacherOrAbove &&
collabStore.room !== null &&
collabStore.room.problemId === problemStore.problem?._id,
)
}
+53 -6
View File
@@ -8,6 +8,8 @@ import { useMyFlowchartStore } from "shared/store/myFlowchart"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { useSubmissionStore } from "oj/store/submission" import { useSubmissionStore } from "oj/store/submission"
import { useFlowchartStore } from "oj/store/flowchart" import { useFlowchartStore } from "oj/store/flowchart"
import { useCollabStore } from "shared/store/collab"
import type { RouteLocationNormalized } from "vue-router"
// 判完之后该发生的事(烟花、点评、回题单)。静态引入:它得在第一次判完之前就挂好 // 判完之后该发生的事(烟花、点评、回题单)。静态引入:它得在第一次判完之前就挂好
import SubmissionEffects from "./components/SubmissionEffects.vue" import SubmissionEffects from "./components/SubmissionEffects.vue"
import ResultTabLabel from "./components/ResultTabLabel.vue" import ResultTabLabel from "./components/ResultTabLabel.vue"
@@ -17,6 +19,7 @@ import {
useProblemPageContext, useProblemPageContext,
type ProblemDrawer as DrawerKey, type ProblemDrawer as DrawerKey,
} from "./composables/problemPageContext" } from "./composables/problemPageContext"
import { useTeacherCollab } from "./composables/teacherCollab"
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。 // 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk。
// 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版, // 题库、比赛、题单三种入口用的是同一个编辑器:原来比赛和题单用的是另一个精简版,
@@ -27,6 +30,7 @@ const ProblemEditor = defineAsyncComponent(loadProblemEditor)
const ProblemContent = defineAsyncComponent(() => import("./components/ProblemContent.vue")) const ProblemContent = defineAsyncComponent(() => import("./components/ProblemContent.vue"))
const ProblemDrawer = defineAsyncComponent(() => import("./components/ProblemDrawer.vue")) const ProblemDrawer = defineAsyncComponent(() => import("./components/ProblemDrawer.vue"))
const ResultPane = defineAsyncComponent(() => import("./components/ResultPane.vue")) const ResultPane = defineAsyncComponent(() => import("./components/ResultPane.vue"))
const MobileActionBar = defineAsyncComponent(() => import("./components/MobileActionBar.vue"))
interface Props { interface Props {
problemID: string problemID: string
@@ -88,12 +92,17 @@ watch(
const drawer = ref<DrawerKey | null>(null) const drawer = ref<DrawerKey | null>(null)
// 协作中的老师看的是学生的提交
const teacherCollab = useTeacherCollab()
const drawerLabel = (key: DrawerKey) =>
key === "submission" && teacherCollab.value ? "他的提交" : DRAWER_TITLE[key]
function toggleDrawer(key: DrawerKey) { function toggleDrawer(key: DrawerKey) {
drawer.value = drawer.value === key ? null : key drawer.value = drawer.value === key ? null : key
} }
const drawerMenu = computed<DropdownOption[]>(() => const drawerMenu = computed<DropdownOption[]>(() =>
ctx.value.drawers.map((key) => ({ label: DRAWER_TITLE[key], key })), ctx.value.drawers.map((key) => ({ label: drawerLabel(key), key })),
) )
// 交上去、或者语法检查没过:切到「结果」,抽屉开着就先关掉。提交按钮在编辑器那边,页签归这一页管 // 交上去、或者语法检查没过:切到「结果」,抽屉开着就先关掉。提交按钮在编辑器那边,页签归这一页管
@@ -106,6 +115,35 @@ watch(
}, },
) )
/**
* 学生正在排队求助、或者老师正在帮他时换题:协作是开在这道题上的,换题就断了
* (SyncCodeEditor 的 detach),排队的求助也跟着撤掉。先问一句,别让他一点「下一题」
* 就把正在帮他的老师踢出去。只拦「离开这道题」,切页签改 `?tab=` 不算
*/
const collabStore = useCollabStore()
const dialog = useDialog()
function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNormalized) {
if (collabStore.isTeacher || collabStore.helpStatus === "idle") return true
if (to.name === from.name && to.params.problemID === from.params.problemID) return true
const active = collabStore.helpStatus === "active"
return new Promise<boolean>((resolve) => {
dialog.warning({
title: active ? "老师正在帮你看这道题" : "你正在排队等老师",
content: active
? "换题之后,老师那边的协作就断了。确定要换吗?"
: "换题之后这次举手就撤掉了,要在新的题目里重新举手。确定要换吗?",
positiveText: "换题",
negativeText: "留在这道题",
onPositiveClick: () => resolve(true),
onNegativeClick: () => resolve(false),
onClose: () => resolve(false),
onMaskClick: () => resolve(false),
})
})
}
onBeforeRouteUpdate(confirmLeavingHelp)
onBeforeRouteLeave(confirmLeavingHelp)
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数 // 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
const resultTab = () => h(ResultTabLabel) const resultTab = () => h(ResultTabLabel)
@@ -187,7 +225,7 @@ onBeforeUnmount(() => {
:secondary="drawer === key" :secondary="drawer === key"
@click="toggleDrawer(key)" @click="toggleDrawer(key)"
> >
{{ DRAWER_TITLE[key] }} {{ drawerLabel(key) }}
</n-button> </n-button>
</n-flex> </n-flex>
</div> </div>
@@ -219,14 +257,18 @@ onBeforeUnmount(() => {
</template> </template>
</n-split> </n-split>
<!-- Mobile --> <!-- Mobile:底部固定「运行例子 / 提交」,内容底下留出它的高度 -->
<template v-else> <div v-else class="mobile">
<ContextBar /> <ContextBar />
<n-tabs v-model:value="currentTab" type="segment"> <n-tabs v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="题目"> <n-tab-pane name="content" tab="题目">
<ProblemContent /> <ProblemContent />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="editor" tab="代码"> <!--
编辑器一直挂着(show,不是默认的 if):底部的「提交」在三个页签下都能点,
没切到过「代码」页签的话草稿就没读进来、按钮是灰的;切走就卸载还会丢掉撤销历史
-->
<n-tab-pane name="editor" tab="代码" display-directive="show">
<ProblemEditor /> <ProblemEditor />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="result" :tab="resultTab" display-directive="show:lazy"> <n-tab-pane name="result" :tab="resultTab" display-directive="show:lazy">
@@ -239,12 +281,17 @@ onBeforeUnmount(() => {
</template> </template>
</n-tabs> </n-tabs>
<ProblemDrawer v-model="drawer" /> <ProblemDrawer v-model="drawer" />
</template> <MobileActionBar />
</div>
</template> </template>
<n-empty v-else :description="errMsg"></n-empty> <n-empty v-else :description="errMsg"></n-empty>
</template> </template>
<style scoped> <style scoped>
.mobile {
padding-bottom: calc(72px + env(safe-area-inset-bottom));
}
.left-pane { .left-pane {
position: relative; position: relative;
height: 100%; height: 100%;
+31 -1
View File
@@ -147,10 +147,40 @@ export function explainDiff(
case "values": case "values":
return "输出的格式对了,但算出来的数不对。拿这个例子的输入,自己一步一步算一遍,看看程序在哪一步和你想的不一样。" return "输出的格式对了,但算出来的数不对。拿这个例子的输入,自己一步一步算一遍,看看程序在哪一步和你想的不一样。"
case "other": case "other":
return "对照一下你的输出和正确输出,找找从哪里开始不一样。" return otherHint(check)
} }
} }
/** 放进一句话里的值:太长的截断,免得一句提示撑满一屏 */
function quote(text: string, max = 24) {
const flat = text.trim()
return `「${flat.length > max ? `${flat.slice(0, max)}…` : flat}」`
}
/**
* 「其他」:格式和内容都不一样(占样例错误的 44%)。原来只说「对照一下,找找从哪里开始
* 不一样」,对 D / E 这种整个值不同的情况没用 —— 学生看得出不一样,看不出为什么。
* 改成把具体的值说出来,再告诉他拿这个例子的输入去走一遍程序(设计文档第 6 节)。
*/
function otherHint(check: SampleCheck) {
const expected = (check.expected ?? "").replace(/\s+$/, "")
const output = (check.output ?? "").replace(/\s+$/, "")
const no = (check.index ?? 0) + 1
const input = (check.input ?? "").trim()
const walk =
input && !input.includes("\n") && input.length <= 24
? `拿例子 ${no} 的输入${quote(input)},一行一行走一遍你的程序,看它走进了哪个分支、算出了什么。`
: `拿例子 ${no} 的输入,一行一行走一遍你的程序,看它走进了哪个分支、算出了什么。`
if (!expected.includes("\n") && !output.includes("\n")) {
return `你的程序输出了${quote(output)},正确的是${quote(expected)}。${walk}`
}
const line = firstDifferentLine(expected, output)
const want = expected.split("\n")[line] ?? ""
const got = output.split("\n")[line]
const gotText = got === undefined ? "什么都没有" : quote(got)
return `从第 ${line + 1} 行开始不一样:正确的是${quote(want)},你输出的是${gotText}。${walk}`
}
/** 第一处不一样的行号,0 起;完全一样(只差结尾空白)时为 -1 */ /** 第一处不一样的行号,0 起;完全一样(只差结尾空白)时为 -1 */
export function firstDifferentLine(expected: string, output: string) { export function firstDifferentLine(expected: string, output: string) {
const e = expected.replace(/\s+$/, "").split("\n") const e = expected.replace(/\s+$/, "").split("\n")
+19 -4
View File
@@ -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,
+117 -30
View File
@@ -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,
} }
}) })
+37 -5
View File
@@ -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,
} }
+13 -1
View File
@@ -71,6 +71,12 @@ export const useSubmissionStore = defineStore("submission", () => {
*/ */
const syntaxErrorInfo = ref("") const syntaxErrorInfo = ref("")
/**
* 这次提交之前自动排版改了代码。排版会直接改掉编辑器里的代码(缩进、空格、换行),
* 学生交完发现代码「变了样」却不知道为什么 —— 结果页签里提一句
*/
const formattedBeforeSubmit = ref(false)
const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, { const { start: startCooldown, isPending: isCooldown } = useTimeout(5000, {
controls: true, controls: true,
immediate: false, immediate: false,
@@ -84,6 +90,7 @@ export const useSubmissionStore = defineStore("submission", () => {
const problem = problemStore.problem const problem = problemStore.problem
if (!problem) return if (!problem) return
syntaxErrorInfo.value = "" syntaxErrorInfo.value = ""
formattedBeforeSubmit.value = false
// 0. 提交前自动格式化(Python 用 ruff,C/C++ 用 clang-format,SQL 用 sqlparse)。 // 0. 提交前自动格式化(Python 用 ruff,C/C++ 用 clang-format,SQL 用 sqlparse)。
// Python 在格式化之前先由服务端的 CPython 查一遍语法,有错就不提交 // Python 在格式化之前先由服务端的 CPython 查一遍语法,有错就不提交
@@ -95,7 +102,10 @@ export const useSubmissionStore = defineStore("submission", () => {
code: codeStore.code.value, code: codeStore.code.value,
language: formatLang, language: formatLang,
}) })
codeStore.setCode(res.code) if (res.code !== codeStore.code.value) {
codeStore.setCode(res.code)
formattedBeforeSubmit.value = true
}
} catch (e) { } catch (e) {
if (errorCode(e) === "syntax-error") { if (errorCode(e) === "syntax-error") {
// 仅 Python 会出现:message 是 CPython 的报错原文,交给 PythonErrorExplain 翻译 // 仅 Python 会出现:message 是 CPython 的报错原文,交给 PythonErrorExplain 翻译
@@ -151,6 +161,7 @@ export const useSubmissionStore = defineStore("submission", () => {
*/ */
function reset() { function reset() {
syntaxErrorInfo.value = "" syntaxErrorInfo.value = ""
formattedBeforeSubmit.value = false
resultSegment.value = "submit" resultSegment.value = "submit"
monitor.reset() monitor.reset()
hint.clearHint() hint.clearHint()
@@ -183,6 +194,7 @@ export const useSubmissionStore = defineStore("submission", () => {
isFormatting, isFormatting,
isSubmittingRequest, isSubmittingRequest,
syntaxErrorInfo, syntaxErrorInfo,
formattedBeforeSubmit,
isCooldown, isCooldown,
submit, submit,
reset, reset,
@@ -59,8 +59,9 @@ onUnmounted(() => {
<style scoped> <style scoped>
.toast { .toast {
position: fixed; position: fixed;
/* 右上角、顶栏下面。原来在右下角,题目页上正好和结果、点评弹窗叠在一起 */
right: 24px; right: 24px;
bottom: 24px; top: 72px;
z-index: 3000; z-index: 3000;
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -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()
} }
} }
+2
View File
@@ -266,6 +266,8 @@ export interface SubmissionListPayload {
myself?: "1" | "0" myself?: "1" | "0"
result?: string result?: string
username?: string username?: string
/** "1" = username 精确匹配(默认是「包含」)。协作中老师看某个学生的提交用 */
exactUsername?: "1"
contestId?: string contestId?: string
problemDisplayId?: string problemDisplayId?: string
language: LANGUAGE | "" language: LANGUAGE | ""
@@ -1,7 +1,7 @@
# OJ2 设计文档:题目详情页重设计 # OJ2 设计文档:题目详情页重设计
日期:2026-09-28 日期:2026-09-28
状态:**设计已定,第 14 节的 1–4 步和第 7 节已实施**。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」 状态:**第 14 节的 6 步和第 7 节都已实施**(2026-09-28)。和本文有出入的地方:手机上「⋯」只收了统计 / 点评 / 我的提交,去自测猫、复制、重置还在「代码」页签的「更多」里;协作中上下文条的跳转没有改成开新标签(老师不在课堂名单里,题库入口下这一条本来就不出现)。效果图在 claude.ai 的私有画布「题目详情页重设计」里(「机房(方案 B)」
那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。效果图表达不了的规则都写在这里, 那一页是定稿,「初稿 1280×800」那一页已作废,只留着对照)。效果图表达不了的规则都写在这里,
**实施时以本文为准**。 **实施时以本文为准**。
+2 -1
View File
@@ -162,7 +162,8 @@ export const HINT_LEVEL_COMPILE = -1
export function hintLevelLabel(level: number) { export function hintLevelLabel(level: number) {
if (level === HINT_LEVEL_COMPILE) return "编译错误" if (level === HINT_LEVEL_COMPILE) return "编译错误"
const item = HINT_LEVELS.find((entry) => entry.level === level) const item = HINT_LEVELS.find((entry) => entry.level === level)
return item ? `L${item.level} ${item.name}` : `L${level}` // 给学生看的,从 1 数起、不写「L0」:学生不知道 L 是什么,也不会从 0 数
return item ? `第 ${item.level + 1} 档 · ${item.name}` : `第 ${level + 1} 档`
} }
/** /**