From a814776fb9a932ecdbcc441409eeaa0e3844d4d5 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 16 Oct 2026 12:28:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=95=99=E7=A8=8B):=20=E6=94=B9=E7=BB=83?= =?UTF-8?q?=E4=B8=80=E7=BB=83=E6=97=B6=E5=8F=AF=E4=BB=A5=E9=A1=BA=E6=89=8B?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E5=AD=A6=E7=94=9F=E7=9A=84=E4=BD=9C=E7=AD=94?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=94=B9=E4=BA=86=E7=AD=94=E6=A1=88=EF=BC=8C?= =?UTF-8?q?=E6=8C=89=E6=97=A7=E7=AD=94=E6=A1=88=E5=81=9A=E5=AF=B9=E7=9A=84?= =?UTF-8?q?=E4=BA=BA=E4=B8=8D=E5=86=8D=E7=AE=97=E5=81=9A=E5=AF=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 练一练是原地改的,作答记录跟着题走:改了答案或选项,按旧答案做对的人仍然显示做对、 「最后错在 选了 B」说的也是旧选项。编辑框底下多一个「同时清空学生的作答(N 人做过)」, 没人做过时不出现;勾上保存,学生那边这道题回到没做。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/admin/tutorial.ts | 64 ++++++++++++++----- apps/web/src/admin/api.ts | 2 + .../tutorial/components/ExerciseManager.vue | 17 ++++- packages/contract/src/admin.ts | 18 ++++-- 4 files changed, 79 insertions(+), 22 deletions(-) diff --git a/apps/api/src/routes/admin/tutorial.ts b/apps/api/src/routes/admin/tutorial.ts index 6db977f..fe62267 100644 --- a/apps/api/src/routes/admin/tutorial.ts +++ b/apps/api/src/routes/admin/tutorial.ts @@ -14,7 +14,19 @@ import { type AdminTutorialListItem, type TutorialProblemCandidate, } from "@oj2/contract" -import { and, asc, countDistinct, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm" +import { + and, + asc, + count, + countDistinct, + desc, + eq, + ilike, + inArray, + isNull, + or, + sql, +} from "drizzle-orm" import { Hono } from "hono" import { requireSuperAdmin, type AppEnv } from "../../auth/middleware" @@ -304,24 +316,32 @@ adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => { // ---------------------------------------------------------------- 练习 -function serializeExercise(row: typeof schema.exercise.$inferSelect) { +function serializeExercise(row: typeof schema.exercise.$inferSelect, attemptUsers: number) { return { id: row.id, type: row.type, data: asRecord(row.data), order: row.order, + attemptUsers, } satisfies AdminExercise } +const attemptUsersColumn = sql`( + select count(*) from "exercise_attempt" ea where ea."exercise_id" = "exercise"."id" +)`.mapWith(Number) + // 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数, // 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误 adminTutorialRoutes.get("/tutorials/:id/exercises", requireSuperAdmin, async (c) => { const rows = await db - .select() + .select({ exercise: schema.exercise, attemptUsers: attemptUsersColumn }) .from(schema.exercise) .where(eq(schema.exercise.tutorialId, queryInteger(c.req.param("id"), 0, { min: 1 }))) .orderBy(asc(schema.exercise.order), asc(schema.exercise.id)) - return success(c, rows.map(serializeExercise)) + return success( + c, + rows.map((row) => serializeExercise(row.exercise, row.attemptUsers)), + ) }) adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => { @@ -345,7 +365,7 @@ adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => { createdAt: new Date().toISOString(), }) .returning() - return success(c, serializeExercise(created!), 201) + return success(c, serializeExercise(created!, 0), 201) }) adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => { @@ -353,17 +373,29 @@ adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => { if (!parsed.success) return parsed.response const dataError = exerciseDataError(parsed.data.type, parsed.data.data) if (dataError) return failure(c, 400, "invalid-exercise", dataError) - const [updated] = await db - .update(schema.exercise) - .set({ - type: parsed.data.type, - data: parsed.data.data, - order: parsed.data.order, - }) - .where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 }))) - .returning() - if (!updated) return failure(c, 404, "exercise-not-found", "Exercise does not exist") - return success(c, serializeExercise(updated)) + const id = queryInteger(c.req.param("id"), 0, { min: 1 }) + const result = await db.transaction(async (tx) => { + const [updated] = await tx + .update(schema.exercise) + .set({ + type: parsed.data.type, + data: parsed.data.data, + order: parsed.data.order, + }) + .where(eq(schema.exercise.id, id)) + .returning() + if (!updated) return null + if (parsed.data.resetAttempts) { + await tx.delete(schema.exerciseAttempt).where(eq(schema.exerciseAttempt.exerciseId, id)) + } + const [attempted] = await tx + .select({ value: count() }) + .from(schema.exerciseAttempt) + .where(eq(schema.exerciseAttempt.exerciseId, id)) + return serializeExercise(updated, attempted?.value ?? 0) + }) + if (!result) return failure(c, 404, "exercise-not-found", "Exercise does not exist") + return success(c, result) }) adminTutorialRoutes.delete("/exercises/:id", requireSuperAdmin, async (c) => { diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 78a4ec9..8504aab 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -521,11 +521,13 @@ export async function updateExercise(data: { type: ExerciseType data: object order: number + resetAttempts: boolean }) { return api.put(`admin/exercises/${data.id}`, { type: data.type, data: data.data, order: data.order, + resetAttempts: data.resetAttempts, }) } diff --git a/apps/web/src/admin/tutorial/components/ExerciseManager.vue b/apps/web/src/admin/tutorial/components/ExerciseManager.vue index be563e9..50b6bd5 100644 --- a/apps/web/src/admin/tutorial/components/ExerciseManager.vue +++ b/apps/web/src/admin/tutorial/components/ExerciseManager.vue @@ -31,6 +31,9 @@ const dialog = useDialog() const exercises = ref([]) const showForm = ref(false) const editingId = ref(null) +/** 编辑的这道有几个人做过;为 0 时不问要不要清空 */ +const editingAttempts = ref(0) +const resetAttempts = ref(false) const formType = ref("mcq") const formOrder = ref(0) @@ -107,6 +110,8 @@ function resetForms() { function openCreate() { editingId.value = null + editingAttempts.value = 0 + resetAttempts.value = false formType.value = "mcq" formOrder.value = exercises.value.length resetForms() @@ -115,6 +120,8 @@ function openCreate() { function openEdit(ex: Exercise) { editingId.value = ex.id + editingAttempts.value = ex.attemptUsers + resetAttempts.value = false formType.value = ex.type formOrder.value = ex.order resetForms() @@ -318,8 +325,9 @@ async function save() { type: formType.value, data, order: formOrder.value, + resetAttempts: resetAttempts.value, }) - message.success("练习题已更新") + message.success(resetAttempts.value ? "练习题已更新,学生的作答已清空" : "练习题已更新") } else { const created = await createExercise({ tutorialId: props.tutorialId, @@ -619,7 +627,12 @@ function typeName(type: ExerciseType) {