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
325 lines
9.3 KiB
Vue
325 lines
9.3 KiB
Vue
<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>
|