自学模块以前一个字节都不落库:读到第几课只存在浏览器的 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:
@@ -12,6 +12,10 @@ import type {
|
||||
AdminAiReport,
|
||||
AdminAiReportList,
|
||||
StuckProblem,
|
||||
LearnStudentProgressList,
|
||||
LearnTutorialProgressList,
|
||||
LearnExerciseProgressList,
|
||||
LearnExerciseAttempt,
|
||||
AdminContestList,
|
||||
AdminUser,
|
||||
AdminUserList,
|
||||
@@ -664,6 +668,43 @@ export function getStuckProblems() {
|
||||
return api.get<StuckProblem[]>("admin/problem-analytics/stuck")
|
||||
}
|
||||
|
||||
export function getLearnStudents(params: {
|
||||
type: "python" | "c"
|
||||
className?: string
|
||||
}) {
|
||||
return api.get<LearnStudentProgressList>("admin/learn-analytics/students", {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getLearnTutorials(params: {
|
||||
type: "python" | "c"
|
||||
className?: string
|
||||
}) {
|
||||
return api.get<LearnTutorialProgressList>("admin/learn-analytics/tutorials", {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getLearnExercises(params: {
|
||||
type: "python" | "c"
|
||||
className?: string
|
||||
}) {
|
||||
return api.get<LearnExerciseProgressList>("admin/learn-analytics/exercises", {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getLearnExerciseAttempts(
|
||||
exerciseId: number,
|
||||
params: { className?: string },
|
||||
) {
|
||||
return api.get<LearnExerciseAttempt[]>(
|
||||
`admin/learn-analytics/exercises/${exerciseId}/attempts`,
|
||||
{ params },
|
||||
)
|
||||
}
|
||||
|
||||
export function getTopACTrend(params: {
|
||||
sinceYear: number
|
||||
untilYear: number
|
||||
|
||||
77
apps/web/src/admin/learn/ExerciseAttempts.vue
Normal file
77
apps/web/src/admin/learn/ExerciseAttempts.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { NText } from "naive-ui"
|
||||
import { getLearnExerciseAttempts } from "admin/api"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { LearnExerciseAttempt } from "utils/types"
|
||||
|
||||
const props = defineProps<{ exerciseId: number; className: string }>()
|
||||
|
||||
const loading = ref(true)
|
||||
const rows = ref<LearnExerciseAttempt[]>([])
|
||||
|
||||
const columns: DataTableColumn<LearnExerciseAttempt>[] = [
|
||||
{ title: "班级", key: "className", width: 80 },
|
||||
{ title: "学号", key: "username", width: 140 },
|
||||
{
|
||||
title: "姓名",
|
||||
key: "realName",
|
||||
width: 100,
|
||||
render: (row) => row.realName || "-",
|
||||
},
|
||||
{
|
||||
title: "结果",
|
||||
key: "solved",
|
||||
width: 130,
|
||||
render: (row) =>
|
||||
row.solved
|
||||
? h(
|
||||
NText,
|
||||
{ type: "success" },
|
||||
() => `做对了(第 ${row.attemptsToSolve} 次)`,
|
||||
)
|
||||
: h(NText, { type: "error" }, () => "还没做对"),
|
||||
},
|
||||
{ title: "提交次数", key: "attempts", width: 100, sorter: "default" },
|
||||
{
|
||||
// 学生最后一次做错时提交的内容,前端拼好的一句人话。选择题看这一列
|
||||
// 就知道全班是不是都掉进同一个干扰项
|
||||
title: "最后一次错在",
|
||||
key: "lastWrongAnswer",
|
||||
minWidth: 180,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => row.lastWrongAnswer || "-",
|
||||
},
|
||||
{
|
||||
title: "最后作答",
|
||||
key: "lastAttemptAt",
|
||||
width: 150,
|
||||
render: (row) => parseTime(row.lastAttemptAt, "M月D日 HH:mm"),
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
rows.value = await getLearnExerciseAttempts(props.exerciseId, {
|
||||
className: props.className,
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-data-table
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
size="small"
|
||||
:pagination="rows.length > 10 ? { pageSize: 10 } : false"
|
||||
/>
|
||||
<n-empty
|
||||
v-if="!loading && rows.length === 0"
|
||||
description="还没有人做过这道练习"
|
||||
size="small"
|
||||
style="padding: 16px 0"
|
||||
/>
|
||||
</template>
|
||||
324
apps/web/src/admin/learn/index.vue
Normal file
324
apps/web/src/admin/learn/index.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
67
apps/web/src/oj/learn/components/LessonList.vue
Normal file
67
apps/web/src/oj/learn/components/LessonList.vue
Normal 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>
|
||||
72
apps/web/src/oj/learn/composables/useLearnTrace.ts
Normal file
72
apps/web/src/oj/learn/composables/useLearnTrace.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -335,5 +335,11 @@ export const admins: RouteRecordRaw = {
|
||||
component: () => import("admin/ai/list.vue"),
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "learn",
|
||||
name: "admin learn analytics",
|
||||
component: () => import("admin/learn/index.vue"),
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -67,6 +67,11 @@ const options = computed<MenuOption[]>(() => {
|
||||
),
|
||||
key: "admin ai reports",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/learn" }, { default: () => "自学" }),
|
||||
key: "admin learn analytics",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,6 +155,11 @@ const options = computed<MenuOption[]>(() => {
|
||||
),
|
||||
key: "admin ai reports",
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(RouterLink, { to: "/admin/learn" }, { default: () => "自学" }),
|
||||
key: "admin learn analytics",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -171,6 +181,7 @@ const active = computed(() => {
|
||||
if (path.startsWith("/admin/announcement")) return "admin announcement list"
|
||||
if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
|
||||
if (path.startsWith("/admin/ai")) return "admin ai reports"
|
||||
if (path.startsWith("/admin/learn")) return "admin learn analytics"
|
||||
return route.name as string
|
||||
})
|
||||
|
||||
|
||||
@@ -90,6 +90,20 @@ export function duration(
|
||||
return formatDurationUnits(durationObj, units)
|
||||
}
|
||||
|
||||
/**
|
||||
* 自学时长的显示。心跳是 15 秒一跳,本来就精确不到秒,一律按分钟取整;
|
||||
* 0 显示成短横线而不是「0 分钟」——「没学过」和「学了不到一分钟」不是一回事。
|
||||
*/
|
||||
export function readableDuration(seconds: number): string {
|
||||
if (seconds <= 0) return "-"
|
||||
if (seconds < 60) return "不到 1 分钟"
|
||||
const minutes = Math.round(seconds / 60)
|
||||
if (minutes < 60) return `${minutes} 分钟`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const rest = minutes % 60
|
||||
return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时`
|
||||
}
|
||||
|
||||
export function durationToDays(
|
||||
start: Date | string,
|
||||
end: Date | string,
|
||||
|
||||
@@ -413,6 +413,13 @@ export type {
|
||||
AdminAiReportList,
|
||||
StuckProblem,
|
||||
AcTrend,
|
||||
LearnStudentProgress,
|
||||
LearnStudentProgressList,
|
||||
LearnTutorialProgress,
|
||||
LearnTutorialProgressList,
|
||||
LearnExerciseProgress,
|
||||
LearnExerciseProgressList,
|
||||
LearnExerciseAttempt,
|
||||
} from "@oj2/contract"
|
||||
|
||||
/**
|
||||
@@ -464,6 +471,9 @@ export type {
|
||||
AdminTutorial as Tutorial,
|
||||
AdminTutorialListItem as TutorialListItem,
|
||||
} from "@oj2/contract"
|
||||
|
||||
/** 学生自己的自学留痕,学习页的目录拿它打勾 */
|
||||
export type { TutorialProgress } from "@oj2/contract"
|
||||
import type {
|
||||
AdminExercise,
|
||||
AdminTutorial,
|
||||
|
||||
Reference in New Issue
Block a user