练一练是原地改的,作答记录跟着题走:改了答案或选项,按旧答案做对的人仍然显示做对、 「最后错在 选了 B」说的也是旧选项。编辑框底下多一个「同时清空学生的作答(N 人做过)」, 没人做过时不出现;勾上保存,学生那边这道题回到没做。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ebb23cdac
commit
a814776fb9
4 files changed
+69
-12
No files matched your search
@@ -14,7 +14,19 @@ import {
|
|||||||
type AdminTutorialListItem,
|
type AdminTutorialListItem,
|
||||||
type TutorialProblemCandidate,
|
type TutorialProblemCandidate,
|
||||||
} from "@oj2/contract"
|
} 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 { Hono } from "hono"
|
||||||
|
|
||||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
data: asRecord(row.data),
|
data: asRecord(row.data),
|
||||||
order: row.order,
|
order: row.order,
|
||||||
|
attemptUsers,
|
||||||
} satisfies AdminExercise
|
} satisfies AdminExercise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const attemptUsersColumn = sql<number>`(
|
||||||
|
select count(*) from "exercise_attempt" ea where ea."exercise_id" = "exercise"."id"
|
||||||
|
)`.mapWith(Number)
|
||||||
|
|
||||||
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数,
|
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数,
|
||||||
// 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误
|
// 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误
|
||||||
adminTutorialRoutes.get("/tutorials/:id/exercises", requireSuperAdmin, async (c) => {
|
adminTutorialRoutes.get("/tutorials/:id/exercises", requireSuperAdmin, async (c) => {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select({ exercise: schema.exercise, attemptUsers: attemptUsersColumn })
|
||||||
.from(schema.exercise)
|
.from(schema.exercise)
|
||||||
.where(eq(schema.exercise.tutorialId, queryInteger(c.req.param("id"), 0, { min: 1 })))
|
.where(eq(schema.exercise.tutorialId, queryInteger(c.req.param("id"), 0, { min: 1 })))
|
||||||
.orderBy(asc(schema.exercise.order), asc(schema.exercise.id))
|
.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) => {
|
adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
|
||||||
@@ -345,7 +365,7 @@ adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
return success(c, serializeExercise(created!), 201)
|
return success(c, serializeExercise(created!, 0), 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
|
adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
|
||||||
@@ -353,17 +373,29 @@ adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
|
|||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
|
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
|
||||||
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
|
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
|
||||||
const [updated] = await db
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
const [updated] = await tx
|
||||||
.update(schema.exercise)
|
.update(schema.exercise)
|
||||||
.set({
|
.set({
|
||||||
type: parsed.data.type,
|
type: parsed.data.type,
|
||||||
data: parsed.data.data,
|
data: parsed.data.data,
|
||||||
order: parsed.data.order,
|
order: parsed.data.order,
|
||||||
})
|
})
|
||||||
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
|
.where(eq(schema.exercise.id, id))
|
||||||
.returning()
|
.returning()
|
||||||
if (!updated) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
|
if (!updated) return null
|
||||||
return success(c, serializeExercise(updated))
|
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) => {
|
adminTutorialRoutes.delete("/exercises/:id", requireSuperAdmin, async (c) => {
|
||||||
|
|||||||
@@ -521,11 +521,13 @@ export async function updateExercise(data: {
|
|||||||
type: ExerciseType
|
type: ExerciseType
|
||||||
data: object
|
data: object
|
||||||
order: number
|
order: number
|
||||||
|
resetAttempts: boolean
|
||||||
}) {
|
}) {
|
||||||
return api.put<Exercise>(`admin/exercises/${data.id}`, {
|
return api.put<Exercise>(`admin/exercises/${data.id}`, {
|
||||||
type: data.type,
|
type: data.type,
|
||||||
data: data.data,
|
data: data.data,
|
||||||
order: data.order,
|
order: data.order,
|
||||||
|
resetAttempts: data.resetAttempts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ const dialog = useDialog()
|
|||||||
const exercises = ref<Exercise[]>([])
|
const exercises = ref<Exercise[]>([])
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
|
/** 编辑的这道有几个人做过;为 0 时不问要不要清空 */
|
||||||
|
const editingAttempts = ref(0)
|
||||||
|
const resetAttempts = ref(false)
|
||||||
const formType = ref<ExerciseType>("mcq")
|
const formType = ref<ExerciseType>("mcq")
|
||||||
const formOrder = ref(0)
|
const formOrder = ref(0)
|
||||||
|
|
||||||
@@ -107,6 +110,8 @@ function resetForms() {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
|
editingAttempts.value = 0
|
||||||
|
resetAttempts.value = false
|
||||||
formType.value = "mcq"
|
formType.value = "mcq"
|
||||||
formOrder.value = exercises.value.length
|
formOrder.value = exercises.value.length
|
||||||
resetForms()
|
resetForms()
|
||||||
@@ -115,6 +120,8 @@ function openCreate() {
|
|||||||
|
|
||||||
function openEdit(ex: Exercise) {
|
function openEdit(ex: Exercise) {
|
||||||
editingId.value = ex.id
|
editingId.value = ex.id
|
||||||
|
editingAttempts.value = ex.attemptUsers
|
||||||
|
resetAttempts.value = false
|
||||||
formType.value = ex.type
|
formType.value = ex.type
|
||||||
formOrder.value = ex.order
|
formOrder.value = ex.order
|
||||||
resetForms()
|
resetForms()
|
||||||
@@ -318,8 +325,9 @@ async function save() {
|
|||||||
type: formType.value,
|
type: formType.value,
|
||||||
data,
|
data,
|
||||||
order: formOrder.value,
|
order: formOrder.value,
|
||||||
|
resetAttempts: resetAttempts.value,
|
||||||
})
|
})
|
||||||
message.success("练习题已更新")
|
message.success(resetAttempts.value ? "练习题已更新,学生的作答已清空" : "练习题已更新")
|
||||||
} else {
|
} else {
|
||||||
const created = await createExercise({
|
const created = await createExercise({
|
||||||
tutorialId: props.tutorialId,
|
tutorialId: props.tutorialId,
|
||||||
@@ -619,7 +627,12 @@ function typeName(type: ExerciseType) {
|
|||||||
</n-form>
|
</n-form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<n-flex justify="end" :size="8">
|
<n-flex align="center" :size="8">
|
||||||
|
<!-- 改了答案或选项时勾上:按旧答案做对的人不该还算做对 -->
|
||||||
|
<n-checkbox v-if="editingId && editingAttempts" v-model:checked="resetAttempts">
|
||||||
|
同时清空学生的作答({{ editingAttempts }} 人做过)
|
||||||
|
</n-checkbox>
|
||||||
|
<span style="flex: 1" />
|
||||||
<n-button @click="showForm = false">取消</n-button>
|
<n-button @click="showForm = false">取消</n-button>
|
||||||
<n-button type="primary" @click="save">保存</n-button>
|
<n-button type="primary" @click="save">保存</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
|||||||
@@ -167,6 +167,8 @@ export const adminExerciseSchema = z.object({
|
|||||||
type: exerciseTypeSchema,
|
type: exerciseTypeSchema,
|
||||||
data: z.record(z.string(), z.unknown()),
|
data: z.record(z.string(), z.unknown()),
|
||||||
order: z.number().int(),
|
order: z.number().int(),
|
||||||
|
/** 几个人做过(有作答记录)。编辑时据此决定要不要问「清空作答」 */
|
||||||
|
attemptUsers: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createExerciseRequestSchema = z.object({
|
export const createExerciseRequestSchema = z.object({
|
||||||
@@ -176,10 +178,18 @@ export const createExerciseRequestSchema = z.object({
|
|||||||
order: z.number().int().default(0),
|
order: z.number().int().default(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateExerciseRequestSchema = createExerciseRequestSchema.omit({
|
export const updateExerciseRequestSchema = createExerciseRequestSchema
|
||||||
|
.omit({
|
||||||
// 练习不支持改挂到别的教程下 —— 旧 EditExerciseSerializer 也没有 tutorial_id
|
// 练习不支持改挂到别的教程下 —— 旧 EditExerciseSerializer 也没有 tutorial_id
|
||||||
tutorialId: true,
|
tutorialId: true,
|
||||||
})
|
})
|
||||||
|
.extend({
|
||||||
|
/**
|
||||||
|
* 同时清空这道题所有人的作答。练习是原地改的,作答记录跟着题 id 走:
|
||||||
|
* 改了答案或选项,按旧答案做对的人仍然算做对、「最后错在」说的也是旧选项
|
||||||
|
*/
|
||||||
|
resetAttempts: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- AI 报告
|
// ---------------------------------------------------------------- AI 报告
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user