feat(自学): 教程和练一练都留痕,老师能看到谁学了多少
Some checks failed
Deploy / deploy (push) Has been cancelled

自学模块以前一个字节都不落库:读到第几课只存在浏览器的 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:
2026-09-01 08:54:27 -06:00
parent 49681d04a6
commit bd84599174
30 changed files with 9484 additions and 36 deletions

View File

@@ -55,6 +55,7 @@ import type {
SubmitCodePayload,
WebsiteConfig,
Tutorial,
TutorialProgress,
} from "utils/types"
/**
@@ -459,3 +460,50 @@ export function getProblemSetUserProgress(
export function getExercises(tutorialId: number): Promise<Exercise[]> {
return api.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
}
/**
* 上报一次练一练的作答。`answer` 是给老师看的一句人话(「选了 A、C」
* 只在做错时才有意义,做对了不用带。
*
* 截到 200 字符再发:后端契约卡的就是 200填空题填了一整段的话
* 不截就是一个 400而学生这边什么都看不见 —— 留痕失败得静悄悄的。
*/
export function reportExerciseAttempt(
exerciseId: number,
payload: { correct: boolean; answer?: string },
) {
return fetch(`/api/exercises/${exerciseId}/attempts`, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
correct: payload.correct,
answer: payload.answer?.slice(0, 200),
}),
}).catch(() => undefined)
}
export function getLearnProgress(type: "python" | "c") {
return api.get<TutorialProgress[]>("learn/progress", { params: { type } })
}
/**
* 上报自学留痕。`opened` 为真表示刚进这一课,否则只是补停留时长。
*
* 走裸 fetch 而不是 axios是为了 `keepalive`:离开页面那一下的最后一次上报,
* axios 发出去也会随页面卸载被浏览器掐掉,学生每节课的最后一段时长就永远丢了。
* 失败一律吞掉 —— 留痕是旁路,不该让学生看到任何报错。
*/
export function reportLearnProgress(
tutorialId: number,
payload: { seconds: number; opened: boolean },
) {
return fetch(`/api/tutorials/${tutorialId}/progress`, {
method: "POST",
credentials: "include",
keepalive: true,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(() => undefined)
}

View File

@@ -4,6 +4,10 @@ import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseDebugData)
const lineHtml = computed(() => highlightLines(data.value.lines, props.lang))
@@ -65,6 +69,11 @@ function lineStyle(i: number): Record<string, string> {
function submit() {
submitted.value = true
const picked = [...selected.value].sort((a, b) => a - b).map((i) => i + 1)
emit("attempt", {
correct: allCorrect.value,
answer: picked.length ? `选了第 ${picked.join("、")}` : "一行都没选",
})
}
function reset() {

View File

@@ -4,6 +4,10 @@ import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseFillData)
type CodeSeg = { type: "code"; html: string }
@@ -58,6 +62,10 @@ function submit() {
}
wrongBlanks.value = wrong
allCorrect.value = wrong.size === 0
emit("attempt", {
correct: allCorrect.value,
answer: `填了 ${userInputs.value.map((v) => v.trim() || "(空)").join(" | ")}`,
})
}
function inputWidth(idx: number): string {

View File

@@ -3,6 +3,10 @@ import type { Exercise, ExerciseGroupData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseGroupData)
const order = ref<number[]>([]) // item 的稳定展示顺序(初始乱序)
@@ -72,6 +76,10 @@ function chipStyle(i: number): Record<string, string> {
function submit() {
submitted.value = true
emit("attempt", {
correct: allCorrect.value,
answer: `分组 ${placement.value.map((b, i) => `${i + 1}${b === -1 ? "?" : b + 1}`).join("、")}`,
})
}
function reset() {

View File

@@ -3,6 +3,10 @@ import type { Exercise, ExerciseMatchData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseMatchData)
const PALETTE = [
@@ -69,6 +73,10 @@ function onRightClick(rightIdx: number) {
function submit() {
submitted.value = true
emit("attempt", {
correct: allCorrect.value,
answer: `配对 ${pairs.value.map((p, i) => `${i + 1}${p === null ? "?" : p + 1}`).join("、")}`,
})
}
function reset() {

View File

@@ -2,6 +2,10 @@
import type { Exercise, ExerciseMcqData } from "utils/types"
const props = defineProps<{ exercise: Exercise }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseMcqData)
const isSingle = computed(() => data.value.answer.length === 1)
@@ -31,6 +35,7 @@ function submit() {
const sel = selected.value
const isEqual =
sel.size === answer.size && [...sel].every((v) => answer.has(v))
emit("attempt", { correct: isEqual, answer: describe(sel) })
if (isEqual) {
correct.value = true
wrong.value = false
@@ -48,6 +53,11 @@ function submit() {
}
}
/** 给老师看的一句人话:选项按 A/B/C 报,报下标没人看得懂 */
function describe(sel: Set<number>) {
return `选了 ${[...sel].sort((a, b) => a - b).map((i) => String.fromCharCode(65 + i)).join("、")}`
}
function reset() {
selected.value = new Set()
correct.value = false

View File

@@ -4,6 +4,10 @@ import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExercisePredictData)
const codeHtml = computed(() => highlight(data.value.code, props.lang))
@@ -27,8 +31,21 @@ const allCorrect = computed(() =>
data.value.answer.some((a) => normalize(a) === normalize(userInput.value)),
)
// 改了答案就把上一次的判定收回去,等他重新点提交 —— 排序/连线/找错/分组四种题
// 本来就是这么做的(各自的交互处都会把 submitted 置回 false只有这里漏了。
// 不收回的话,`allCorrect` 是跟着输入实时算的,学生错一次之后把答案改对,
// 界面直接跳成「输出正确!」、提交按钮同时禁用 —— submit() 再也不会执行,
// 于是这道题**永远不会被记成做对**(留痕里他就一直卡在那次错的上面)。
watch(userInput, () => {
submitted.value = false
})
function submit() {
submitted.value = true
emit("attempt", {
correct: allCorrect.value,
answer: `答「${userInput.value.replace(/\n/g, "⏎")}`,
})
}
function reset() {

View File

@@ -5,6 +5,10 @@ import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
attempt: [payload: { correct: boolean; answer?: string }]
}>()
const data = computed(() => props.exercise.data as ExerciseSortData)
type LineItem = { originalIdx: number; text: string }
@@ -55,6 +59,11 @@ const allCorrect = computed(() =>
function submit() {
submitted.value = true
emit("attempt", {
correct: allCorrect.value,
// 报的是「他把原文第几行排在了第几位」,老师对着题面就能看出错在哪
answer: `顺序 ${lines.value.map((item) => item.originalIdx + 1).join("-")}`,
})
}
function reset() {

View File

@@ -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>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { TutorialProgress } from "utils/types"
import { readableDuration } from "utils/functions"
defineProps<{
titles: { id: number; title: string }[]
step: number
/** 按教程 id 索引的自学留痕,未登录时是空的 */
progress: Record<number, TutorialProgress>
/** 是否在留痕(登录了才留) */
traced: boolean
}>()
const emit = defineEmits<{ select: [lesson: number] }>()
</script>
<template>
<n-list hoverable clickable>
<n-list-item
v-for="(item, index) in titles"
:key="item.id"
@click="emit('select', index + 1)"
>
<!-- 标题独占一行目录栏只有屏幕的五分之一宽已读摆在同一行会把
中文标题挤成两截 -->
<n-flex vertical :size="2">
<n-text
:type="step === index + 1 ? 'primary' : undefined"
:strong="step === index + 1"
>
{{ index + 1 }}. {{ item.title }}
</n-text>
<!-- 每篇教程都有一条进度没读过的是一行零所以这里判的是读没读过
不是有没有这条记录 -->
<n-text
v-if="progress[item.id]?.viewCount"
type="success"
style="font-size: 12px"
>
已读 · {{ readableDuration(progress[item.id].totalSeconds) }}
</n-text>
<n-text
v-if="progress[item.id]?.exerciseTotal"
:type="
progress[item.id].exerciseSolved === progress[item.id].exerciseTotal
? 'success'
: undefined
"
:depth="
progress[item.id].exerciseSolved === progress[item.id].exerciseTotal
? undefined
: 3
"
style="font-size: 12px"
>
练一练 {{ progress[item.id].exerciseSolved }} /
{{ progress[item.id].exerciseTotal }}
</n-text>
</n-flex>
</n-list-item>
</n-list>
<!-- 只在没登录时提一句登录了却还没读的人不需要被提醒你还没读 -->
<n-text v-if="!traced" depth="3" style="display: block; padding: 8px 4px">
登录后可以记录学习进度
</n-text>
</template>

View File

@@ -0,0 +1,72 @@
import { reportLearnProgress } from "oj/api"
/** 计时心跳。攒够 FLUSH_SECONDS 才上报一次,别让每个学生每秒钟打一次接口 */
const TICK_MS = 15_000
const FLUSH_SECONDS = 60
/**
* 挂机保护:连续这么久没有任何鼠标/键盘/滚轮动作就停止计时。
*
* 机房的电脑经常开着页面就走了,不设这道闸的话「停留时长」会变成「电脑开机时长」,
* 老师看到的数字全是假的。10 分钟是折中:真在读长课文的学生不会连滚轮都不碰这么久,
* 而挂机的最多也只多算 10 分钟。
*/
const IDLE_MS = 10 * 60 * 1000
/**
* 自学留痕的客户端计时。
*
* 只在「页面可见 + 人没挂机」时累加秒数,攒够一分钟或离开这一课时上报。
* 换课、组件卸载、页面隐藏(切标签页/关窗口/手机切后台)都会先把攒着的秒数冲出去 ——
* 手机上 `beforeunload` 常常不触发,`visibilitychange` 才是可靠的那个。
*
* @param tutorialId 当前这一课0 表示还没加载好
* @param enabled 是否留痕。未登录时为 false教程本身保持免登录可读只是不记
*/
export function useLearnTrace(
tutorialId: Ref<number>,
enabled: Ref<boolean>,
) {
const visibility = useDocumentVisibility()
const { idle } = useIdle(IDLE_MS)
// 攒着还没上报的秒数,以及它属于哪一课 —— 换课时先把上一课的冲掉,
// 不能记在当前 tutorialId 名下,否则时长会被算到下一课头上
let pending = 0
let pendingId = 0
function flush() {
if (!enabled.value || pending <= 0 || pendingId <= 0) return
const seconds = pending
const id = pendingId
pending = 0
reportLearnProgress(id, { seconds, opened: false })
}
const timer = window.setInterval(() => {
if (!enabled.value || tutorialId.value <= 0) return
if (visibility.value !== "visible" || idle.value) return
pendingId = tutorialId.value
pending += TICK_MS / 1000
if (pending >= FLUSH_SECONDS) flush()
}, TICK_MS)
// enabled 也要盯着:学生常常是打开教程之后才在弹窗里登录的,那一下 tutorialId
// 没变,只看 tutorialId 的话这一课就永远不算「打开过」,得等他翻到下一课才开始留痕
watch([tutorialId, enabled], ([id, on], [previousId, previousOn]) => {
if (previousId && previousId !== id) flush()
if (!on || id <= 0) return
if (id === previousId && on === previousOn) return
pendingId = id
reportLearnProgress(id, { seconds: 0, opened: true })
})
watch(visibility, (value) => {
if (value === "hidden") flush()
})
onBeforeUnmount(() => {
window.clearInterval(timer)
flush()
})
}

View File

@@ -9,20 +9,13 @@
>
<n-gi :span="1" class="learn-col">
<n-card title="教程目录" :bordered="false" size="small">
<n-list hoverable clickable>
<n-list-item
v-for="(item, index) in titles"
:key="item.id"
@click="goToLesson(index + 1)"
>
<n-text
:type="step === index + 1 ? 'primary' : undefined"
:strong="step === index + 1"
>
{{ index + 1 }}. {{ item.title }}
</n-text>
</n-list-item>
</n-list>
<LessonList
:titles="titles"
:step="step"
:progress="progress"
:traced="traced"
@select="goToLesson"
/>
</n-card>
</n-gi>
@@ -69,20 +62,13 @@
<template v-if="tutorial.id && !isDesktop">
<n-tabs type="line" animated v-model:value="activeTab">
<n-tab-pane name="catalog" tab="目录">
<n-list hoverable clickable>
<n-list-item
v-for="(item, index) in titles"
:key="item.id"
@click="goToLesson(index + 1)"
>
<n-text
:type="step === index + 1 ? 'primary' : undefined"
:strong="step === index + 1"
>
{{ index + 1 }}. {{ item.title }}
</n-text>
</n-list-item>
</n-list>
<LessonList
:titles="titles"
:step="step"
:progress="progress"
:traced="traced"
@select="goToLesson"
/>
</n-tab-pane>
<n-tab-pane name="content" :tab="`第 ${step} 课`">
@@ -140,11 +126,19 @@
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import type { Tutorial, Exercise, LANGUAGE } from "utils/types"
import { getTutorial, getTutorials, getExercises } from "../api"
import type { Tutorial, Exercise, LANGUAGE, TutorialProgress } from "utils/types"
import {
getTutorial,
getTutorials,
getExercises,
getLearnProgress,
} from "../api"
import { parseExercises } from "./composables/useExerciseParse"
import { useLearnTrace } from "./composables/useLearnTrace"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useUserStore } from "shared/store/user"
import LessonList from "./components/LessonList.vue"
const ExerciseWidget = defineAsyncComponent(
() => import("./components/ExerciseWidget.vue"),
@@ -158,6 +152,10 @@ const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const userStore = useUserStore()
// 未登录也能看教程(学习页本来就不要求登录),只是不留痕
const traced = computed(() => userStore.isAuthed)
const step = computed(() => {
const value = route.params.step as string | undefined
@@ -180,6 +178,7 @@ const editorLanguage = computed<LANGUAGE>(() =>
tutorial.value.type === "c" ? "C" : "Python3",
)
const titles = ref<{ id: number; title: string }[]>([])
const progress = ref<Record<number, TutorialProgress>>({})
const exercises = ref<Exercise[]>([])
const activeTab = ref("content")
const isEmpty = ref(false)
@@ -188,6 +187,12 @@ const segments = computed(() =>
parseExercises(tutorial.value.content ?? "", exercises.value),
)
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
useLearnTrace(
computed(() => tutorial.value.id ?? 0),
traced,
)
const isFirstLesson = computed(() => step.value === 1)
const isLastLesson = computed(() => step.value === titles.value.length)
@@ -204,6 +209,23 @@ function goToNextLesson() {
if (step.value < titles.value.length) goToLesson(step.value + 1)
}
/**
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响上课,
* 但弹个错会把「我是不是没学」的焦虑塞给学生。
*/
async function loadProgress() {
if (!traced.value) {
progress.value = {}
return
}
try {
const rows = await getLearnProgress(type.value)
progress.value = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
} catch {
progress.value = {}
}
}
async function init() {
const res1 = await getTutorials(type.value)
titles.value = res1
@@ -217,6 +239,7 @@ async function init() {
if (res2.status === "fulfilled") tutorial.value = res2.value
exercises.value = exs.status === "fulfilled" ? exs.value : []
learnStep.value[type.value] = step.value
loadProgress()
}
watch(
@@ -226,6 +249,10 @@ watch(
},
{ immediate: true },
)
// 在教程页上登录/退出时把目录的勾重新拉一遍。学生多半是先点开教程、
// 被弹窗拦下才登录的,不盯着这个的话勾要等他刷新页面才出现
watch(traced, loadProgress)
</script>
<style scoped>