- 方块格多一列「配套题」:他打开过的那些课挂的题,做对几道 / 一共几道;格子提示里也带上 - 右栏分「练一练 / 配套题」两个页签:每道题读过这课的人里几人做对,点开看谁没交、谁交了没对, 一键跳到这个人这道题的提交 - 学生明细每课多一段配套题:做对没有、交了几次、最后哪天交的 - 做对不论早晚,读教程之前在课上做对的也算;口径同学生端课文里那个勾(代码 AC 或流程图 A / S) - 1366 宽下 C 语言 20 课也不用横着滑了:「最后学习」只留日期,几列收窄 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7839a65f59
commit
1ebb23cdac
5 files changed
+474
-33
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
FLOWCHART_PASS_GRADES,
|
||||
STUDENT_ROLES,
|
||||
isLessonDone,
|
||||
type LearnBoard,
|
||||
@@ -14,8 +15,9 @@ 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"
|
||||
|
||||
/**
|
||||
@@ -65,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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 方块格:一个班的学生 × 这门课的每一课,打开过没有、学完没有(练一练全对)。
|
||||
*
|
||||
@@ -87,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)
|
||||
@@ -104,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([
|
||||
@@ -185,7 +260,16 @@ adminLearnRoutes.get("/learn-analytics/board", requireTeacher, async (c) => {
|
||||
}
|
||||
if (isLessonDone(lesson)) done.push(tutorial.id)
|
||||
}
|
||||
return { ...student, opened, done, exerciseSolved, lessons, totalSeconds, lastViewedAt }
|
||||
return {
|
||||
...student,
|
||||
opened,
|
||||
done,
|
||||
exerciseSolved,
|
||||
lessons,
|
||||
problems: problemsOf.get(student.userId) ?? [],
|
||||
totalSeconds,
|
||||
lastViewedAt,
|
||||
}
|
||||
}),
|
||||
} satisfies LearnBoard)
|
||||
})
|
||||
@@ -378,6 +462,19 @@ adminLearnRoutes.get("/learn-analytics/students/:id", requireTeacher, async (c)
|
||||
.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) ?? []
|
||||
@@ -399,6 +496,19 @@ adminLearnRoutes.get("/learn-analytics/students/:id", requireTeacher, async (c)
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
@@ -13,8 +13,10 @@ import { EXERCISE_KIND } from "./kinds"
|
||||
* 一个学生的自学明细:点方块格里的名字从右边滑出来。
|
||||
*
|
||||
* 一课一段,标题行后面一串小方块是这课的每道练一练(绿 = 做对,红 = 做过还没对,
|
||||
* 灰 = 没做);没学完的课默认展开,逐题写着第几次做对、最后错在哪。
|
||||
* 灰 = 没做);有没做对的课默认展开,逐题写着第几次做对、最后错在哪,下面接着这课的
|
||||
* 配套题做没做对(不论早晚,读这课之前做对的也算)。
|
||||
*/
|
||||
const router = useRouter()
|
||||
const props = defineProps<{
|
||||
userId: number | null
|
||||
type: TutorialType
|
||||
@@ -29,6 +31,7 @@ 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)
|
||||
@@ -37,7 +40,11 @@ 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,
|
||||
@@ -54,6 +61,19 @@ function stateOf(ex: Exercise) {
|
||||
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)
|
||||
@@ -74,8 +94,9 @@ watch(
|
||||
data.lessons
|
||||
.filter(
|
||||
(lesson) =>
|
||||
lesson.exercises.some((ex) => ex.attempts > 0) &&
|
||||
lesson.exercises.some((ex) => !ex.solved),
|
||||
(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),
|
||||
)
|
||||
@@ -102,6 +123,9 @@ watch(
|
||||
<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>
|
||||
@@ -148,10 +172,20 @@ watch(
|
||||
</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" class="ex-list">
|
||||
<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">
|
||||
@@ -171,6 +205,29 @@ watch(
|
||||
</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>
|
||||
@@ -294,6 +351,16 @@ watch(
|
||||
.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
.sub-head {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.link {
|
||||
color: #18a058;
|
||||
text-decoration: none;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.bad {
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
|
||||
@@ -18,7 +19,8 @@ import { EXERCISE_KIND } from "./kinds"
|
||||
*
|
||||
* 中间是学生 × 课的方块格(浅绿 = 打开过,绿 = 学完,也就是练一练全对;练一练做了一部分的
|
||||
* 按做对的比例从下往上涨绿,一道没做对的底下压一条橙),一课没打开过的学生收成一串名单。
|
||||
* 点名字看这个人每课每道练一练的明细;右边按课看练一练做对了多少,点一道看每个人最后错在哪。
|
||||
* 点名字看这个人每课每道练一练、每道配套题的明细;右边按课看练一练和配套题做对了多少,
|
||||
* 点一道看每个人的情况。配套题做对不论早晚,读这课之前在课上做对的也算。
|
||||
* 不选班就按排名页同一套规则猜老师在教哪个班。
|
||||
*/
|
||||
const configStore = useConfigStore()
|
||||
@@ -30,6 +32,8 @@ 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)
|
||||
@@ -67,6 +71,19 @@ 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])),
|
||||
)
|
||||
@@ -88,6 +105,30 @@ 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)
|
||||
@@ -114,20 +155,41 @@ function cellTitle(row: LearnBoardStudent, step: number, title: string, id: numb
|
||||
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 ? "打开过,练一练没做" : "打开过"}`
|
||||
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) {
|
||||
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
|
||||
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
|
||||
@@ -234,6 +296,7 @@ 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>
|
||||
@@ -260,9 +323,16 @@ onMounted(load)
|
||||
>
|
||||
<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>
|
||||
@@ -299,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"
|
||||
@@ -308,8 +388,27 @@ 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">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
|
||||
@@ -343,6 +442,21 @@ onMounted(load)
|
||||
/>
|
||||
</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>
|
||||
@@ -397,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);
|
||||
@@ -448,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);
|
||||
@@ -463,7 +586,7 @@ onMounted(load)
|
||||
border-top: 0;
|
||||
}
|
||||
.c-name {
|
||||
width: 118px;
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -473,7 +596,7 @@ onMounted(load)
|
||||
.cells,
|
||||
.cells-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
.group-head {
|
||||
display: flex;
|
||||
@@ -531,20 +654,20 @@ onMounted(load)
|
||||
text-decoration: underline;
|
||||
}
|
||||
.c-num {
|
||||
width: 56px;
|
||||
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;
|
||||
@@ -610,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;
|
||||
@@ -663,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) {
|
||||
@@ -716,10 +843,5 @@ onMounted(load)
|
||||
.side {
|
||||
width: auto;
|
||||
}
|
||||
.lesson-select {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 「自学情况」的方块格:一个班的学生 × 这门课的每一课。
|
||||
*
|
||||
@@ -666,6 +678,8 @@ export const learnBoardStudentSchema = z.object({
|
||||
wrongAttempts: z.number().int(),
|
||||
}),
|
||||
),
|
||||
/** 交过的配套题(一次没交的不在里面),哪一课的题由 `tutorials[].problems` 对 */
|
||||
problems: z.array(learnProblemStateSchema),
|
||||
totalSeconds: z.number().int(),
|
||||
lastViewedAt: z.string().nullable(),
|
||||
})
|
||||
@@ -679,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),
|
||||
@@ -757,6 +775,17 @@ export const learnStudentDetailSchema = z.object({
|
||||
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(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user