fix(题目): 修掉代码审查查出的几处问题——流程图拿到的徽章不弹、点评弹窗读失败时关不掉等
- 流程图画到 A / S 拿到的题单徽章、成就原来不弹:流程图那条 WebSocket 只认评分完成 / 失败, 成就消息被丢掉了,要等下次换路由拉 pending 才出来 - 强制点评弹窗读不出点评、或者一直交不上时,「下一题 / 继续」按钮也给出来:原来弹窗关不掉, 只能按浏览器后退;拦下换题时就记上「继续」,按钮文字不会写错 - 「放回编辑器」走正常的换语言路子(画流程图时切回代码编辑器、读那门语言的草稿); 这道题已经不收的语言(0020 摘掉的 Java / JS / Go)置灰,只能复制 - 举着手时刚交对、1.5 秒内点下一题:原来「确定要换题吗」要问两遍(点评完再问一次), 现在同意过的那个地址不再问 - 课堂看板上评分中、评失败的流程图不再算「尝试过」,和学生的课堂条一个口径; 新加的流程图查询和其他查询并行,不再多等一轮数据库 - 课堂条的数据只认最后发出的那次请求:进页面那次后到的话会把刚做完的那道盖回「没做」 - 「A / S 算做完」收进契约,前后端只留一份;协作中老师的判断统一用 useTeacherCollab; 几处放错位置、说法过时的注释 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10169f5792
commit
83b6b7a81a
16 files changed
+162
-99
No files matched your search
@@ -9,13 +9,3 @@ export function gradeForScore(score: number) {
|
|||||||
if (score >= 70) return "B"
|
if (score >= 70) return "B"
|
||||||
return "C"
|
return "C"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 画到这两档算这道题做完:题单进度、课堂条、「你画的流程图」都按它
|
|
||||||
* (设计文档 2026-09-28-problem-page-redesign 第 3 节决定 4、6)
|
|
||||||
*/
|
|
||||||
export const FLOWCHART_PASS_GRADES = ["S", "A"]
|
|
||||||
|
|
||||||
export function isFlowchartPass(grade: string | null | undefined) {
|
|
||||||
return grade === "S" || grade === "A"
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FlowchartUpdate } from "@oj2/contract"
|
import { isFlowchartPass, type FlowchartUpdate } from "@oj2/contract"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
|
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
@@ -6,7 +6,7 @@ import { db, schema } from "../db"
|
|||||||
import { publishFlowchartUpdate } from "../events"
|
import { publishFlowchartUpdate } from "../events"
|
||||||
import { completeChat } from "../services/ai"
|
import { completeChat } from "../services/ai"
|
||||||
import { recordSolvedAndNotify } from "../services/problemset"
|
import { recordSolvedAndNotify } from "../services/problemset"
|
||||||
import { gradeForScore, isFlowchartPass } from "./grade"
|
import { gradeForScore } from "./grade"
|
||||||
import type { FlowchartJobData } from "./job"
|
import type { FlowchartJobData } from "./job"
|
||||||
|
|
||||||
function evaluationPrompt(problem: typeof schema.problem.$inferSelect) {
|
function evaluationPrompt(problem: typeof schema.problem.$inferSelect) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type ClassComparisonResponse,
|
type ClassComparisonResponse,
|
||||||
type ClassRankItem,
|
type ClassRankItem,
|
||||||
type ClassUserRank,
|
type ClassUserRank,
|
||||||
|
FLOWCHART_PASS_GRADES,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, desc, eq, gte, inArray, isNotNull, isNull, like, lte, sql } from "drizzle-orm"
|
import { and, desc, eq, gte, inArray, isNotNull, isNull, like, lte, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -17,7 +18,6 @@ import { Hono } from "hono"
|
|||||||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, parseBody, success } from "../http"
|
import { failure, parseBody, success } from "../http"
|
||||||
import { FLOWCHART_PASS_GRADES } from "../flowchart/grade"
|
|
||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { calendarDay, dayStart, localTime } from "../time"
|
import { calendarDay, dayStart, localTime } from "../time"
|
||||||
import { queryInteger, rounded } from "./helpers"
|
import { queryInteger, rounded } from "./helpers"
|
||||||
@@ -309,43 +309,45 @@ classroomRoutes.get("/me/class-activity", requireAuth, async (c) => {
|
|||||||
|
|
||||||
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
|
const lesson = await classLessonProblems(user.className, CLASS_ACTIVITY_LOOKBACK_DAYS)
|
||||||
const ids = lesson.problems.map((problem) => problem.id)
|
const ids = lesson.problems.map((problem) => problem.id)
|
||||||
const mine = ids.length
|
// 代码提交和流程图提交两张表,互不依赖,一起查
|
||||||
? await db
|
const [mine, drawn] = await Promise.all([
|
||||||
.select({
|
ids.length
|
||||||
problemId: schema.submission.problemId,
|
? db
|
||||||
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
|
.select({
|
||||||
})
|
problemId: schema.submission.problemId,
|
||||||
.from(schema.submission)
|
accepted: sql<boolean>`bool_or(${inArray(schema.submission.result, SOLVED_RESULTS)})`,
|
||||||
.where(
|
})
|
||||||
and(
|
.from(schema.submission)
|
||||||
eq(schema.submission.userId, user.id),
|
.where(
|
||||||
inArray(schema.submission.problemId, ids),
|
and(
|
||||||
isNull(schema.submission.contestId),
|
eq(schema.submission.userId, user.id),
|
||||||
),
|
inArray(schema.submission.problemId, ids),
|
||||||
)
|
isNull(schema.submission.contestId),
|
||||||
.groupBy(schema.submission.problemId)
|
),
|
||||||
: []
|
)
|
||||||
|
.groupBy(schema.submission.problemId)
|
||||||
|
: [],
|
||||||
|
// 流程图作业:画到 A / S 也算做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
|
||||||
|
// 有的题流程图交了几百次、代码个位数,只看代码提交的话,这些学生在课堂条上永远是「没做」
|
||||||
|
ids.length
|
||||||
|
? db
|
||||||
|
.select({
|
||||||
|
problemId: schema.flowchartSubmission.problemId,
|
||||||
|
passed: sql<boolean>`bool_or(${inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES])})`,
|
||||||
|
})
|
||||||
|
.from(schema.flowchartSubmission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.flowchartSubmission.userId, user.id),
|
||||||
|
inArray(schema.flowchartSubmission.problemId, ids),
|
||||||
|
// 只数评完了的:评分中、评失败的那次不算「做过」
|
||||||
|
eq(schema.flowchartSubmission.status, 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.flowchartSubmission.problemId)
|
||||||
|
: [],
|
||||||
|
])
|
||||||
const mineById = new Map(mine.map((row) => [row.problemId, row.accepted]))
|
const mineById = new Map(mine.map((row) => [row.problemId, row.accepted]))
|
||||||
|
|
||||||
// 流程图作业:画到 A / S 也算做完(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 6)。
|
|
||||||
// 有的题流程图交了几百次、代码个位数,只看代码提交的话,这些学生在课堂条上永远是「没做」
|
|
||||||
const drawn = ids.length
|
|
||||||
? await db
|
|
||||||
.select({
|
|
||||||
problemId: schema.flowchartSubmission.problemId,
|
|
||||||
passed: sql<boolean>`bool_or(${inArray(schema.flowchartSubmission.aiGrade, FLOWCHART_PASS_GRADES)})`,
|
|
||||||
})
|
|
||||||
.from(schema.flowchartSubmission)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.flowchartSubmission.userId, user.id),
|
|
||||||
inArray(schema.flowchartSubmission.problemId, ids),
|
|
||||||
// 只数评完了的:评分中、评失败的那次不算「做过」
|
|
||||||
eq(schema.flowchartSubmission.status, 2),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.groupBy(schema.flowchartSubmission.problemId)
|
|
||||||
: []
|
|
||||||
for (const row of drawn) {
|
for (const row of drawn) {
|
||||||
mineById.set(row.problemId, (mineById.get(row.problemId) ?? false) || row.passed)
|
mineById.set(row.problemId, (mineById.get(row.problemId) ?? false) || row.passed)
|
||||||
}
|
}
|
||||||
@@ -462,7 +464,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
.slice(-RECENT_LESSONS)
|
.slice(-RECENT_LESSONS)
|
||||||
|
|
||||||
const localDay = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')`
|
const localDay = sql<string>`to_char(${localTime(schema.submission.createTime)}, 'YYYY-MM-DD')`
|
||||||
const [cells, lastSubmits, attendance] = await Promise.all([
|
const [cells, lastSubmits, attendance, drawn] = await Promise.all([
|
||||||
ids.length && userIds.length
|
ids.length && userIds.length
|
||||||
? db
|
? db
|
||||||
.select({
|
.select({
|
||||||
@@ -515,21 +517,19 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
)
|
)
|
||||||
.groupBy(schema.submission.userId)
|
.groupBy(schema.submission.userId)
|
||||||
: [],
|
: [],
|
||||||
])
|
/**
|
||||||
/**
|
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
|
||||||
* 流程图作业:画到 A / S 也算这道题做完,和学生首页的课堂条同一个口径(/me/class-activity)。
|
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」
|
||||||
* 有的题这节课整个班都在画流程图,只数代码提交的话,看板上满屏「没开始」
|
*/
|
||||||
*/
|
|
||||||
const drawn =
|
|
||||||
ids.length && userIds.length
|
ids.length && userIds.length
|
||||||
? await db
|
? db
|
||||||
.select({
|
.select({
|
||||||
userId: schema.flowchartSubmission.userId,
|
userId: schema.flowchartSubmission.userId,
|
||||||
problemId: schema.flowchartSubmission.problemId,
|
problemId: schema.flowchartSubmission.problemId,
|
||||||
attempts: sql<number>`count(*) filter (where ${gte(schema.flowchartSubmission.createTime, start)})::int`,
|
attempts: sql<number>`count(*) filter (where ${gte(schema.flowchartSubmission.createTime, start)})::int`,
|
||||||
firstPassedAt: sql<
|
firstPassedAt: sql<
|
||||||
string | null
|
string | null
|
||||||
>`min(${schema.flowchartSubmission.createTime}) filter (where ${and(eq(schema.flowchartSubmission.status, 2), inArray(schema.flowchartSubmission.aiGrade, FLOWCHART_PASS_GRADES))})`,
|
>`min(${schema.flowchartSubmission.createTime}) filter (where ${and(eq(schema.flowchartSubmission.status, 2), inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES]))})`,
|
||||||
lastAt: sql<
|
lastAt: sql<
|
||||||
string | null
|
string | null
|
||||||
>`max(${schema.flowchartSubmission.createTime}) filter (where ${gte(schema.flowchartSubmission.createTime, start)})`,
|
>`max(${schema.flowchartSubmission.createTime}) filter (where ${gte(schema.flowchartSubmission.createTime, start)})`,
|
||||||
@@ -539,10 +539,13 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
and(
|
and(
|
||||||
inArray(schema.flowchartSubmission.userId, userIds),
|
inArray(schema.flowchartSubmission.userId, userIds),
|
||||||
inArray(schema.flowchartSubmission.problemId, ids),
|
inArray(schema.flowchartSubmission.problemId, ids),
|
||||||
|
// 只数评完了的:评分中、评失败的那次不算「做过」,和 /me/class-activity 一致
|
||||||
|
eq(schema.flowchartSubmission.status, 2),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId)
|
.groupBy(schema.flowchartSubmission.userId, schema.flowchartSubmission.problemId)
|
||||||
: []
|
: [],
|
||||||
|
])
|
||||||
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
||||||
const drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
const drawnByKey = new Map(drawn.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
||||||
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
|
const lastByUser = new Map(lastSubmits.map((row) => [row.userId, row.lastAt]))
|
||||||
|
|||||||
@@ -296,19 +296,6 @@ 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
|
||||||
@@ -327,6 +314,19 @@ export async function usernameFilter(username: string) {
|
|||||||
: frozen
|
: frozen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名**精确**匹配。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`
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 两条提交列表的题号筛选:先把题号解析成 problem.id,再按 `submission.problem_id` 筛。
|
* 两条提交列表的题号筛选:先把题号解析成 problem.id,再按 `submission.problem_id` 筛。
|
||||||
* 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能
|
* 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ export async function recordSolvedProblem(
|
|||||||
/**
|
/**
|
||||||
* 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id
|
* 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id
|
||||||
* 外键指向代码提交表,流程图提交的 id 进不去。不影响「解锁加入题单之前的提交」——
|
* 外键指向代码提交表,流程图提交的 id 进不去。不影响「解锁加入题单之前的提交」——
|
||||||
* 那道闸看的是 progress_detail(routes/submission.ts 的 problemSetJoinTimes)
|
* 那道闸看的是 progress_detail(本文件下面的 problemSetJoinTimes)
|
||||||
*/
|
*/
|
||||||
submissionId: string | null,
|
submissionId: string | null,
|
||||||
solvedAt: string,
|
solvedAt: string,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { NFlex } from "naive-ui"
|
|||||||
import SubmitCode from "./SubmitCode.vue"
|
import SubmitCode from "./SubmitCode.vue"
|
||||||
import { useProblemPageContext } from "../composables/problemPageContext"
|
import { useProblemPageContext } from "../composables/problemPageContext"
|
||||||
import { statisticsOpen, useEditorMenu } from "../composables/editorMenu"
|
import { statisticsOpen, useEditorMenu } from "../composables/editorMenu"
|
||||||
|
import { useTeacherCollab } from "../composables/teacherCollab"
|
||||||
|
|
||||||
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
|
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
|
||||||
|
|
||||||
@@ -73,14 +74,10 @@ const showHelpButton = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 教师端:协作就开在这道题上。接单后直接跳到题目页协作(原来是 CollabModal 弹框),
|
* 教师正在这道题上协作:编辑器里是学生的代码。协作状态和「结束协作」在工具栏上面那条
|
||||||
* 所以状态和「结束协作」得摆在题目页的工具栏上 —— 也只有这一个按钮能结束,
|
* 协作条里(CollabBar);工具栏这边禁掉语言选择、不给「写代码 / 画流程图」切换
|
||||||
* 不像弹框那样按一下 Esc 就把协作关掉了。
|
|
||||||
*/
|
*/
|
||||||
const collabHere = computed(
|
const showCollabBar = useTeacherCollab()
|
||||||
() => collabStore.room !== null && collabStore.room.problemId === problem.value?._id,
|
|
||||||
)
|
|
||||||
const showCollabBar = computed(() => collabHere.value && userStore.isTeacherOrAbove)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏
|
* 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const suggestions = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
function gradeType(grade: string) {
|
function gradeType(grade: string) {
|
||||||
if (grade === "S" || grade === "A") return "success"
|
if (isFlowchartPass(grade)) return "success"
|
||||||
if (grade === "B") return "warning"
|
if (grade === "B") return "warning"
|
||||||
return "error"
|
return "error"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 { useDraftKey } from "../composables/draftKey"
|
import { useDraftKey } from "../composables/draftKey"
|
||||||
|
import { useTeacherCollab } from "../composables/teacherCollab"
|
||||||
import CollabBar from "./CollabBar.vue"
|
import CollabBar from "./CollabBar.vue"
|
||||||
import { useFlowchartStore } from "oj/store/flowchart"
|
import { useFlowchartStore } from "oj/store/flowchart"
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ const collabHere = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/** 协作中的教师:编辑器里是学生的代码,不是他自己的 */
|
/** 协作中的教师:编辑器里是学生的代码,不是他自己的 */
|
||||||
const teacherCollab = computed(() => collabHere.value && collabStore.isTeacher)
|
const teacherCollab = useTeacherCollab()
|
||||||
|
|
||||||
/** 教师接单前自己选的语言,协作结束后连同草稿一起还原 */
|
/** 教师接单前自己选的语言,协作结束后连同草稿一起还原 */
|
||||||
let teacherLanguageBefore: LANGUAGE | null = null
|
let teacherLanguageBefore: LANGUAGE | null = null
|
||||||
|
|||||||
@@ -347,7 +347,14 @@ function sectorAriaLabel(index: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="submitFailed" class="error">没记上,再点一次</p>
|
<p v-if="submitFailed" class="error">没记上,再点一次</p>
|
||||||
<div v-if="state === 'results' && $slots.after" class="after">
|
<!--
|
||||||
|
强制弹窗关不掉,这个插槽(「下一题 / 继续」)是唯一的出口:读不出点评、交点评失败时也得给,
|
||||||
|
不能把学生关在弹窗里(设计文档第 7 节:接口出错时不能把学生卡住)
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
v-if="(state === 'results' || state === 'failed' || submitFailed) && $slots.after"
|
||||||
|
class="after"
|
||||||
|
>
|
||||||
<slot name="after" />
|
<slot name="after" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
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"
|
||||||
@@ -22,6 +23,7 @@ import { useTeacherCollab } from "../composables/teacherCollab"
|
|||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const codeStore = useCodeStore()
|
const codeStore = useCodeStore()
|
||||||
|
const problemStore = useProblemStore()
|
||||||
const collabStore = useCollabStore()
|
const collabStore = useCollabStore()
|
||||||
const ctx = useProblemPageContext()
|
const ctx = useProblemPageContext()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -139,11 +141,22 @@ function loadedOf(id: string): Loaded | null {
|
|||||||
return value && typeof value === "object" ? value : null
|
return value && typeof value === "object" ? value : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function putBack(id: string) {
|
/** 这道题现在还收不收那次提交的语言(0020 从题目里摘掉了 Java / JS / Go,老提交还在) */
|
||||||
|
function canPutBack(id: string) {
|
||||||
const loaded = loadedOf(id)
|
const loaded = loadedOf(id)
|
||||||
if (!loaded) return
|
return !!loaded && problemStore.codeLanguages.some((language) => language === loaded.language)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putBack(id: string) {
|
||||||
|
const loaded = loadedOf(id)
|
||||||
|
if (!loaded || !canPutBack(id)) return
|
||||||
|
// 语言不一样就先按正常的路子切过去(存语言偏好、读那门语言的草稿;画流程图时切回代码编辑器),
|
||||||
|
// 等编辑器切完再放代码 —— 直接改 codeStore 的语言会绕过这些,草稿存错地方
|
||||||
|
if (codeStore.code.language !== loaded.language) {
|
||||||
|
problemStore.switchLanguage(loaded.language)
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
// 同一个编辑器里 dispatch 一次整段替换,CodeMirror 的历史记着它,Ctrl+Z 能撤回
|
// 同一个编辑器里 dispatch 一次整段替换,CodeMirror 的历史记着它,Ctrl+Z 能撤回
|
||||||
codeStore.setLanguage(loaded.language)
|
|
||||||
codeStore.setCode(loaded.code)
|
codeStore.setCode(loaded.code)
|
||||||
message.success("已放回编辑器,按 Ctrl+Z 可以撤回")
|
message.success("已放回编辑器,按 Ctrl+Z 可以撤回")
|
||||||
}
|
}
|
||||||
@@ -233,6 +246,12 @@ watch(query, () => {
|
|||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
secondary
|
secondary
|
||||||
|
:disabled="!canPutBack(row.id)"
|
||||||
|
:title="
|
||||||
|
canPutBack(row.id)
|
||||||
|
? undefined
|
||||||
|
: `这道题现在不收 ${LANGUAGE_SHOW_VALUE[loadedOf(row.id)!.language]} 了,只能复制`
|
||||||
|
"
|
||||||
@click="putBack(row.id)"
|
@click="putBack(row.id)"
|
||||||
>
|
>
|
||||||
放回编辑器
|
放回编辑器
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ const commentPanel = ref(false)
|
|||||||
const reviewOwed = ref(false)
|
const reviewOwed = ref(false)
|
||||||
let reviewChecking = false
|
let reviewChecking = false
|
||||||
let navigationAfterReview: string | null = null
|
let navigationAfterReview: string | null = null
|
||||||
|
// navigationAfterReview 是普通变量(守卫里改,不需要触发渲染),弹窗按钮的文案(「继续」)
|
||||||
|
// 要一个响应式的影子:拦下的那一刻就记上,点评读不出来、交不上时按钮也得写对
|
||||||
|
const navigationAfterReviewShown = ref(false)
|
||||||
|
|
||||||
async function openReview() {
|
async function openReview() {
|
||||||
if (reviewChecking) return
|
if (reviewChecking) return
|
||||||
@@ -67,6 +70,7 @@ function settleReview() {
|
|||||||
reviewOwed.value = false
|
reviewOwed.value = false
|
||||||
const target = navigationAfterReview
|
const target = navigationAfterReview
|
||||||
navigationAfterReview = null
|
navigationAfterReview = null
|
||||||
|
navigationAfterReviewShown.value = false
|
||||||
if (target) router.push(target)
|
if (target) router.push(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,9 +90,6 @@ const reviewed = ref(false)
|
|||||||
const continueButton = useTemplateRef<{ $el: HTMLElement }>("continueButton")
|
const continueButton = useTemplateRef<{ $el: HTMLElement }>("continueButton")
|
||||||
const lessonStore = useLessonStore()
|
const lessonStore = useLessonStore()
|
||||||
|
|
||||||
// navigationAfterReview 是普通变量(守卫里改,不需要触发渲染),按钮文案要一个响应式的影子
|
|
||||||
const navigationAfterReviewShown = ref(false)
|
|
||||||
|
|
||||||
/** 题目名中位 7 字、最长 33 字,按钮里放不下那么长的 */
|
/** 题目名中位 7 字、最长 33 字,按钮里放不下那么长的 */
|
||||||
const shortTitle = (title: string) => (title.length > 14 ? `${title.slice(0, 13)}…` : title)
|
const shortTitle = (title: string) => (title.length > 14 ? `${title.slice(0, 13)}…` : title)
|
||||||
|
|
||||||
@@ -106,7 +107,6 @@ const continueAction = computed(() => {
|
|||||||
|
|
||||||
async function onReviewed() {
|
async function onReviewed() {
|
||||||
reviewed.value = true
|
reviewed.value = true
|
||||||
navigationAfterReviewShown.value = navigationAfterReview !== null
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
continueButton.value?.$el.focus()
|
continueButton.value?.$el.focus()
|
||||||
}
|
}
|
||||||
@@ -132,6 +132,7 @@ function holdForReview(to: RouteLocationNormalized, from: RouteLocationNormalize
|
|||||||
if (!reviewOwed.value || commentPanel.value) return true
|
if (!reviewOwed.value || commentPanel.value) return true
|
||||||
if (to.name === from.name && to.params.problemID === from.params.problemID) return true
|
if (to.name === from.name && to.params.problemID === from.params.problemID) return true
|
||||||
navigationAfterReview = to.fullPath
|
navigationAfterReview = to.fullPath
|
||||||
|
navigationAfterReviewShown.value = true
|
||||||
cancelCommentPanel()
|
cancelCommentPanel()
|
||||||
openReview()
|
openReview()
|
||||||
return false
|
return false
|
||||||
@@ -167,6 +168,7 @@ watch(
|
|||||||
cancelCommentPanel()
|
cancelCommentPanel()
|
||||||
reviewOwed.value = false
|
reviewOwed.value = false
|
||||||
navigationAfterReview = null
|
navigationAfterReview = null
|
||||||
|
navigationAfterReviewShown.value = false
|
||||||
cancelGoToProblemSet()
|
cancelGoToProblemSet()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -140,9 +140,19 @@ watch(
|
|||||||
*/
|
*/
|
||||||
const collabStore = useCollabStore()
|
const collabStore = useCollabStore()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
/**
|
||||||
|
* 已经同意过要去的地方。通过之后 1.5 秒内点「下一题」:这里先问一次,接着被强制点评拦下
|
||||||
|
* (SubmissionEffects 的 holdForReview),评完再 push 同一个地址 —— 不记着的话会再问一遍,
|
||||||
|
* 第二次点「留在这道题」就成了同意换题之后又被留下
|
||||||
|
*/
|
||||||
|
let approvedTarget: string | null = null
|
||||||
function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNormalized) {
|
function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNormalized) {
|
||||||
if (collabStore.isTeacher || collabStore.helpStatus === "idle") return true
|
if (collabStore.isTeacher || collabStore.helpStatus === "idle") return true
|
||||||
if (to.name === from.name && to.params.problemID === from.params.problemID) return true
|
if (to.name === from.name && to.params.problemID === from.params.problemID) return true
|
||||||
|
if (approvedTarget === to.fullPath) {
|
||||||
|
approvedTarget = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
const active = collabStore.helpStatus === "active"
|
const active = collabStore.helpStatus === "active"
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
@@ -152,7 +162,10 @@ function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNorm
|
|||||||
: "换题之后这次举手就撤掉了,要在新的题目里重新举手。确定要换吗?",
|
: "换题之后这次举手就撤掉了,要在新的题目里重新举手。确定要换吗?",
|
||||||
positiveText: "换题",
|
positiveText: "换题",
|
||||||
negativeText: "留在这道题",
|
negativeText: "留在这道题",
|
||||||
onPositiveClick: () => resolve(true),
|
onPositiveClick: () => {
|
||||||
|
approvedTarget = to.fullPath
|
||||||
|
resolve(true)
|
||||||
|
},
|
||||||
onNegativeClick: () => resolve(false),
|
onNegativeClick: () => resolve(false),
|
||||||
onClose: () => resolve(false),
|
onClose: () => resolve(false),
|
||||||
onMaskClick: () => resolve(false),
|
onMaskClick: () => resolve(false),
|
||||||
@@ -161,6 +174,13 @@ function confirmLeavingHelp(to: RouteLocationNormalized, from: RouteLocationNorm
|
|||||||
}
|
}
|
||||||
onBeforeRouteUpdate(confirmLeavingHelp)
|
onBeforeRouteUpdate(confirmLeavingHelp)
|
||||||
onBeforeRouteLeave(confirmLeavingHelp)
|
onBeforeRouteLeave(confirmLeavingHelp)
|
||||||
|
// 到了就作废:没被点评拦下、一次就换过去的,不能留着让下次去同一道题时不再问
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
(path) => {
|
||||||
|
if (path === approvedTarget) approvedTarget = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
|
// 结果页签的标题带状态图标(ResultTabLabel),n-tab-pane 的 tab 接受渲染函数
|
||||||
const resultTab = () => h(ResultTabLabel)
|
const resultTab = () => h(ResultTabLabel)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 type { FlowchartScores, FlowchartSubmission } from "@oj2/contract"
|
import { isFlowchartPass, type FlowchartScores, type FlowchartSubmission } from "@oj2/contract"
|
||||||
import type { Edge, Node } from "@vue-flow/core"
|
import type { Edge, Node } from "@vue-flow/core"
|
||||||
import { getFlowchartScores, getFlowchartSubmission, submitFlowchart } from "oj/api"
|
import { getFlowchartScores, getFlowchartSubmission, submitFlowchart } from "oj/api"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
@@ -9,6 +9,8 @@ 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"
|
||||||
|
import { useAchievementStore } from "shared/store/achievement"
|
||||||
|
import type { QueuedAchievement } from "utils/types"
|
||||||
|
|
||||||
// store 里没有组件上下文,useMessage() 拿不到,和 utils/api.ts 一样用独立的一份
|
// store 里没有组件上下文,useMessage() 拿不到,和 utils/api.ts 一样用独立的一份
|
||||||
const { message } = createDiscreteApi(["message"])
|
const { message } = createDiscreteApi(["message"])
|
||||||
@@ -26,10 +28,8 @@ export interface FlowchartEditorInstance {
|
|||||||
setFlowchartData: (data: { nodes: Node[]; edges: Edge[] }) => void
|
setFlowchartData: (data: { nodes: Node[]; edges: Edge[] }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 画到这两档算这道题做完(后端 flowchart/grade.ts 的 FLOWCHART_PASS_GRADES) */
|
// 「A / S 算做完」的规则在契约里,前后端一份;这里转出去,组件照旧从 flowchart store 引
|
||||||
export function isFlowchartPass(grade: string | null | undefined) {
|
export { isFlowchartPass }
|
||||||
return grade === "S" || grade === "A"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这一页上最近一次交的图走到哪了。idle = 这一页还没交过(历史分数照样有)
|
* 这一页上最近一次交的图走到哪了。idle = 这一页还没交过(历史分数照样有)
|
||||||
@@ -175,6 +175,14 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
|
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
|
||||||
|
// 流程图画到 A/S 算完成之后,后端会在同一条用户通道上推题单徽章、成就(flowchart/run.ts 的
|
||||||
|
// recordSolvedAndNotify)。原来这里只认评分那两种,交代码的监视器又不一定开着,要等下次
|
||||||
|
// 换路由拉 pending 才弹。队列按 id 去重,两条连接各收一遍也只弹一次
|
||||||
|
const frame = data as unknown as { type: string; achievements?: QueuedAchievement[] }
|
||||||
|
if (frame.type === "achievement_unlocked") {
|
||||||
|
useAchievementStore().enqueue(frame.achievements ?? [])
|
||||||
|
return
|
||||||
|
}
|
||||||
if (data.type === "flowchart_evaluation_completed") {
|
if (data.type === "flowchart_evaluation_completed") {
|
||||||
settle(data.submissionId, {
|
settle(data.submissionId, {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -16,19 +16,27 @@ export const useLessonStore = defineStore("lesson", () => {
|
|||||||
const activity = ref<ClassActivity | null>(null)
|
const activity = ref<ClassActivity | null>(null)
|
||||||
let loadedAt = 0
|
let loadedAt = 0
|
||||||
let loading: Promise<void> | null = null
|
let loading: Promise<void> | null = null
|
||||||
|
/**
|
||||||
|
* 只认最后发出去的那次。进页面那次还在路上时学生就交对了:先就地标成做完、再强制重拉 ——
|
||||||
|
* 先发的那次要是后到,会把「做完」盖回去,「下一题」又指回刚做完的这道
|
||||||
|
*/
|
||||||
|
let sequence = 0
|
||||||
|
|
||||||
/** 没拉过或者旧了才拉;force 用在刚通过一道题之后 */
|
/** 没拉过或者旧了才拉;force 用在刚通过一道题之后 */
|
||||||
function load(force = false) {
|
function load(force = false) {
|
||||||
if (!force && activity.value && Date.now() - loadedAt < STALE_MS) return loading
|
if (!force && activity.value && Date.now() - loadedAt < STALE_MS) return loading
|
||||||
|
if (!force && loading) return loading
|
||||||
|
const mine = ++sequence
|
||||||
loading = getClassActivity()
|
loading = getClassActivity()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
if (mine !== sequence) return
|
||||||
activity.value = res
|
activity.value = res
|
||||||
loadedAt = Date.now()
|
loadedAt = Date.now()
|
||||||
})
|
})
|
||||||
// 拉不到就没有课堂条,不影响做题
|
// 拉不到就没有课堂条,不影响做题
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
loading = null
|
if (mine === sequence) loading = null
|
||||||
})
|
})
|
||||||
return loading
|
return loading
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ const { isDark, toggleDark } = useDarkTransition()
|
|||||||
*/
|
*/
|
||||||
const pendingHelpCount = computed(() => (collabStore.isTeacher ? collabStore.pendingCount : 0))
|
const pendingHelpCount = computed(() => (collabStore.isTeacher ? collabStore.pendingCount : 0))
|
||||||
|
|
||||||
// 从 store 中获取屏幕模式状态
|
|
||||||
|
|
||||||
const names = [
|
const names = [
|
||||||
"man-with-chinese-cap-1",
|
"man-with-chinese-cap-1",
|
||||||
"cat-face",
|
"cat-face",
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ export const flowchartDetailSchema = z.object({
|
|||||||
count: z.number().int(),
|
count: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 流程图画到这两档算这道题做完:题单进度、课堂条、课堂看板、「你画的流程图」、结果页签的 ✓
|
||||||
|
* 都按它(设计文档 2026-09-28-problem-page-redesign 第 3 节决定 4、6)。前后端只有这一份
|
||||||
|
*/
|
||||||
|
export const FLOWCHART_PASS_GRADES = ["S", "A"] as const
|
||||||
|
|
||||||
|
export function isFlowchartPass(grade: string | null | undefined) {
|
||||||
|
return (FLOWCHART_PASS_GRADES as readonly string[]).includes(grade ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
/** 自己在一道题上评完的历次分数,早的在前;hidden = 被题单闸门藏起来的次数 */
|
/** 自己在一道题上评完的历次分数,早的在前;hidden = 被题单闸门藏起来的次数 */
|
||||||
export const flowchartScoresSchema = z.object({
|
export const flowchartScoresSchema = z.object({
|
||||||
scores: z.array(
|
scores: z.array(
|
||||||
|
|||||||
Reference in new issue
Block a user