Compare commits

...
2 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 1ebb23cdac feat(自学情况): 看得到配套题做没做——每人一列、每课一个页签、学生明细里逐题列出
Deploy / deploy (push) Canceled after 0s
- 方块格多一列「配套题」:他打开过的那些课挂的题,做对几道 / 一共几道;格子提示里也带上
- 右栏分「练一练 / 配套题」两个页签:每道题读过这课的人里几人做对,点开看谁没交、谁交了没对,
  一键跳到这个人这道题的提交
- 学生明细每课多一段配套题:做对没有、交了几次、最后哪天交的
- 做对不论早晚,读教程之前在课上做对的也算;口径同学生端课文里那个勾(代码 AC 或流程图 A / S)
- 1366 宽下 C 语言 20 课也不用横着滑了:「最后学习」只留日期,几列收窄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:21:34 +08:00
xuyueandClaude Opus 5.5 7839a65f59 feat(自学情况): 每个学生练一练做到哪了——格子分出做了一部分、点名字看逐题明细
- 方块格多一档「做了一部分」:按做对的比例从下往上涨绿,一道没做对的底下压一条橙;
  鼠标停上去是「练一练 3/5,错了 4 次」
- 「练一练」列改成 做对/应做(应做 = 他打开过的课的练一练总数)
- 点学生名字从右边滑出明细:每课一段,逐题写第几次做对、交了几次、最后错在哪;
  手机上返回键关它
- 点开一道练一练,底下多一串「读了这课还没做」的人
- 顺手修:那个弹框七列加起来比弹框宽,「最后作答」被挤没了;改成一列用户名

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:40:30 +08:00
10 changed files with 1108 additions and 64 deletions

No files matched your search

+2 -3
View File
@@ -1301,9 +1301,8 @@ export const problemsetBadge = pgTable(
* OJ2 自己建的表,不是 Django 遗留,所以没有代理主键 —— 写入路径只有一条 upsert,
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
*
* **只记「读到哪一课 + 停留多久」,不记练一练的作答。** 练习的对错全在浏览器里判
* (见 apps/web/src/oj/learn/components/),学生可以随便重试到对为止,上报上来也只是
* 「他按了几次按钮」,不构成教学证据,还要为此多养一张按人按题膨胀的表。
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isLessonDone`)。
*
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
+251 -11
View File
@@ -1,19 +1,23 @@
import {
FLOWCHART_PASS_GRADES,
STUDENT_ROLES,
isLessonDone,
type LearnBoard,
type LearnExerciseAttempt,
type LearnBoardStudent,
type LearnExerciseAttemptList,
type LearnExerciseProgress,
type LearnExerciseProgressList,
type LearnStudentDetail,
} 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 { requireTeacher, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { accepted } from "../../services/learning-stats"
import { defaultClassFor } from "../../services/ranking"
import { exerciseCountColumn } from "../../services/tutorial"
import { exerciseCountColumn, tutorialProblems } from "../../services/tutorial"
import { queryInteger, rounded } from "../helpers"
/**
@@ -63,6 +67,59 @@ function studentCondition(value: string | null) {
)
}
/**
* 这些人在这些题上交过几次、做对没有。代码提交(不含比赛)和流程图提交并起来数,
* 做对 = 代码 AC / AST_CHECK_FAILED 或流程图评到 A / S —— 和学生端课文里那个勾、
* 课堂看板(routes/classroom.ts 的 activityRows)同一个口径。不论早晚:读这课之前
* 在课上就做对的照样算。两边都按 `(problem_id, user_id)` 索引走。
*/
async function problemActivity(userIds: number[], problemIds: number[]) {
if (!userIds.length || !problemIds.length) return []
const code = db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
createTime: schema.submission.createTime,
solved: sql<boolean>`${inArray(schema.submission.result, accepted)}`.as("solved"),
})
.from(schema.submission)
.where(
and(
isNull(schema.submission.contestId),
inArray(schema.submission.userId, userIds),
inArray(schema.submission.problemId, problemIds),
),
)
const flowchart = db
.select({
userId: schema.flowchartSubmission.userId,
problemId: schema.flowchartSubmission.problemId,
createTime: schema.flowchartSubmission.createTime,
solved: sql<boolean>`${and(
eq(schema.flowchartSubmission.status, 2),
inArray(schema.flowchartSubmission.aiGrade, [...FLOWCHART_PASS_GRADES]),
)}`.as("solved"),
})
.from(schema.flowchartSubmission)
.where(
and(
inArray(schema.flowchartSubmission.userId, userIds),
inArray(schema.flowchartSubmission.problemId, problemIds),
),
)
const activity = code.unionAll(flowchart).as("activity")
return db
.select({
userId: activity.userId,
problemId: activity.problemId,
solved: sql<boolean>`bool_or(${activity.solved})`,
submissions: count(),
lastSubmitAt: sql<string>`max(${activity.createTime})`,
})
.from(activity)
.groupBy(activity.userId, activity.problemId)
}
/**
* 方块格:一个班的学生 × 这门课的每一课,打开过没有、学完没有(练一练全对)。
*
@@ -85,6 +142,18 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
.from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.orderBy(asc(schema.tutorial.order))
.then(async (rows) => {
const problems = await tutorialProblems(
rows.map((row) => row.id),
true,
)
return rows.map((row) => ({
...row,
problems: problems
.filter((problem) => problem.tutorialId === row.id)
.map(({ id, displayId, title }) => ({ id, displayId, title })),
}))
})
const tutorialIds = tutorials.map((row) => row.id)
if (!className)
return success(c, { className: null, tutorials, students: [] } satisfies LearnBoard)
@@ -102,7 +171,15 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
.orderBy(asc(schema.user.className), asc(schema.user.username))
const userIds = students.map((row) => row.userId)
// 阅读和练习分两条查再拼:两个一对多挂在同一张表上 join,count 全是错的
// 阅读、练习、配套题分开查再拼:几个一对多挂在同一张表上 join,count 全是错的
const problemIds = [...new Set(tutorials.flatMap((row) => row.problems.map((p) => p.id)))]
const problemRows = await problemActivity(userIds, problemIds)
const problemsOf = new Map<number, LearnBoardStudent["problems"]>()
for (const { userId, ...row } of problemRows) {
const list = problemsOf.get(userId) ?? []
list.push(row)
problemsOf.set(userId, list)
}
const [progressRows, solvedRows] =
userIds.length && tutorialIds.length
? await Promise.all([
@@ -125,7 +202,14 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
.select({
userId: schema.exerciseAttempt.userId,
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)
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId))
@@ -133,7 +217,6 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
and(
inArray(schema.exerciseAttempt.userId, userIds),
inArray(schema.exercise.tutorialId, tutorialIds),
eq(schema.exerciseAttempt.solved, true),
),
)
.groupBy(schema.exerciseAttempt.userId, schema.exercise.tutorialId),
@@ -142,8 +225,8 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
const progress = new Map<string, (typeof progressRows)[number]>()
for (const row of progressRows) progress.set(`${row.userId}:${row.tutorialId}`, row)
const solved = new Map<string, number>()
for (const row of solvedRows) solved.set(`${row.userId}:${row.tutorialId}`, row.solved)
const attempted = new Map<string, (typeof solvedRows)[number]>()
for (const row of solvedRows) attempted.set(`${row.userId}:${row.tutorialId}`, row)
return success(c, {
className,
@@ -151,14 +234,20 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
students: students.map((student) => {
const opened: number[] = []
const done: number[] = []
const lessons: LearnBoardStudent["lessons"] = []
let exerciseSolved = 0
let totalSeconds = 0
let lastViewedAt: string | null = null
for (const tutorial of tutorials) {
const key = `${student.userId}:${tutorial.id}`
const row = progress.get(key)
const solvedHere = solved.get(key) ?? 0
const exercises = attempted.get(key)
const solvedHere = exercises?.solved ?? 0
exerciseSolved += solvedHere
if (exercises) {
const { tried, solved, wrongAttempts } = exercises
lessons.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts })
}
if (row) {
opened.push(tutorial.id)
totalSeconds += row.totalSeconds
@@ -171,7 +260,16 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
}
if (isLessonDone(lesson)) done.push(tutorial.id)
}
return { ...student, opened, done, exerciseSolved, totalSeconds, lastViewedAt }
return {
...student,
opened,
done,
exerciseSolved,
lessons,
problems: problemsOf.get(student.userId) ?? [],
totalSeconds,
lastViewedAt,
}
}),
} satisfies LearnBoard)
})
@@ -271,5 +369,147 @@ adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher,
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
.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 related = await tutorialProblems(
tutorials.map((row) => row.tutorialId),
true,
)
const activity = new Map(
(
await problemActivity(
[id],
related.map((row) => row.id),
)
).map((row) => [row.problemId, row]),
)
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) ?? [],
problems: related
.filter((problem) => problem.tutorialId === row.tutorialId)
.map(({ id: problemId, displayId, title }) => {
const done = activity.get(problemId)
return {
id: problemId,
displayId,
title,
solved: done?.solved ?? false,
submissions: done?.submissions ?? 0,
lastSubmitAt: done?.lastSubmitAt ?? null,
}
}),
}))
.filter((row) => row.viewCount > 0 || row.exercises.some((ex) => ex.attempts > 0)),
} satisfies LearnStudentDetail)
})
+10 -4
View File
@@ -12,7 +12,8 @@ import type {
GradeTrendProblem,
LearnBoard,
LearnExerciseProgressList,
LearnExerciseAttempt,
LearnExerciseAttemptList,
LearnStudentDetail,
AdminContestList,
AdminUser,
AdminUserList,
@@ -657,9 +658,14 @@ export function getLearnExercises(params: { type: "python" | "c"; className?: st
}
export function getLearnExerciseAttempts(exerciseId: number, params: { className?: string }) {
return api.get<LearnExerciseAttempt[]>(`admin/learn-analytics/exercises/${exerciseId}/attempts`, {
params,
})
return api.get<LearnExerciseAttemptList>(
`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() {
+49 -10
View File
@@ -11,6 +11,7 @@ const { isMobile } = useBreakpoints()
const loading = ref(true)
const rows = ref<LearnExerciseAttempt[]>([])
const untried = ref<{ userId: number; username: string }[]>([])
function renderResult(row: LearnExerciseAttempt) {
return row.solved
@@ -19,13 +20,12 @@ function renderResult(row: LearnExerciseAttempt) {
}
const desktopColumns: DataTableColumn<LearnExerciseAttempt>[] = [
{ title: "班级", key: "className", width: 80 },
{ title: "学号", key: "username", width: 140 },
// 一个弹框只看一个班,班级列多余;用户名里本来就带名字,不再单列真名
{
title: "姓名",
key: "realName",
width: 100,
render: (row) => row.realName || "-",
title: "学生",
key: "username",
width: 140,
render: (row) => h(UserName, { username: row.username }),
},
{
title: "结果",
@@ -33,7 +33,7 @@ const desktopColumns: DataTableColumn<LearnExerciseAttempt>[] = [
width: 130,
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: "最后作答",
key: "lastAttemptAt",
width: 150,
width: 120,
render: (row) => parseTime(row.lastAttemptAt, "M月D日 HH:mm"),
},
]
/** 手机上七列放不下:一个人一格,名字和结果一行,下面一行小字是交了几次、最后错在哪 */
/** 手机上五列放不下:一个人一格,名字和结果一行,下面一行小字是交了几次、最后错在哪 */
const mobileColumns: DataTableColumn<LearnExerciseAttempt>[] = [
{
title: "学生",
@@ -74,9 +74,11 @@ const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns)
onMounted(async () => {
try {
rows.value = await getLearnExerciseAttempts(props.exerciseId, {
const data = await getLearnExerciseAttempts(props.exerciseId, {
className: props.className,
})
rows.value = data.results
untried.value = data.untried
} finally {
loading.value = false
}
@@ -98,9 +100,46 @@ onMounted(async () => {
size="small"
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>
<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) {
display: flex;
flex-direction: column;
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
import type { LearnBoardStudent } from "utils/types"
/** 读过这课的一个人,和他在这道配套题上的情况(一次没交是 null) */
export interface ProblemReader {
userId: number
username: string
state: LearnBoardStudent["problems"][number] | null
}
</script>
<script setup lang="ts">
import { NText } from "naive-ui"
import { parseTime } from "utils/functions"
import UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 一道配套题,读过这课的人各自做没做对。数据就是方块格那份,不另外请求。
* 交过没对的排最前(老师点进来是找卡住的人),其次一次没交的,做对的垫底。
*/
const props = defineProps<{ displayId: string; people: ProblemReader[] }>()
const router = useRouter()
const { isMobile } = useBreakpoints()
function rank(row: ProblemReader) {
if (!row.state) return 1
return row.state.solved ? 2 : 0
}
const rows = computed(() => [...props.people].sort((a, b) => rank(a) - rank(b)))
function renderResult(row: ProblemReader) {
if (!row.state) return h(NText, { depth: 3 }, () => "没交")
return row.state.solved
? h(NText, { type: "success" }, () => "做对了")
: h(NText, { type: "error" }, () => `交了 ${row.state!.submissions} 次还没对`)
}
/** 提交列表按这个人、这道题筛好,新标签页打开,后台这页不动 */
function renderLink(row: ProblemReader) {
if (!row.state) return null
const { href } = router.resolve({
path: "/submission",
query: { problem: props.displayId, username: row.username, exactUsername: "1" },
})
return h("a", { href, target: "_blank", class: "link" }, "看提交")
}
const desktopColumns: DataTableColumn<ProblemReader>[] = [
{
title: "学生",
key: "username",
width: 150,
render: (row) => h(UserName, { username: row.username }),
},
{ title: "结果", key: "result", width: 150, render: renderResult },
{
title: "最后提交",
key: "lastSubmitAt",
width: 150,
render: (row) =>
row.state ? parseTime(row.state.lastSubmitAt, "YYYY年M月D日") : h(NText, { depth: 3 }, "-"),
},
{ title: "", key: "link", render: renderLink },
]
const mobileColumns: DataTableColumn<ProblemReader>[] = [
{
title: "学生",
key: "username",
render: (row) =>
h("div", { class: "m-line" }, [
h(UserName, { username: row.username }),
h("span", { class: "m-right" }, [renderResult(row), renderLink(row)]),
]),
},
]
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
</script>
<template>
<n-data-table
striped
:columns="columns"
:data="rows"
:row-key="(row: ProblemReader) => row.userId"
size="small"
:pagination="rows.length > 10 ? { pageSize: 10 } : false"
/>
</template>
<style scoped>
:deep(.link) {
color: #18a058;
text-decoration: none;
}
:deep(.link:hover) {
text-decoration: underline;
}
:deep(.m-line) {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
:deep(.m-right) {
display: flex;
gap: 12px;
flex-shrink: 0;
}
</style>
+367
View File
@@ -0,0 +1,367 @@
<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 router = useRouter()
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]
type Problem = Lesson["problems"][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)
// 一道题挂在两课只算一次
const problems = new Map(lessons.flatMap((lesson) => lesson.problems).map((p) => [p.id, p]))
return {
problemSolved: [...problems.values()].filter((p) => p.solved).length,
problemTotal: problems.size,
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 problemStateOf(problem: Problem) {
if (problem.solved) return "solved"
return problem.submissions > 0 ? "wrong" : "todo"
}
/** 提交列表按这个人、这道题筛好,新标签页打开 */
function submissionsHref(problem: Problem) {
return router.resolve({
path: "/submission",
query: { problem: problem.displayId, username: detail.value?.username, exactUsername: "1" },
}).href
}
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)) ||
lesson.problems.some((p) => p.submissions > 0 && !p.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
>
<template v-if="summary.problemTotal">
· 配套题 {{ summary.problemSolved }}/{{ summary.problemTotal }}</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>
<template v-if="lesson.problems.length">
· 配套题 {{ lesson.problems.filter((p) => p.solved).length }}/{{
lesson.problems.length
}}</template
>
</span>
</button>
<div
v-if="
expanded.has(lesson.tutorialId) && (lesson.exercises.length || lesson.problems.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 v-if="lesson.problems.length" class="sub-head small muted">配套题</div>
<div v-for="problem in lesson.problems" :key="problem.id" class="ex">
<span class="square" :class="problemStateOf(problem)" />
<div class="ex-body">
<div class="ex-question">
<span class="kind num">{{ problem.displayId }}</span>
{{ problem.title }}
</div>
<div class="small num">
<span v-if="problem.solved" class="ok">做对了</span>
<span v-else-if="problem.submissions" class="bad"
>交了 {{ problem.submissions }} 次还没对</span
>
<span v-else class="muted">没交</span>
<template v-if="problem.lastSubmitAt">
<span class="muted">
· {{ parseTime(problem.lastSubmitAt, "YYYY年M月D日") }} ·
</span>
<a :href="submissionsHref(problem)" target="_blank" class="link">看提交</a>
</template>
</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);
}
.sub-head {
margin-top: 4px;
}
.link {
color: #18a058;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.bad {
color: var(--bad);
}
</style>
+224 -36
View File
@@ -10,33 +10,32 @@ import { groupClassOptions } from "oj/submission/utils"
import { groupByProject, splitTitle, type TutorialType } from "oj/learn/lesson"
import "oj/learn/learn.css"
import ExerciseAttempts from "./ExerciseAttempts.vue"
import ProblemReaders, { type ProblemReader } from "./ProblemReaders.vue"
import StudentDrawer from "./StudentDrawer.vue"
import { EXERCISE_KIND } from "./kinds"
/**
* 自学情况(设计稿「后台 · 自学情况」):一个班学到哪了。
*
* 中间是学生 × 课的方块格(浅绿 = 打开过,绿 = 学完,也就是练一练全对),一课没打开过的
* 学生收成一串名单;右边按课看练一练做对了多少,点一道看每个人最后错在哪。
* 中间是学生 × 课的方块格(浅绿 = 打开过,绿 = 学完,也就是练一练全对;练一练做了一部分的
* 按做对的比例从下往上涨绿,一道没做对的底下压一条橙),一课没打开过的学生收成一串名单。
* 点名字看这个人每课每道练一练、每道配套题的明细;右边按课看练一练和配套题做对了多少,
* 点一道看每个人的情况。配套题做对不论早晚,读这课之前在课上做对的也算。
* 不选班就按排名页同一套规则猜老师在教哪个班。
*/
const configStore = useConfigStore()
const theme = useThemeVars()
const KIND: Record<string, string> = {
mcq: "选择",
sort: "排序",
fill: "填空",
match: "连线",
predict: "预测输出",
debug: "找错",
group: "分组",
}
const type = ref<TutorialType>("python")
const className = ref<string | null>(null)
const board = ref<LearnBoard | null>(null)
const exercises = ref<LearnExerciseProgress[]>([])
const lessonId = ref<number | null>(null)
const expanded = ref<number | null>(null)
const side = ref<"exercise" | "problem">("exercise")
const problemOpen = ref<number | null>(null)
const studentId = ref<number | null>(null)
const showStudent = ref(false)
const loading = ref(false)
const classOptions = computed(() => groupClassOptions(configStore.config?.classList ?? []))
@@ -72,6 +71,28 @@ const openedCount = computed(() => {
}
return counts
})
const lessonProblems = computed(
() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])),
)
/** 学生 → 题 → 交过的情况 */
const problemStates = computed(
() =>
new Map(
(board.value?.students ?? []).map((row) => [
row.userId,
new Map(row.problems.map((problem) => [problem.problemId, problem])),
]),
),
)
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 lessonOptions = computed(() =>
groups.value.flatMap((group) =>
@@ -84,16 +105,94 @@ const lessonOptions = computed(() =>
const lessonExercises = computed(() =>
exercises.value.filter((row) => row.tutorialId === lessonId.value),
)
/** 读过(或做过练一练)这一课的人 */
function readersOf(id: number) {
return started.value.filter(
(row) => row.opened.includes(id) || row.lessons.some((lesson) => lesson.tutorialId === id),
)
}
const sideProblems = computed(() => {
const id = lessonId.value
if (id === null) return []
const readers = readersOf(id)
return (lessonProblems.value.get(id) ?? []).map((problem) => {
const people: ProblemReader[] = readers.map((row) => ({
userId: row.userId,
username: row.username,
state: problemStates.value.get(row.userId)?.get(problem.id) ?? null,
}))
return {
...problem,
people,
solved: people.filter((person) => person.state?.solved).length,
}
})
})
const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value))
function lessonOf(row: LearnBoardStudent, id: number) {
return row.lessons.find((lesson) => lesson.tutorialId === id)
}
function cellOf(row: LearnBoardStudent, id: number) {
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"
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) {
const state = cellOf(row, id)
return `第 ${step} 课 ${splitTitle(title).kp}:${state === "done" ? "学完了" : state === "opened" ? "打开过" : "没打开"}`
const head = `第 ${step} 课 ${splitTitle(title).kp}`
const total = exerciseTotal.value.get(id) ?? 0
const lesson = lessonOf(row, id)
const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : ""
const problems = lessonProblems.value.get(id) ?? []
const states = problemStates.value.get(row.userId)
const tail = problems.length
? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}`
: ""
if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}`
if (lesson) return `${head}:练一练 ${lesson.solved}/${total}${wrong}${tail}`
if (row.opened.includes(id)) return `${head}:${total ? "打开过,练一练没做" : "打开过"}${tail}`
return `${head}:没打开`
}
/** 他打开过或做过练一练的那些课 */
function touched(row: LearnBoardStudent) {
return new Set([...row.opened, ...row.lessons.map((lesson) => lesson.tutorialId)])
}
/** 应做 = 他碰过的那些课,练一练加起来几道 */
function exerciseDue(row: LearnBoardStudent) {
let due = 0
for (const id of touched(row)) due += exerciseTotal.value.get(id) ?? 0
return due
}
/** 他碰过的那些课的配套题,做对几道 / 一共几道(一道题挂在两课只算一次) */
function problemCount(row: LearnBoardStudent) {
const ids = new Set<number>()
for (const id of touched(row)) {
for (const problem of lessonProblems.value.get(id) ?? []) ids.add(problem.id)
}
const states = problemStates.value.get(row.userId)
let solved = 0
for (const id of ids) if (states?.get(id)?.solved) solved++
return { solved, total: ids.size }
}
function openStudent(row: LearnBoardStudent) {
studentId.value = row.userId
showStudent.value = true
}
function flag(row: LearnExerciseProgress) {
@@ -197,11 +296,14 @@ onMounted(load)
</div>
</div>
<span class="c-num muted">练一练</span>
<span class="c-num muted">配套题</span>
<span class="c-stay muted">停留</span>
<span class="c-last muted">最后学习</span>
</div>
<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 v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
<span
@@ -209,14 +311,28 @@ onMounted(load)
:key="lesson.id"
class="cell"
:class="cellOf(row, lesson.id)"
:style="cellStyle(row, lesson.id)"
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)"
/>
</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-num num">
<template v-if="problemCount(row).total"
>{{ problemCount(row).solved
}}<span class="muted">/{{ problemCount(row).total }}</span></template
>
<template v-else>-</template>
</span>
<span class="c-stay num">{{ readableDuration(row.totalSeconds) }}</span>
<span class="c-last num muted">{{
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-"
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日") : "-"
}}</span>
</div>
<div v-if="!started.length" class="none muted">这个班还没人打开过</div>
@@ -253,8 +369,18 @@ onMounted(load)
<section class="card side">
<div class="side-head">
<span class="strong">练一练</span>
<span class="grow" />
<div class="seg small">
<button
type="button"
:class="{ on: side === 'exercise' }"
@click="side = 'exercise'"
>
练一练
</button>
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">
配套题
</button>
</div>
<n-select
v-model:value="lessonId"
:options="lessonOptions"
@@ -262,11 +388,30 @@ onMounted(load)
class="lesson-select"
/>
</div>
<div v-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
<div v-for="row in lessonExercises" :key="row.exerciseId" class="ex">
<template v-if="side === 'problem'">
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
<div v-for="row in sideProblems" :key="row.id" class="ex">
<button type="button" class="ex-main" @click="problemOpen = row.id">
<span class="ex-line">
<span class="kind num">{{ row.displayId }}</span>
<span class="question">{{ row.title }}</span>
<Icon icon="ph:caret-right-bold" :width="12" class="muted" />
</span>
<span class="ex-line small num">
<span>做对 {{ row.solved }} / 读过 {{ row.people.length }}</span>
</span>
</button>
</div>
</template>
<div v-else-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
<div
v-for="row in side === 'exercise' ? lessonExercises : []"
:key="row.exerciseId"
class="ex"
>
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
<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>
<Icon icon="ph:caret-right-bold" :width="12" class="muted" />
</span>
@@ -296,6 +441,23 @@ onMounted(load)
:class-name="board?.className ?? ''"
/>
</n-modal>
<!-- 点一道配套题:读过这课的人各自做没做对 -->
<n-modal
:show="problemOpen !== null"
preset="card"
:title="openProblem ? `${openProblem.displayId} ${openProblem.title}` : '配套题'"
style="width: 640px; max-width: 95vw"
@update:show="(show: boolean) => !show && (problemOpen = null)"
>
<ProblemReaders
v-if="openProblem"
:display-id="openProblem.displayId"
:people="openProblem.people"
/>
</n-modal>
<StudentDrawer v-model:show="showStudent" :user-id="studentId" :type="type" :steps="steps" />
</div>
</template>
@@ -349,6 +511,15 @@ onMounted(load)
border-radius: 0 4px 4px 0;
margin-left: -1px;
}
.seg.small {
flex-shrink: 0;
}
.seg.small button {
height: 26px;
padding: 0 10px;
font-size: 12px;
white-space: nowrap;
}
.seg button.on {
border-color: #18a058;
color: var(--learn-green-text);
@@ -400,7 +571,7 @@ onMounted(load)
.line {
display: flex;
align-items: center;
gap: 10px;
gap: 8px;
min-height: 34px;
padding: 0 16px;
border-top: 1px solid rgba(128, 128, 128, 0.1);
@@ -415,7 +586,7 @@ onMounted(load)
border-top: 0;
}
.c-name {
width: 118px;
width: 110px;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
@@ -425,7 +596,7 @@ onMounted(load)
.cells,
.cells-head {
display: flex;
gap: 8px;
gap: 6px;
}
.group-head {
display: flex;
@@ -464,21 +635,39 @@ onMounted(load)
.cell.done {
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 {
width: 52px;
width: 44px;
text-align: right;
font-size: 13px;
flex-shrink: 0;
}
.c-stay {
width: 80px;
width: 70px;
text-align: right;
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.c-last {
width: 92px;
width: 64px;
text-align: right;
font-size: 13px;
white-space: nowrap;
@@ -544,12 +733,13 @@ onMounted(load)
font-size: 12px;
}
.side {
width: 270px;
width: 250px;
flex-shrink: 0;
padding: 14px 16px;
}
.side-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 4px;
@@ -597,10 +787,13 @@ onMounted(load)
.flag {
color: var(--learn-orange-text);
}
.class-select,
.lesson-select {
.class-select {
width: 170px;
}
.lesson-select {
flex: 1 0 100%;
min-width: 0;
}
/* 手机上:方块格在自己的框里横着滑(名字列钉在左边),练一练排到下面 */
@media (max-width: 768px) {
@@ -650,10 +843,5 @@ onMounted(load)
.side {
width: auto;
}
.lesson-select {
flex: 1;
width: auto;
min-width: 0;
}
}
</style>
+10
View File
@@ -0,0 +1,10 @@
/** 练一练的题型,后台显示用的短名 */
export const EXERCISE_KIND: Record<string, string> = {
mcq: "选择",
sort: "排序",
fill: "填空",
match: "连线",
predict: "预测输出",
debug: "找错",
group: "分组",
}
+2
View File
@@ -335,6 +335,8 @@ export type {
LearnExerciseProgress,
LearnExerciseProgressList,
LearnExerciseAttempt,
LearnExerciseAttemptList,
LearnStudentDetail,
} from "@oj2/contract"
/**
+80
View File
@@ -639,6 +639,18 @@ export const gradeTrendProblemSchema = z.object({
// ---------------------------------------------------------------- 自学情况
/**
* 一个学生在一道配套题上的状态。「做对」不论早晚 —— 读这课之前在课上就做对的也算,
* 口径同学生端课文里的勾:代码 AC(含 AST_CHECK_FAILED)或流程图评到 A / S
*/
const learnProblemStateSchema = z.object({
problemId: z.number().int(),
solved: z.boolean(),
/** 代码和流程图一共交了几次,比赛提交不算 */
submissions: z.number().int(),
lastSubmitAt: z.string(),
})
/**
* 「自学情况」的方块格:一个班的学生 × 这门课的每一课。
*
@@ -654,6 +666,20 @@ export const learnBoardStudentSchema = z.object({
opened: z.array(z.number().int()),
done: z.array(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(),
}),
),
/** 交过的配套题(一次没交的不在里面),哪一课的题由 `tutorials[].problems` 对 */
problems: z.array(learnProblemStateSchema),
totalSeconds: z.number().int(),
lastViewedAt: z.string().nullable(),
})
@@ -667,6 +693,10 @@ export const learnBoardSchema = z.object({
title: z.string(),
project: z.string(),
exerciseTotal: z.number().int(),
/** 这课的配套题,按老师排的顺序;藏起来的题不算 */
problems: z.array(
z.object({ id: z.number().int(), displayId: z.string(), title: z.string() }),
),
}),
),
students: z.array(learnBoardStudentSchema),
@@ -712,6 +742,54 @@ export const learnExerciseAttemptSchema = z.object({
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(),
}),
),
/** 配套题,没交过的 `submissions` 为 0、`lastSubmitAt` 为空 */
problems: z.array(
z.object({
id: z.number().int(),
displayId: z.string(),
title: z.string(),
solved: z.boolean(),
submissions: z.number().int(),
lastSubmitAt: z.string().nullable(),
}),
),
}),
),
})
export const generateFlowchartRequestSchema = z.object({
python: z
.string()
@@ -1056,6 +1134,8 @@ export type LearnBoardStudent = z.infer<typeof learnBoardStudentSchema>
export type LearnExerciseProgress = z.infer<typeof learnExerciseProgressSchema>
export type LearnExerciseProgressList = z.infer<typeof learnExerciseProgressListSchema>
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 RenameTagResponse = z.infer<typeof renameTagResponseSchema>