自学模块以前一个字节都不落库:读到第几课只存在浏览器的 localStorage 里, 练一练的对错是组件内的一个 ref,刷新即失忆。老师能看到的只有「谁交了题」。 现在两张新表: * tutorial_progress —— 一个学生 × 一课,记打开次数和累计停留秒数 * exercise_attempt —— 一个学生 × 一道练习,记试了几次、错了几次、 第几次做对的、最后一次做错时填的什么 都存聚合不存流水。练习那张表尤其明显:流水会随着学生反复点提交无限长, 而多出来的行回答不了任何新问题 ——「他第 3 次和第 5 次都选了 B」对老师 没有意义,「他试了 7 次才对」有。 停留时长只在页面可见、且十分钟内有过操作时才计。机房的电脑经常开着页面 就走了,不设这道闸的话「停留时长」会变成「电脑开机时长」,老师看到的 数字全是假的。换课、切标签页、关窗口都会先把攒着的秒数冲给**离开的那一课**。 练一练的对错仍然是前端判的:答案本来就随题面一起下发到浏览器,后端再判 一遍也挡不住任何人,只是重复实现七套判题。所以这是教学观察数据,不是成绩。 `last_wrong_answer` 存的是前端拼好的一句人话(「选了 C」「顺序 3-1-2」), 不是原始作答结构 —— 七种题型形状各不相同,存结构就得在后台按题型各写一套 渲染,而老师要看的只是他错在哪。 顺带修掉预测输出题的一个老问题:它的 `submitted` 一旦为真就不再收回,而 `allCorrect` 是跟着输入实时算的,于是学生错一次之后把答案改对,界面直接 跳成「输出正确!」、提交按钮同时禁用,submit() 再也执行不到 —— 这道题 **永远不会被记成做对**。排序/连线/找错/分组四种题本来就在交互处把 submitted 置回 false,只有这里漏了,按同一套补上。 学生端:目录每课显示「✓ 已读 · 11 分钟」和「练一练 3/5」。教程保持免登录 可读,未登录只是不留痕,并明说一句。 老师端:后台新开「自学情况」(教师及以上可进),三个 tab —— 按学生(默认把读得最少的排在最前,这张表要回答的是谁还没开始)、 按练习(每道题的正确率、一次做对几人、做对的人平均试几次;展开看逐人明细 和他们最后错在哪)、按课程。班级框填 3-4 位是具体班级,1-2 位当年级前缀。 外键用了库级 CASCADE,和 Django 建的那批 NO ACTION 不同:删教程、删用户 不必再记得回来手工清子表。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GW5ef6C2kRW8Ru27ghCaUu
This commit is contained in:
1 parent
49681d04a6
commit
bd84599174
30 files changed
+9484
-36
No files matched your search
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Exercise } from "utils/types"
|
||||
import { reportExerciseAttempt } from "oj/api"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
const ExerciseMcq = defineAsyncComponent(() => import("./ExerciseMcq.vue"))
|
||||
const ExerciseSort = defineAsyncComponent(() => import("./ExerciseSort.vue"))
|
||||
@@ -11,31 +13,78 @@ const ExercisePredict = defineAsyncComponent(
|
||||
const ExerciseDebug = defineAsyncComponent(() => import("./ExerciseDebug.vue"))
|
||||
const ExerciseGroup = defineAsyncComponent(() => import("./ExerciseGroup.vue"))
|
||||
|
||||
defineProps<{ exercise: Exercise; lang?: string }>()
|
||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
/**
|
||||
* 七种题型各自判完对错后都往上抛 attempt,留痕只在这里做一次 ——
|
||||
* 每个题型组件里各写一遍上报,早晚会漏掉一两个。
|
||||
*
|
||||
* 两道闸:做对之后不再上报(后端也冻结,这里省一次请求);同一份答案连点两次不算
|
||||
* 两次(排序题、连线题的「提交」按钮点完不会禁用,一个字没动再点一下不是新的尝试)。
|
||||
*/
|
||||
let solved = false
|
||||
let lastAnswer: string | null = null
|
||||
|
||||
// 教程页里 v-for 的 key 是段落序号,换课时组件实例会被复用 —— 不跟着题目 id 重置的话,
|
||||
// 上一课做对的状态会把这一课的第一次作答吃掉
|
||||
watch(
|
||||
() => props.exercise.id,
|
||||
() => {
|
||||
solved = false
|
||||
lastAnswer = null
|
||||
},
|
||||
)
|
||||
|
||||
function onAttempt(payload: { correct: boolean; answer?: string }) {
|
||||
if (!userStore.isAuthed || solved) return
|
||||
const answer = payload.answer ?? ""
|
||||
if (answer === lastAnswer) return
|
||||
lastAnswer = answer
|
||||
if (payload.correct) solved = true
|
||||
reportExerciseAttempt(props.exercise.id, payload)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ExerciseMcq v-if="exercise.type === 'mcq'" :exercise="exercise" />
|
||||
<ExerciseMcq
|
||||
v-if="exercise.type === 'mcq'"
|
||||
:exercise="exercise"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseSort
|
||||
v-else-if="exercise.type === 'sort'"
|
||||
:exercise="exercise"
|
||||
:lang="lang"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseFill
|
||||
v-else-if="exercise.type === 'fill'"
|
||||
:exercise="exercise"
|
||||
:lang="lang"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseMatch
|
||||
v-else-if="exercise.type === 'match'"
|
||||
:exercise="exercise"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseMatch v-else-if="exercise.type === 'match'" :exercise="exercise" />
|
||||
<ExercisePredict
|
||||
v-else-if="exercise.type === 'predict'"
|
||||
:exercise="exercise"
|
||||
:lang="lang"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseDebug
|
||||
v-else-if="exercise.type === 'debug'"
|
||||
:exercise="exercise"
|
||||
:lang="lang"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseGroup
|
||||
v-else-if="exercise.type === 'group'"
|
||||
:exercise="exercise"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseGroup v-else-if="exercise.type === 'group'" :exercise="exercise" />
|
||||
</template>
|
||||
Reference in new issue
Block a user