feat(自学情况): 每个学生练一练做到哪了——格子分出做了一部分、点名字看逐题明细
- 方块格多一档「做了一部分」:按做对的比例从下往上涨绿,一道没做对的底下压一条橙; 鼠标停上去是「练一练 3/5,错了 4 次」 - 「练一练」列改成 做对/应做(应做 = 他打开过的课的练一练总数) - 点学生名字从右边滑出明细:每课一段,逐题写第几次做对、交了几次、最后错在哪; 手机上返回键关它 - 点开一道练一练,底下多一串「读了这课还没做」的人 - 顺手修:那个弹框七列加起来比弹框宽,「最后作答」被挤没了;改成一列用户名 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
859a537804
commit
7839a65f59
9 files changed
+647
-44
No files matched your search
@@ -1301,9 +1301,8 @@ export const problemsetBadge = pgTable(
|
|||||||
* OJ2 自己建的表,不是 Django 遗留,所以没有代理主键 —— 写入路径只有一条 upsert,
|
* OJ2 自己建的表,不是 Django 遗留,所以没有代理主键 —— 写入路径只有一条 upsert,
|
||||||
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
|
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
|
||||||
*
|
*
|
||||||
* **只记「读到哪一课 + 停留多久」,不记练一练的作答。** 练习的对错全在浏览器里判
|
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
|
||||||
* (见 apps/web/src/oj/learn/components/),学生可以随便重试到对为止,上报上来也只是
|
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isLessonDone`)。
|
||||||
* 「他按了几次按钮」,不构成教学证据,还要为此多养一张按人按题膨胀的表。
|
|
||||||
*
|
*
|
||||||
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
|
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
|
||||||
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
|
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import {
|
|||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
isLessonDone,
|
isLessonDone,
|
||||||
type LearnBoard,
|
type LearnBoard,
|
||||||
type LearnExerciseAttempt,
|
type LearnBoardStudent,
|
||||||
|
type LearnExerciseAttemptList,
|
||||||
type LearnExerciseProgress,
|
type LearnExerciseProgress,
|
||||||
type LearnExerciseProgressList,
|
type LearnExerciseProgressList,
|
||||||
|
type LearnStudentDetail,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm"
|
import { and, asc, count, desc, eq, inArray, isNull, like, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||||
@@ -125,7 +127,14 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
|
|||||||
.select({
|
.select({
|
||||||
userId: schema.exerciseAttempt.userId,
|
userId: schema.exerciseAttempt.userId,
|
||||||
tutorialId: schema.exercise.tutorialId,
|
tutorialId: schema.exercise.tutorialId,
|
||||||
solved: count(),
|
tried: count(),
|
||||||
|
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
|
||||||
|
Number,
|
||||||
|
),
|
||||||
|
wrongAttempts:
|
||||||
|
sql<number>`coalesce(sum(${schema.exerciseAttempt.wrongAttempts}), 0)`.mapWith(
|
||||||
|
Number,
|
||||||
|
),
|
||||||
})
|
})
|
||||||
.from(schema.exerciseAttempt)
|
.from(schema.exerciseAttempt)
|
||||||
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId))
|
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId))
|
||||||
@@ -133,7 +142,6 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
|
|||||||
and(
|
and(
|
||||||
inArray(schema.exerciseAttempt.userId, userIds),
|
inArray(schema.exerciseAttempt.userId, userIds),
|
||||||
inArray(schema.exercise.tutorialId, tutorialIds),
|
inArray(schema.exercise.tutorialId, tutorialIds),
|
||||||
eq(schema.exerciseAttempt.solved, true),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(schema.exerciseAttempt.userId, schema.exercise.tutorialId),
|
.groupBy(schema.exerciseAttempt.userId, schema.exercise.tutorialId),
|
||||||
@@ -142,8 +150,8 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
|
|||||||
|
|
||||||
const progress = new Map<string, (typeof progressRows)[number]>()
|
const progress = new Map<string, (typeof progressRows)[number]>()
|
||||||
for (const row of progressRows) progress.set(`${row.userId}:${row.tutorialId}`, row)
|
for (const row of progressRows) progress.set(`${row.userId}:${row.tutorialId}`, row)
|
||||||
const solved = new Map<string, number>()
|
const attempted = new Map<string, (typeof solvedRows)[number]>()
|
||||||
for (const row of solvedRows) solved.set(`${row.userId}:${row.tutorialId}`, row.solved)
|
for (const row of solvedRows) attempted.set(`${row.userId}:${row.tutorialId}`, row)
|
||||||
|
|
||||||
return success(c, {
|
return success(c, {
|
||||||
className,
|
className,
|
||||||
@@ -151,14 +159,20 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
|
|||||||
students: students.map((student) => {
|
students: students.map((student) => {
|
||||||
const opened: number[] = []
|
const opened: number[] = []
|
||||||
const done: number[] = []
|
const done: number[] = []
|
||||||
|
const lessons: LearnBoardStudent["lessons"] = []
|
||||||
let exerciseSolved = 0
|
let exerciseSolved = 0
|
||||||
let totalSeconds = 0
|
let totalSeconds = 0
|
||||||
let lastViewedAt: string | null = null
|
let lastViewedAt: string | null = null
|
||||||
for (const tutorial of tutorials) {
|
for (const tutorial of tutorials) {
|
||||||
const key = `${student.userId}:${tutorial.id}`
|
const key = `${student.userId}:${tutorial.id}`
|
||||||
const row = progress.get(key)
|
const row = progress.get(key)
|
||||||
const solvedHere = solved.get(key) ?? 0
|
const exercises = attempted.get(key)
|
||||||
|
const solvedHere = exercises?.solved ?? 0
|
||||||
exerciseSolved += solvedHere
|
exerciseSolved += solvedHere
|
||||||
|
if (exercises) {
|
||||||
|
const { tried, solved, wrongAttempts } = exercises
|
||||||
|
lessons.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts })
|
||||||
|
}
|
||||||
if (row) {
|
if (row) {
|
||||||
opened.push(tutorial.id)
|
opened.push(tutorial.id)
|
||||||
totalSeconds += row.totalSeconds
|
totalSeconds += row.totalSeconds
|
||||||
@@ -171,7 +185,7 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
|
|||||||
}
|
}
|
||||||
if (isLessonDone(lesson)) done.push(tutorial.id)
|
if (isLessonDone(lesson)) done.push(tutorial.id)
|
||||||
}
|
}
|
||||||
return { ...student, opened, done, exerciseSolved, totalSeconds, lastViewedAt }
|
return { ...student, opened, done, exerciseSolved, lessons, totalSeconds, lastViewedAt }
|
||||||
}),
|
}),
|
||||||
} satisfies LearnBoard)
|
} satisfies LearnBoard)
|
||||||
})
|
})
|
||||||
@@ -271,5 +285,121 @@ adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher,
|
|||||||
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
|
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
|
||||||
.orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts))
|
.orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts))
|
||||||
|
|
||||||
return success(c, rows satisfies LearnExerciseAttempt[])
|
// 读过这课却没碰这道题的人:从这课的阅读记录出发,反连作答表取空
|
||||||
|
const untried = await db
|
||||||
|
.select({ userId: schema.user.id, username: schema.user.username })
|
||||||
|
.from(schema.exercise)
|
||||||
|
.innerJoin(
|
||||||
|
schema.tutorialProgress,
|
||||||
|
eq(schema.tutorialProgress.tutorialId, schema.exercise.tutorialId),
|
||||||
|
)
|
||||||
|
.innerJoin(schema.user, eq(schema.user.id, schema.tutorialProgress.userId))
|
||||||
|
.leftJoin(
|
||||||
|
schema.exerciseAttempt,
|
||||||
|
and(
|
||||||
|
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
|
||||||
|
eq(schema.exerciseAttempt.userId, schema.user.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.exercise.id, id),
|
||||||
|
isNull(schema.exerciseAttempt.userId),
|
||||||
|
studentCondition(className.value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(schema.user.username))
|
||||||
|
|
||||||
|
return success(c, { results: rows, untried } satisfies LearnExerciseAttemptList)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个学生的自学明细:他打开过或做过练一练的每一课,每道练习做到哪了。
|
||||||
|
*
|
||||||
|
* 不限班级、也不限学生角色 —— 老师从方块格点进来的一定是学生,
|
||||||
|
* 而按 id 查一个人的自学留痕本来就是老师能看的东西(方块格上全有)。
|
||||||
|
*/
|
||||||
|
adminLearnRoutes.get("/learn-analytics/students/:id", requireTeacher, async (c) => {
|
||||||
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
|
const type = tutorialTypeOf(c.req.query("type"))
|
||||||
|
|
||||||
|
const [student] = await db
|
||||||
|
.select({
|
||||||
|
userId: schema.user.id,
|
||||||
|
username: schema.user.username,
|
||||||
|
className: schema.user.className,
|
||||||
|
})
|
||||||
|
.from(schema.user)
|
||||||
|
.where(eq(schema.user.id, id))
|
||||||
|
if (!student) return failure(c, 404, "user-not-found", "用户不存在")
|
||||||
|
|
||||||
|
const visible = and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))
|
||||||
|
const [tutorials, exercises] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
tutorialId: schema.tutorial.id,
|
||||||
|
title: schema.tutorial.title,
|
||||||
|
viewCount: schema.tutorialProgress.viewCount,
|
||||||
|
totalSeconds: schema.tutorialProgress.totalSeconds,
|
||||||
|
lastViewedAt: schema.tutorialProgress.lastViewedAt,
|
||||||
|
})
|
||||||
|
.from(schema.tutorial)
|
||||||
|
.leftJoin(
|
||||||
|
schema.tutorialProgress,
|
||||||
|
and(
|
||||||
|
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
|
||||||
|
eq(schema.tutorialProgress.userId, id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(visible)
|
||||||
|
.orderBy(asc(schema.tutorial.order)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
tutorialId: schema.exercise.tutorialId,
|
||||||
|
exerciseId: schema.exercise.id,
|
||||||
|
type: schema.exercise.type,
|
||||||
|
question: sql<string>`coalesce(${schema.exercise.data}->>'question', '')`,
|
||||||
|
attempts: schema.exerciseAttempt.attempts,
|
||||||
|
solved: schema.exerciseAttempt.solved,
|
||||||
|
attemptsToSolve: schema.exerciseAttempt.attemptsToSolve,
|
||||||
|
lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer,
|
||||||
|
lastAttemptAt: schema.exerciseAttempt.lastAttemptAt,
|
||||||
|
})
|
||||||
|
.from(schema.exercise)
|
||||||
|
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
|
||||||
|
.leftJoin(
|
||||||
|
schema.exerciseAttempt,
|
||||||
|
and(
|
||||||
|
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
|
||||||
|
eq(schema.exerciseAttempt.userId, id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(visible)
|
||||||
|
.orderBy(asc(schema.exercise.order)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const byLesson = new Map<number, LearnStudentDetail["lessons"][number]["exercises"]>()
|
||||||
|
for (const { tutorialId, ...row } of exercises) {
|
||||||
|
const list = byLesson.get(tutorialId) ?? []
|
||||||
|
list.push({
|
||||||
|
...row,
|
||||||
|
attempts: row.attempts ?? 0,
|
||||||
|
solved: row.solved ?? false,
|
||||||
|
})
|
||||||
|
byLesson.set(tutorialId, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
return success(c, {
|
||||||
|
...student,
|
||||||
|
lessons: tutorials
|
||||||
|
.map((row) => ({
|
||||||
|
tutorialId: row.tutorialId,
|
||||||
|
title: row.title,
|
||||||
|
viewCount: row.viewCount ?? 0,
|
||||||
|
totalSeconds: row.totalSeconds ?? 0,
|
||||||
|
lastViewedAt: row.lastViewedAt,
|
||||||
|
exercises: byLesson.get(row.tutorialId) ?? [],
|
||||||
|
}))
|
||||||
|
.filter((row) => row.viewCount > 0 || row.exercises.some((ex) => ex.attempts > 0)),
|
||||||
|
} satisfies LearnStudentDetail)
|
||||||
})
|
})
|
||||||
@@ -12,7 +12,8 @@ import type {
|
|||||||
GradeTrendProblem,
|
GradeTrendProblem,
|
||||||
LearnBoard,
|
LearnBoard,
|
||||||
LearnExerciseProgressList,
|
LearnExerciseProgressList,
|
||||||
LearnExerciseAttempt,
|
LearnExerciseAttemptList,
|
||||||
|
LearnStudentDetail,
|
||||||
AdminContestList,
|
AdminContestList,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
AdminUserList,
|
AdminUserList,
|
||||||
@@ -657,9 +658,14 @@ export function getLearnExercises(params: { type: "python" | "c"; className?: st
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getLearnExerciseAttempts(exerciseId: number, params: { className?: string }) {
|
export function getLearnExerciseAttempts(exerciseId: number, params: { className?: string }) {
|
||||||
return api.get<LearnExerciseAttempt[]>(`admin/learn-analytics/exercises/${exerciseId}/attempts`, {
|
return api.get<LearnExerciseAttemptList>(
|
||||||
params,
|
`admin/learn-analytics/exercises/${exerciseId}/attempts`,
|
||||||
})
|
{ params },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLearnStudent(userId: number, params: { type: "python" | "c" }) {
|
||||||
|
return api.get<LearnStudentDetail>(`admin/learn-analytics/students/${userId}`, { params })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGradeTrend() {
|
export function getGradeTrend() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { isMobile } = useBreakpoints()
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const rows = ref<LearnExerciseAttempt[]>([])
|
const rows = ref<LearnExerciseAttempt[]>([])
|
||||||
|
const untried = ref<{ userId: number; username: string }[]>([])
|
||||||
|
|
||||||
function renderResult(row: LearnExerciseAttempt) {
|
function renderResult(row: LearnExerciseAttempt) {
|
||||||
return row.solved
|
return row.solved
|
||||||
@@ -19,13 +20,12 @@ function renderResult(row: LearnExerciseAttempt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const desktopColumns: DataTableColumn<LearnExerciseAttempt>[] = [
|
const desktopColumns: DataTableColumn<LearnExerciseAttempt>[] = [
|
||||||
{ title: "班级", key: "className", width: 80 },
|
// 一个弹框只看一个班,班级列多余;用户名里本来就带名字,不再单列真名
|
||||||
{ title: "学号", key: "username", width: 140 },
|
|
||||||
{
|
{
|
||||||
title: "姓名",
|
title: "学生",
|
||||||
key: "realName",
|
key: "username",
|
||||||
width: 100,
|
width: 140,
|
||||||
render: (row) => row.realName || "-",
|
render: (row) => h(UserName, { username: row.username }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "结果",
|
title: "结果",
|
||||||
@@ -33,7 +33,7 @@ const desktopColumns: DataTableColumn<LearnExerciseAttempt>[] = [
|
|||||||
width: 130,
|
width: 130,
|
||||||
render: renderResult,
|
render: renderResult,
|
||||||
},
|
},
|
||||||
{ title: "提交次数", key: "attempts", width: 100, sorter: "default" },
|
{ title: "提交次数", key: "attempts", width: 110, sorter: "default" },
|
||||||
{
|
{
|
||||||
// 学生最后一次做错时提交的内容,前端拼好的一句人话。选择题看这一列
|
// 学生最后一次做错时提交的内容,前端拼好的一句人话。选择题看这一列
|
||||||
// 就知道全班是不是都掉进同一个干扰项
|
// 就知道全班是不是都掉进同一个干扰项
|
||||||
@@ -46,12 +46,12 @@ const desktopColumns: DataTableColumn<LearnExerciseAttempt>[] = [
|
|||||||
{
|
{
|
||||||
title: "最后作答",
|
title: "最后作答",
|
||||||
key: "lastAttemptAt",
|
key: "lastAttemptAt",
|
||||||
width: 150,
|
width: 120,
|
||||||
render: (row) => parseTime(row.lastAttemptAt, "M月D日 HH:mm"),
|
render: (row) => parseTime(row.lastAttemptAt, "M月D日 HH:mm"),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 手机上七列放不下:一个人一格,名字和结果一行,下面一行小字是交了几次、最后错在哪 */
|
/** 手机上五列放不下:一个人一格,名字和结果一行,下面一行小字是交了几次、最后错在哪 */
|
||||||
const mobileColumns: DataTableColumn<LearnExerciseAttempt>[] = [
|
const mobileColumns: DataTableColumn<LearnExerciseAttempt>[] = [
|
||||||
{
|
{
|
||||||
title: "学生",
|
title: "学生",
|
||||||
@@ -74,9 +74,11 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
rows.value = await getLearnExerciseAttempts(props.exerciseId, {
|
const data = await getLearnExerciseAttempts(props.exerciseId, {
|
||||||
className: props.className,
|
className: props.className,
|
||||||
})
|
})
|
||||||
|
rows.value = data.results
|
||||||
|
untried.value = data.untried
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -98,9 +100,46 @@ onMounted(async () => {
|
|||||||
size="small"
|
size="small"
|
||||||
style="padding: 16px 0"
|
style="padding: 16px 0"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="untried.length" class="untried">
|
||||||
|
<div class="untried-head">
|
||||||
|
<span class="strong">读了这课还没做</span>
|
||||||
|
<n-text depth="3">{{ untried.length }} 人</n-text>
|
||||||
|
</div>
|
||||||
|
<div class="chips">
|
||||||
|
<span v-for="row in untried" :key="row.userId" class="chip"
|
||||||
|
><UserName :username="row.username"
|
||||||
|
/></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.untried {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.untried-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||||
|
border-radius: 13px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
:deep(.m-row) {
|
:deep(.m-row) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { getLearnStudent } from "admin/api"
|
||||||
|
import { parseTime, readableDuration } from "utils/functions"
|
||||||
|
import type { LearnStudentDetail } from "utils/types"
|
||||||
|
import UserName from "shared/components/UserName.vue"
|
||||||
|
import { useBackClose } from "shared/composables/backClose"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
import { splitTitle, type TutorialType } from "oj/learn/lesson"
|
||||||
|
import { EXERCISE_KIND } from "./kinds"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个学生的自学明细:点方块格里的名字从右边滑出来。
|
||||||
|
*
|
||||||
|
* 一课一段,标题行后面一串小方块是这课的每道练一练(绿 = 做对,红 = 做过还没对,
|
||||||
|
* 灰 = 没做);没学完的课默认展开,逐题写着第几次做对、最后错在哪。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
userId: number | null
|
||||||
|
type: TutorialType
|
||||||
|
/** 课 id → 第几课,跟方块格表头的编号一致 */
|
||||||
|
steps: Map<number, number>
|
||||||
|
}>()
|
||||||
|
const show = defineModel<boolean>("show", { required: true })
|
||||||
|
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
useBackClose(show, () => !isDesktop.value)
|
||||||
|
|
||||||
|
type Lesson = LearnStudentDetail["lessons"][number]
|
||||||
|
type Exercise = Lesson["exercises"][number]
|
||||||
|
|
||||||
|
const detail = ref<LearnStudentDetail | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const expanded = ref(new Set<number>())
|
||||||
|
|
||||||
|
const summary = computed(() => {
|
||||||
|
const lessons = detail.value?.lessons ?? []
|
||||||
|
const exercises = lessons.flatMap((lesson) => lesson.exercises)
|
||||||
|
return {
|
||||||
|
opened: lessons.filter((lesson) => lesson.viewCount > 0).length,
|
||||||
|
seconds: lessons.reduce((n, lesson) => n + lesson.totalSeconds, 0),
|
||||||
|
solved: exercises.filter((ex) => ex.solved).length,
|
||||||
|
total: exercises.length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function solvedOf(lesson: Lesson) {
|
||||||
|
return lesson.exercises.filter((ex) => ex.solved).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateOf(ex: Exercise) {
|
||||||
|
if (ex.solved) return "solved"
|
||||||
|
return ex.attempts > 0 ? "wrong" : "todo"
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(id: number) {
|
||||||
|
const next = new Set(expanded.value)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
expanded.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [show.value, props.userId, props.type] as const,
|
||||||
|
async ([open, userId, type]) => {
|
||||||
|
if (!open || !userId) return
|
||||||
|
if (detail.value?.userId !== userId) detail.value = null
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await getLearnStudent(userId, { type })
|
||||||
|
detail.value = data
|
||||||
|
expanded.value = new Set(
|
||||||
|
data.lessons
|
||||||
|
.filter(
|
||||||
|
(lesson) =>
|
||||||
|
lesson.exercises.some((ex) => ex.attempts > 0) &&
|
||||||
|
lesson.exercises.some((ex) => !ex.solved),
|
||||||
|
)
|
||||||
|
.map((lesson) => lesson.tutorialId),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
detail.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-drawer v-model:show="show" :width="isDesktop ? 520 : '100%'" placement="right">
|
||||||
|
<n-drawer-content closable :native-scrollbar="false">
|
||||||
|
<template #header>
|
||||||
|
<div v-if="detail" class="head">
|
||||||
|
<UserName :username="detail.username" class="head-name" />
|
||||||
|
<span class="muted small num">
|
||||||
|
打开过 {{ summary.opened }} 课
|
||||||
|
<template v-if="summary.seconds">
|
||||||
|
· 停留 {{ readableDuration(summary.seconds) }}</template
|
||||||
|
>
|
||||||
|
<template v-if="summary.total">
|
||||||
|
· 练一练 {{ summary.solved }}/{{ summary.total }}</template
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-else>自学明细</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 抽屉是传送出去的,scoped CSS 里的 v-bind() 够不着,颜色挂在这层 style 上 -->
|
||||||
|
<n-spin
|
||||||
|
:show="loading"
|
||||||
|
:style="{
|
||||||
|
'--ok': theme.successColor,
|
||||||
|
'--bad': theme.errorColor,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div v-if="detail && !detail.lessons.length" class="muted none">还没打开过</div>
|
||||||
|
<div v-for="lesson in detail?.lessons ?? []" :key="lesson.tutorialId" class="lesson">
|
||||||
|
<button type="button" class="lesson-head" @click="toggle(lesson.tutorialId)">
|
||||||
|
<span class="lesson-line">
|
||||||
|
<span class="lesson-title">
|
||||||
|
<span class="muted num">第 {{ steps.get(lesson.tutorialId) }} 课</span>
|
||||||
|
{{ splitTitle(lesson.title).kp }}
|
||||||
|
</span>
|
||||||
|
<span class="grow" />
|
||||||
|
<span v-if="lesson.exercises.length" class="small num">
|
||||||
|
{{ solvedOf(lesson) }}/{{ lesson.exercises.length }}
|
||||||
|
</span>
|
||||||
|
<span class="squares">
|
||||||
|
<span
|
||||||
|
v-for="ex in lesson.exercises"
|
||||||
|
:key="ex.exerciseId"
|
||||||
|
class="square"
|
||||||
|
:class="stateOf(ex)"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="lesson-line small muted num">
|
||||||
|
<template v-if="lesson.viewCount">
|
||||||
|
打开 {{ lesson.viewCount }} 次
|
||||||
|
<template v-if="lesson.totalSeconds">
|
||||||
|
· 停留 {{ readableDuration(lesson.totalSeconds) }}</template
|
||||||
|
>
|
||||||
|
<template v-if="lesson.lastViewedAt">
|
||||||
|
· {{ parseTime(lesson.lastViewedAt, "M月D日 HH:mm") }}</template
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<template v-else>没打开过,直接做的练一练</template>
|
||||||
|
<template v-if="!lesson.exercises.length"> · 这课没有练一练</template>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="expanded.has(lesson.tutorialId) && lesson.exercises.length" class="ex-list">
|
||||||
|
<div v-for="ex in lesson.exercises" :key="ex.exerciseId" class="ex">
|
||||||
|
<span class="square" :class="stateOf(ex)" />
|
||||||
|
<div class="ex-body">
|
||||||
|
<div class="ex-question">
|
||||||
|
<span class="kind">{{ EXERCISE_KIND[ex.type] ?? ex.type }}</span>
|
||||||
|
{{ ex.question || "(没有题干)" }}
|
||||||
|
</div>
|
||||||
|
<div class="small num">
|
||||||
|
<span v-if="ex.solved" class="ok">做对了(第 {{ ex.attemptsToSolve }} 次)</span>
|
||||||
|
<template v-else-if="ex.attempts > 0">
|
||||||
|
<span class="bad">还没做对,交了 {{ ex.attempts }} 次</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="muted">没做</span>
|
||||||
|
<span v-if="ex.lastWrongAnswer" class="muted">
|
||||||
|
· 最后错在 {{ ex.lastWrongAnswer }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
|
</n-drawer-content>
|
||||||
|
</n-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.head-name {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.none {
|
||||||
|
padding: 24px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.lesson {
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
|
}
|
||||||
|
.lesson:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
.lesson-head {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lesson-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.lesson-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.squares {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.square {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: rgba(128, 128, 128, 0.18);
|
||||||
|
}
|
||||||
|
.square.solved {
|
||||||
|
background: #18a058;
|
||||||
|
}
|
||||||
|
.square.wrong {
|
||||||
|
background: var(--bad);
|
||||||
|
}
|
||||||
|
.ex-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 0 14px;
|
||||||
|
}
|
||||||
|
.ex {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.ex .square {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.ex-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.ex-question {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.kind {
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
margin-right: 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(128, 128, 128, 0.12);
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.bad {
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,33 +10,28 @@ import { groupClassOptions } from "oj/submission/utils"
|
|||||||
import { groupByProject, splitTitle, type TutorialType } from "oj/learn/lesson"
|
import { groupByProject, splitTitle, type TutorialType } from "oj/learn/lesson"
|
||||||
import "oj/learn/learn.css"
|
import "oj/learn/learn.css"
|
||||||
import ExerciseAttempts from "./ExerciseAttempts.vue"
|
import ExerciseAttempts from "./ExerciseAttempts.vue"
|
||||||
|
import StudentDrawer from "./StudentDrawer.vue"
|
||||||
|
import { EXERCISE_KIND } from "./kinds"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自学情况(设计稿「后台 · 自学情况」):一个班学到哪了。
|
* 自学情况(设计稿「后台 · 自学情况」):一个班学到哪了。
|
||||||
*
|
*
|
||||||
* 中间是学生 × 课的方块格(浅绿 = 打开过,绿 = 学完,也就是练一练全对),一课没打开过的
|
* 中间是学生 × 课的方块格(浅绿 = 打开过,绿 = 学完,也就是练一练全对;练一练做了一部分的
|
||||||
* 学生收成一串名单;右边按课看练一练做对了多少,点一道看每个人最后错在哪。
|
* 按做对的比例从下往上涨绿,一道没做对的底下压一条橙),一课没打开过的学生收成一串名单。
|
||||||
|
* 点名字看这个人每课每道练一练的明细;右边按课看练一练做对了多少,点一道看每个人最后错在哪。
|
||||||
* 不选班就按排名页同一套规则猜老师在教哪个班。
|
* 不选班就按排名页同一套规则猜老师在教哪个班。
|
||||||
*/
|
*/
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
|
|
||||||
const KIND: Record<string, string> = {
|
|
||||||
mcq: "选择",
|
|
||||||
sort: "排序",
|
|
||||||
fill: "填空",
|
|
||||||
match: "连线",
|
|
||||||
predict: "预测输出",
|
|
||||||
debug: "找错",
|
|
||||||
group: "分组",
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = ref<TutorialType>("python")
|
const type = ref<TutorialType>("python")
|
||||||
const className = ref<string | null>(null)
|
const className = ref<string | null>(null)
|
||||||
const board = ref<LearnBoard | null>(null)
|
const board = ref<LearnBoard | null>(null)
|
||||||
const exercises = ref<LearnExerciseProgress[]>([])
|
const exercises = ref<LearnExerciseProgress[]>([])
|
||||||
const lessonId = ref<number | null>(null)
|
const lessonId = ref<number | null>(null)
|
||||||
const expanded = ref<number | null>(null)
|
const expanded = ref<number | null>(null)
|
||||||
|
const studentId = ref<number | null>(null)
|
||||||
|
const showStudent = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
|
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
|
||||||
@@ -72,6 +67,15 @@ const openedCount = computed(() => {
|
|||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
})
|
})
|
||||||
|
const exerciseTotal = computed(
|
||||||
|
() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])),
|
||||||
|
)
|
||||||
|
const steps = computed(
|
||||||
|
() =>
|
||||||
|
new Map(
|
||||||
|
groups.value.flatMap((group) => group.lessons.map((lesson) => [lesson.id, lesson.step])),
|
||||||
|
),
|
||||||
|
)
|
||||||
const maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
|
const maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
|
||||||
const lessonOptions = computed(() =>
|
const lessonOptions = computed(() =>
|
||||||
groups.value.flatMap((group) =>
|
groups.value.flatMap((group) =>
|
||||||
@@ -85,15 +89,48 @@ const lessonExercises = computed(() =>
|
|||||||
exercises.value.filter((row) => row.tutorialId === lessonId.value),
|
exercises.value.filter((row) => row.tutorialId === lessonId.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function lessonOf(row: LearnBoardStudent, id: number) {
|
||||||
|
return row.lessons.find((lesson) => lesson.tutorialId === id)
|
||||||
|
}
|
||||||
|
|
||||||
function cellOf(row: LearnBoardStudent, id: number) {
|
function cellOf(row: LearnBoardStudent, id: number) {
|
||||||
if (row.done.includes(id)) return "done"
|
if (row.done.includes(id)) return "done"
|
||||||
|
const lesson = lessonOf(row, id)
|
||||||
|
if (lesson) return lesson.solved ? "partial" : "partial stuck"
|
||||||
if (row.opened.includes(id)) return "opened"
|
if (row.opened.includes(id)) return "opened"
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
|
||||||
|
function cellStyle(row: LearnBoardStudent, id: number) {
|
||||||
|
const lesson = lessonOf(row, id)
|
||||||
|
const total = exerciseTotal.value.get(id) ?? 0
|
||||||
|
if (!lesson || !total || row.done.includes(id)) return undefined
|
||||||
|
return { "--fill": `${Math.round((lesson.solved / total) * 100)}%` }
|
||||||
|
}
|
||||||
|
|
||||||
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
|
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
|
||||||
const state = cellOf(row, id)
|
const head = `第 ${step} 课 ${splitTitle(title).kp}`
|
||||||
return `第 ${step} 课 ${splitTitle(title).kp}:${state === "done" ? "学完了" : state === "opened" ? "打开过" : "没打开"}`
|
const total = exerciseTotal.value.get(id) ?? 0
|
||||||
|
const lesson = lessonOf(row, id)
|
||||||
|
const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : ""
|
||||||
|
if (row.done.includes(id)) return `${head}:学完了${wrong}`
|
||||||
|
if (lesson) return `${head}:练一练 ${lesson.solved}/${total}${wrong}`
|
||||||
|
if (row.opened.includes(id)) return `${head}:${total ? "打开过,练一练没做" : "打开过"}`
|
||||||
|
return `${head}:没打开`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应做 = 他打开过或做过练一练的那些课,练一练加起来几道 */
|
||||||
|
function exerciseDue(row: LearnBoardStudent) {
|
||||||
|
const ids = new Set([...row.opened, ...row.lessons.map((lesson) => lesson.tutorialId)])
|
||||||
|
let due = 0
|
||||||
|
for (const id of ids) due += exerciseTotal.value.get(id) ?? 0
|
||||||
|
return due
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStudent(row: LearnBoardStudent) {
|
||||||
|
studentId.value = row.userId
|
||||||
|
showStudent.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function flag(row: LearnExerciseProgress) {
|
function flag(row: LearnExerciseProgress) {
|
||||||
@@ -201,7 +238,9 @@ onMounted(load)
|
|||||||
<span class="c-last muted">最后学习</span>
|
<span class="c-last muted">最后学习</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="row in started" :key="row.userId" class="line">
|
<div v-for="row in started" :key="row.userId" class="line">
|
||||||
<span class="c-name"><UserName :username="row.username" /></span>
|
<button type="button" class="c-name name-btn" @click="openStudent(row)">
|
||||||
|
<UserName :username="row.username" />
|
||||||
|
</button>
|
||||||
<div class="cells">
|
<div class="cells">
|
||||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
|
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
|
||||||
<span
|
<span
|
||||||
@@ -209,11 +248,18 @@ onMounted(load)
|
|||||||
:key="lesson.id"
|
:key="lesson.id"
|
||||||
class="cell"
|
class="cell"
|
||||||
:class="cellOf(row, lesson.id)"
|
:class="cellOf(row, lesson.id)"
|
||||||
|
:style="cellStyle(row, lesson.id)"
|
||||||
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)"
|
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="c-num num">{{ row.exerciseSolved || "-" }}</span>
|
<span class="c-num num">
|
||||||
|
<template v-if="exerciseDue(row)"
|
||||||
|
>{{ row.exerciseSolved
|
||||||
|
}}<span class="muted">/{{ exerciseDue(row) }}</span></template
|
||||||
|
>
|
||||||
|
<template v-else>-</template>
|
||||||
|
</span>
|
||||||
<span class="c-stay num">{{ readableDuration(row.totalSeconds) }}</span>
|
<span class="c-stay num">{{ readableDuration(row.totalSeconds) }}</span>
|
||||||
<span class="c-last num muted">{{
|
<span class="c-last num muted">{{
|
||||||
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-"
|
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-"
|
||||||
@@ -266,7 +312,7 @@ onMounted(load)
|
|||||||
<div v-for="row in lessonExercises" :key="row.exerciseId" class="ex">
|
<div v-for="row in lessonExercises" :key="row.exerciseId" class="ex">
|
||||||
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
|
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
|
||||||
<span class="ex-line">
|
<span class="ex-line">
|
||||||
<span class="kind">{{ KIND[row.type] ?? row.type }}</span>
|
<span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
|
||||||
<span class="question">{{ row.question || "(没有题干)" }}</span>
|
<span class="question">{{ row.question || "(没有题干)" }}</span>
|
||||||
<Icon icon="ph:caret-right-bold" :width="12" class="muted" />
|
<Icon icon="ph:caret-right-bold" :width="12" class="muted" />
|
||||||
</span>
|
</span>
|
||||||
@@ -296,6 +342,8 @@ onMounted(load)
|
|||||||
:class-name="board?.className ?? ''"
|
:class-name="board?.className ?? ''"
|
||||||
/>
|
/>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
|
<StudentDrawer v-model:show="showStudent" :user-id="studentId" :type="type" :steps="steps" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -464,8 +512,26 @@ onMounted(load)
|
|||||||
.cell.done {
|
.cell.done {
|
||||||
background: #18a058;
|
background: #18a058;
|
||||||
}
|
}
|
||||||
|
.cell.partial {
|
||||||
|
background: linear-gradient(to top, #18a058 var(--fill), rgba(24, 160, 88, 0.3) var(--fill));
|
||||||
|
}
|
||||||
|
.cell.stuck {
|
||||||
|
box-shadow: inset 0 -4px 0 v-bind("theme.warningColor");
|
||||||
|
}
|
||||||
|
.name-btn {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.name-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
.c-num {
|
.c-num {
|
||||||
width: 52px;
|
width: 56px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** 练一练的题型,后台显示用的短名 */
|
||||||
|
export const EXERCISE_KIND: Record<string, string> = {
|
||||||
|
mcq: "选择",
|
||||||
|
sort: "排序",
|
||||||
|
fill: "填空",
|
||||||
|
match: "连线",
|
||||||
|
predict: "预测输出",
|
||||||
|
debug: "找错",
|
||||||
|
group: "分组",
|
||||||
|
}
|
||||||
@@ -335,6 +335,8 @@ export type {
|
|||||||
LearnExerciseProgress,
|
LearnExerciseProgress,
|
||||||
LearnExerciseProgressList,
|
LearnExerciseProgressList,
|
||||||
LearnExerciseAttempt,
|
LearnExerciseAttempt,
|
||||||
|
LearnExerciseAttemptList,
|
||||||
|
LearnStudentDetail,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -654,6 +654,18 @@ export const learnBoardStudentSchema = z.object({
|
|||||||
opened: z.array(z.number().int()),
|
opened: z.array(z.number().int()),
|
||||||
done: z.array(z.number().int()),
|
done: z.array(z.number().int()),
|
||||||
exerciseSolved: z.number().int(),
|
exerciseSolved: z.number().int(),
|
||||||
|
/**
|
||||||
|
* 碰过练一练的课,一课一项(一道没做的课不在里面)。方块格靠它分出「做了一部分」,
|
||||||
|
* `wrongAttempts` 是这课所有练习错的次数加起来
|
||||||
|
*/
|
||||||
|
lessons: z.array(
|
||||||
|
z.object({
|
||||||
|
tutorialId: z.number().int(),
|
||||||
|
tried: z.number().int(),
|
||||||
|
solved: z.number().int(),
|
||||||
|
wrongAttempts: z.number().int(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
totalSeconds: z.number().int(),
|
totalSeconds: z.number().int(),
|
||||||
lastViewedAt: z.string().nullable(),
|
lastViewedAt: z.string().nullable(),
|
||||||
})
|
})
|
||||||
@@ -712,6 +724,43 @@ export const learnExerciseAttemptSchema = z.object({
|
|||||||
lastAttemptAt: z.string(),
|
lastAttemptAt: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const learnExerciseAttemptListSchema = z.object({
|
||||||
|
results: z.array(learnExerciseAttemptSchema),
|
||||||
|
/** 打开过这一课、这道练习却一次没交的人 */
|
||||||
|
untried: z.array(z.object({ userId: z.number().int(), username: z.string() })),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个学生的自学明细(点方块格里的名字):他碰过的每一课,每道练一练做到哪了。
|
||||||
|
* 一课没碰过的不下发;练习没做过的 `attempts` 为 0、其余字段为空。
|
||||||
|
*/
|
||||||
|
export const learnStudentDetailSchema = z.object({
|
||||||
|
userId: z.number().int(),
|
||||||
|
username: z.string(),
|
||||||
|
className: z.string().nullable(),
|
||||||
|
lessons: z.array(
|
||||||
|
z.object({
|
||||||
|
tutorialId: z.number().int(),
|
||||||
|
title: z.string(),
|
||||||
|
viewCount: z.number().int(),
|
||||||
|
totalSeconds: z.number().int(),
|
||||||
|
lastViewedAt: z.string().nullable(),
|
||||||
|
exercises: z.array(
|
||||||
|
z.object({
|
||||||
|
exerciseId: z.number().int(),
|
||||||
|
type: exerciseTypeSchema,
|
||||||
|
question: z.string(),
|
||||||
|
attempts: z.number().int(),
|
||||||
|
solved: z.boolean(),
|
||||||
|
attemptsToSolve: z.number().int().nullable(),
|
||||||
|
lastWrongAnswer: z.string().nullable(),
|
||||||
|
lastAttemptAt: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
export const generateFlowchartRequestSchema = z.object({
|
export const generateFlowchartRequestSchema = z.object({
|
||||||
python: z
|
python: z
|
||||||
.string()
|
.string()
|
||||||
@@ -1056,6 +1105,8 @@ export type LearnBoardStudent = z.infer<typeof learnBoardStudentSchema>
|
|||||||
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
|
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
|
||||||
export type LearnExerciseProgressList = z.infer<typeof learnExerciseProgressListSchema>
|
export type LearnExerciseProgressList = z.infer<typeof learnExerciseProgressListSchema>
|
||||||
export type LearnExerciseAttempt = z.infer<typeof learnExerciseAttemptSchema>
|
export type LearnExerciseAttempt = z.infer<typeof learnExerciseAttemptSchema>
|
||||||
|
export type LearnExerciseAttemptList = z.infer<typeof learnExerciseAttemptListSchema>
|
||||||
|
export type LearnStudentDetail = z.infer<typeof learnStudentDetailSchema>
|
||||||
|
|
||||||
export type AdminTag = z.infer<typeof adminTagSchema>
|
export type AdminTag = z.infer<typeof adminTagSchema>
|
||||||
export type RenameTagResponse = z.infer<typeof renameTagResponseSchema>
|
export type RenameTagResponse = z.infer<typeof renameTagResponseSchema>
|
||||||
|
|||||||
Reference in new issue
Block a user