Files
OJ2/apps/web/src/admin/learn/index.vue
yuetsh bd84599174
Some checks failed
Deploy / deploy (push) Has been cancelled
feat(自学): 教程和练一练都留痕,老师能看到谁学了多少
自学模块以前一个字节都不落库:读到第几课只存在浏览器的 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
2026-09-01 08:54:27 -06:00

325 lines
9.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { NProgress, NText } from "naive-ui"
import {
getLearnStudents,
getLearnTutorials,
getLearnExercises,
} from "admin/api"
import { readableDuration, parseTime } from "utils/functions"
import type {
LearnStudentProgress,
LearnTutorialProgress,
LearnExerciseProgress,
} from "utils/types"
import ExerciseAttempts from "./ExerciseAttempts.vue"
const EXERCISE_TYPE_LABEL: Record<string, string> = {
mcq: "选择",
sort: "排序",
fill: "填空",
match: "连线",
predict: "预测输出",
debug: "找错",
group: "分组",
}
const type = ref<"python" | "c">("python")
// 3-4 位是具体班级1-2 位当年级前缀(后端 classFilter 分的岔)
const className = ref("")
const tab = ref("students")
const loading = ref(false)
const students = ref<LearnStudentProgress[]>([])
const tutorials = ref<LearnTutorialProgress[]>([])
const exercises = ref<LearnExerciseProgress[]>([])
const tutorialCount = ref(0)
const exerciseCount = ref(0)
const studentCount = ref(0)
// 展开明细的那一行;一次只展开一道题,免得几十个请求一起打出去
const expanded = ref<number[]>([])
const typeOptions = [
{ label: "Python", value: "python" },
{ label: "C 语言", value: "c" },
]
const startedCount = computed(
() => students.value.filter((row) => row.readCount > 0).length,
)
const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
{ title: "班级", key: "className", width: 90, sorter: "default" },
{ title: "学号", key: "username", width: 140 },
{
title: "姓名",
key: "realName",
width: 110,
render: (row) => row.realName || "-",
},
{
title: `已读(共 ${tutorialCount.value} 课)`,
key: "readCount",
width: 170,
sorter: "default",
// 默认把读得最少的排在最前面:这张表要回答的是「谁还没开始」,
// 按读得多的排在前面,需要盯的人全在最后一页
defaultSortOrder: "ascend",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.readCount} / ${tutorialCount.value}`),
h(NProgress, {
type: "line",
percentage: tutorialCount.value
? Math.round((row.readCount / tutorialCount.value) * 100)
: 0,
showIndicator: false,
status: row.readCount === 0 ? "error" : "success",
style: "width: 70px",
}),
]),
},
{
title: `练一练(共 ${exerciseCount.value} 道)`,
key: "exerciseSolved",
width: 190,
sorter: "default",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.exerciseSolved} / ${exerciseCount.value}`),
// 做过但没做对的题,和提交总次数,一起说明「他在硬啃还是没碰」
row.exerciseTried > row.exerciseSolved
? h(
NText,
{ depth: 3, style: "font-size: 12px" },
() => `${row.exerciseTried - row.exerciseSolved}`,
)
: null,
row.exerciseAttempts
? h(
NText,
{ depth: 3, style: "font-size: 12px" },
() => `${row.exerciseAttempts}`,
)
: null,
]),
},
{
title: "累计时长",
key: "totalSeconds",
width: 130,
sorter: "default",
render: (row) => readableDuration(row.totalSeconds),
},
{
title: "最后学习",
key: "lastViewedAt",
width: 170,
sorter: "default",
render: (row) =>
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-",
},
])
const tutorialColumns = computed<DataTableColumn<LearnTutorialProgress>[]>(
() => [
{
title: "#",
key: "order",
width: 60,
render: (_, index) => index + 1,
},
{ title: "课程", key: "title", minWidth: 200 },
{
title: `读过的人(共 ${studentCount.value} 人)`,
key: "readers",
width: 200,
sorter: "default",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.readers} / ${studentCount.value}`),
h(NProgress, {
type: "line",
percentage: studentCount.value
? Math.round((row.readers / studentCount.value) * 100)
: 0,
showIndicator: false,
status: row.readers === 0 ? "error" : "success",
style: "width: 70px",
}),
]),
},
{
title: "人均时长",
key: "avgSeconds",
width: 130,
sorter: "default",
render: (row) => readableDuration(row.avgSeconds),
},
{
title: "累计时长",
key: "totalSeconds",
width: 130,
sorter: "default",
render: (row) => readableDuration(row.totalSeconds),
},
],
)
const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
() => [
{ type: "expand", renderExpand: (row) => h(ExerciseAttempts, {
exerciseId: row.exerciseId,
className: className.value.trim(),
}) },
{
title: "课",
key: "tutorialOrder",
width: 160,
ellipsis: { tooltip: true },
render: (row) => `${row.tutorialOrder}. ${row.tutorialTitle}`,
},
{
title: "题型",
key: "type",
width: 90,
render: (row) => EXERCISE_TYPE_LABEL[row.type] ?? row.type,
},
{
title: "题干",
key: "question",
minWidth: 220,
ellipsis: { tooltip: true },
render: (row) => row.question || "(无题干)",
},
{
title: "做对 / 做过",
key: "solvedUsers",
width: 150,
sorter: "default",
render: (row) =>
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
h("span", `${row.solvedUsers} / ${row.triedUsers}`),
h(NProgress, {
type: "line",
percentage: row.triedUsers
? Math.round((row.solvedUsers / row.triedUsers) * 100)
: 0,
showIndicator: false,
status: row.triedUsers === 0 ? "error" : "success",
style: "width: 60px",
}),
]),
},
{
title: "一次做对",
key: "firstTryUsers",
width: 110,
sorter: "default",
render: (row) => `${row.firstTryUsers}`,
},
{
// 做对的人平均试了几次。它和「一次做对」一起看才分得清难题和歧义题:
// 平均 3 次但没人一次对 → 题目本身有坑
title: "平均试几次",
key: "avgAttemptsToSolve",
width: 120,
sorter: "default",
defaultSortOrder: "descend",
render: (row) => (row.solvedUsers ? `${row.avgAttemptsToSolve}` : "-"),
},
{
title: "提交总次数",
key: "attempts",
width: 120,
sorter: "default",
},
],
)
async function load() {
loading.value = true
expanded.value = []
const params = { type: type.value, className: className.value.trim() }
try {
// 三张表一起拉:切 tab 是纯前端的事,不该再等一次网络
const [studentRes, tutorialRes, exerciseRes] = await Promise.all([
getLearnStudents(params),
getLearnTutorials(params),
getLearnExercises(params),
])
students.value = studentRes.results
tutorialCount.value = studentRes.tutorialCount
exerciseCount.value = studentRes.exerciseCount
tutorials.value = tutorialRes.results
studentCount.value = tutorialRes.studentCount
exercises.value = exerciseRes.results
} finally {
loading.value = false
}
}
watch(type, load)
onMounted(load)
</script>
<template>
<h2 style="margin-top: 0">自学情况</h2>
<n-flex align="center" style="margin-bottom: 16px">
<n-radio-group v-model:value="type" size="small">
<n-radio-button
v-for="item in typeOptions"
:key="item.value"
:value="item.value"
:label="item.label"
/>
</n-radio-group>
<n-input
v-model:value="className"
placeholder="班级或年级,如 241 / 24"
clearable
style="width: 200px"
@keyup.enter="load"
@clear="load"
/>
<n-button type="primary" secondary @click="load">查询</n-button>
<n-text depth="3">
{{ studentCount }} 名学生{{ startedCount }} 人已经开始学
</n-text>
</n-flex>
<n-tabs v-model:value="tab" type="line" animated>
<n-tab-pane name="students" tab="按学生">
<n-data-table
:loading="loading"
:columns="studentColumns"
:data="students"
:row-key="(row: LearnStudentProgress) => row.userId"
striped
:pagination="{ pageSize: 20 }"
/>
</n-tab-pane>
<n-tab-pane name="exercises" tab="按练习">
<n-data-table
:loading="loading"
:columns="exerciseColumns"
:data="exercises"
:row-key="(row: LearnExerciseProgress) => row.exerciseId"
v-model:expanded-row-keys="expanded"
striped
:pagination="{ pageSize: 20 }"
/>
</n-tab-pane>
<n-tab-pane name="tutorials" tab="按课程">
<n-data-table
:loading="loading"
:columns="tutorialColumns"
:data="tutorials"
:row-key="(row: LearnTutorialProgress) => row.tutorialId"
striped
:pagination="{ pageSize: 20 }"
/>
</n-tab-pane>
</n-tabs>
</template>