- 后台课堂任务 admin/lesson → admin/task(/admin/task),和前台 oj/task 对上 - 后台首页 admin/setting/home.vue → admin/home/,设置 → admin/config/ - 卡点分析、年度趋势从 admin/problem 搬到 admin/analytics(/admin/analytics/stuck|trend),和菜单的「学情分析」、后端 analytics.ts 对上 - 首页 dashboard.vue / TeacherHome.vue → student.vue / teacher.vue;自学首页 home.vue → index.vue,看一课 index.vue → tutorial.vue - lesson 只表示课堂上的一节:自学那边改叫 tutorial(oj/learn/lesson.ts → utils.ts、isLessonDone → isTutorialDone、Lesson*.vue → Tutorial*.vue、自学情况的 lessons 字段 → tutorials) - 旧地址不留跳转 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
29c31640b5
commit
48590eee60
37 files changed
+1238
-1236
No files matched your search
@@ -1195,7 +1195,7 @@ export const problemsetBadge = pgTable(
|
||||
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
|
||||
*
|
||||
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
|
||||
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isLessonDone`)。
|
||||
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isTutorialDone`)。
|
||||
*
|
||||
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
|
||||
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
FLOWCHART_PASS_GRADES,
|
||||
STUDENT_ROLES,
|
||||
isLessonDone,
|
||||
isTutorialDone,
|
||||
type LearnBoard,
|
||||
type LearnBoardStudent,
|
||||
type LearnExerciseAttemptList,
|
||||
@@ -198,7 +198,7 @@ adminLearnRoutes.get('/learn-analytics/board', requireTeacher, async (c) => {
|
||||
students: students.map((student) => {
|
||||
const opened: number[] = []
|
||||
const done: number[] = []
|
||||
const lessons: LearnBoardStudent['lessons'] = []
|
||||
const attempts: LearnBoardStudent['tutorials'] = []
|
||||
let exerciseSolved = 0
|
||||
let totalSeconds = 0
|
||||
let lastViewedAt: string | null = null
|
||||
@@ -210,26 +210,26 @@ adminLearnRoutes.get('/learn-analytics/board', requireTeacher, async (c) => {
|
||||
exerciseSolved += solvedHere
|
||||
if (exercises) {
|
||||
const { tried, solved, wrongAttempts } = exercises
|
||||
lessons.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts })
|
||||
attempts.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts })
|
||||
}
|
||||
if (row) {
|
||||
opened.push(tutorial.id)
|
||||
totalSeconds += row.totalSeconds
|
||||
if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt
|
||||
}
|
||||
const lesson = {
|
||||
const here = {
|
||||
viewCount: row?.viewCount ?? 0,
|
||||
exerciseTotal: tutorial.exerciseTotal,
|
||||
exerciseSolved: solvedHere,
|
||||
}
|
||||
if (isLessonDone(lesson)) done.push(tutorial.id)
|
||||
if (isTutorialDone(here)) done.push(tutorial.id)
|
||||
}
|
||||
return {
|
||||
...student,
|
||||
opened,
|
||||
done,
|
||||
exerciseSolved,
|
||||
lessons,
|
||||
tutorials: attempts,
|
||||
problems: problemsOf.get(student.userId) ?? [],
|
||||
totalSeconds,
|
||||
lastViewedAt,
|
||||
@@ -394,27 +394,27 @@ adminLearnRoutes.get('/learn-analytics/students/:id', requireTeacher, async (c)
|
||||
).map((row) => [row.problemId, row]),
|
||||
)
|
||||
|
||||
const byLesson = new Map<number, LearnStudentDetail['lessons'][number]['exercises']>()
|
||||
const byTutorial = new Map<number, LearnStudentDetail['tutorials'][number]['exercises']>()
|
||||
for (const { tutorialId, ...row } of exercises) {
|
||||
const list = byLesson.get(tutorialId) ?? []
|
||||
const list = byTutorial.get(tutorialId) ?? []
|
||||
list.push({
|
||||
...row,
|
||||
attempts: row.attempts ?? 0,
|
||||
solved: row.solved ?? false,
|
||||
})
|
||||
byLesson.set(tutorialId, list)
|
||||
byTutorial.set(tutorialId, list)
|
||||
}
|
||||
|
||||
return success(c, {
|
||||
...student,
|
||||
lessons: tutorials
|
||||
tutorials: 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) ?? [],
|
||||
exercises: byTutorial.get(row.tutorialId) ?? [],
|
||||
problems: related
|
||||
.filter((problem) => problem.tutorialId === row.tutorialId)
|
||||
.map(({ id: problemId, displayId, title }) => {
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { avgTries, rate } from './stuckMath'
|
||||
|
||||
/**
|
||||
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
|
||||
* 还没做对的人)。从 Stuck.vue 拆出来,取数和三栏的联动还在那边。
|
||||
* 还没做对的人)。从 stuck.vue 拆出来,取数和三栏的联动还在那边。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
detail: StuckProblemDetail
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/** 卡点分析(Stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
|
||||
/** 卡点分析(stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
|
||||
|
||||
/** 百分比,取整;分母是 0 时给 0 */
|
||||
export function rate(part: number, whole: number) {
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -7,7 +7,7 @@ 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 { splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||
import { EXERCISE_KIND } from './kinds'
|
||||
|
||||
/**
|
||||
@@ -30,31 +30,31 @@ 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]
|
||||
type Tutorial = LearnStudentDetail['tutorials'][number]
|
||||
type Exercise = Tutorial['exercises'][number]
|
||||
type Problem = Tutorial['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 tutorials = detail.value?.tutorials ?? []
|
||||
const exercises = tutorials.flatMap((tutorial) => tutorial.exercises)
|
||||
// 一道题挂在两课只算一次
|
||||
const problems = new Map(lessons.flatMap((lesson) => lesson.problems).map((p) => [p.id, p]))
|
||||
const problems = new Map(tutorials.flatMap((tutorial) => tutorial.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),
|
||||
opened: tutorials.filter((tutorial) => tutorial.viewCount > 0).length,
|
||||
seconds: tutorials.reduce((n, tutorial) => n + tutorial.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 solvedOf(tutorial: Tutorial) {
|
||||
return tutorial.exercises.filter((ex) => ex.solved).length
|
||||
}
|
||||
|
||||
function stateOf(ex: Exercise) {
|
||||
@@ -92,13 +92,13 @@ watch(
|
||||
const data = await getLearnStudent(userId, { type })
|
||||
detail.value = data
|
||||
expanded.value = new Set(
|
||||
data.lessons
|
||||
data.tutorials
|
||||
.filter(
|
||||
(lesson) =>
|
||||
(lesson.exercises.some((ex) => ex.attempts > 0) && lesson.exercises.some((ex) => !ex.solved)) ||
|
||||
lesson.problems.some((p) => p.submissions > 0 && !p.solved),
|
||||
(tutorial) =>
|
||||
(tutorial.exercises.some((ex) => ex.attempts > 0) && tutorial.exercises.some((ex) => !ex.solved)) ||
|
||||
tutorial.problems.some((p) => p.submissions > 0 && !p.solved),
|
||||
)
|
||||
.map((lesson) => lesson.tutorialId),
|
||||
.map((tutorial) => tutorial.tutorialId),
|
||||
)
|
||||
} catch {
|
||||
detail.value = null
|
||||
@@ -133,40 +133,42 @@ watch(
|
||||
'--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 }}
|
||||
<div v-if="detail && !detail.tutorials.length" class="muted none">还没打开过</div>
|
||||
<div v-for="tutorial in detail?.tutorials ?? []" :key="tutorial.tutorialId" class="tutorial">
|
||||
<button type="button" class="tutorial-head" @click="toggle(tutorial.tutorialId)">
|
||||
<span class="tutorial-line">
|
||||
<span class="tutorial-title">
|
||||
<span class="muted num">第 {{ steps.get(tutorial.tutorialId) }} 课</span>
|
||||
{{ splitTitle(tutorial.title).kp }}
|
||||
</span>
|
||||
<span class="grow" />
|
||||
<span v-if="lesson.exercises.length" class="small num"> {{ solvedOf(lesson) }}/{{ lesson.exercises.length }} </span>
|
||||
<span v-if="tutorial.exercises.length" class="small num"> {{ solvedOf(tutorial) }}/{{ tutorial.exercises.length }} </span>
|
||||
<span class="squares">
|
||||
<span v-for="ex in lesson.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" />
|
||||
<span v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" />
|
||||
</span>
|
||||
<Icon
|
||||
v-if="lesson.exercises.length || lesson.problems.length"
|
||||
:icon="expanded.has(lesson.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
|
||||
v-if="tutorial.exercises.length || tutorial.problems.length"
|
||||
:icon="expanded.has(tutorial.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
|
||||
:width="12"
|
||||
class="muted"
|
||||
/>
|
||||
</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>
|
||||
<span class="tutorial-line small muted num">
|
||||
<template v-if="tutorial.viewCount">
|
||||
打开 {{ tutorial.viewCount }} 次
|
||||
<template v-if="tutorial.totalSeconds"> · 停留 {{ readableDuration(tutorial.totalSeconds) }}</template>
|
||||
<template v-if="tutorial.lastViewedAt"> · {{ parseTime(tutorial.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>
|
||||
<template v-if="!tutorial.exercises.length"> · 这课没有练一练</template>
|
||||
<template v-if="tutorial.problems.length">
|
||||
· 配套题 {{ tutorial.problems.filter((p) => p.solved).length }}/{{ tutorial.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">
|
||||
<div v-if="expanded.has(tutorial.tutorialId) && (tutorial.exercises.length || tutorial.problems.length)" class="ex-list">
|
||||
<div v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="ex">
|
||||
<span class="square" :class="stateOf(ex)" />
|
||||
<div class="ex-body">
|
||||
<div class="ex-question">
|
||||
@@ -183,8 +185,8 @@ 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">
|
||||
<div v-if="tutorial.problems.length" class="sub-head small muted">配套题</div>
|
||||
<div v-for="problem in tutorial.problems" :key="problem.id" class="ex">
|
||||
<span class="square" :class="problemStateOf(problem)" />
|
||||
<div class="ex-body">
|
||||
<div class="ex-question">
|
||||
@@ -235,13 +237,13 @@ watch(
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.lesson {
|
||||
.tutorial {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
.lesson:first-child {
|
||||
.tutorial:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
.lesson-head {
|
||||
.tutorial-head {
|
||||
width: 100%;
|
||||
padding: 12px 0;
|
||||
border: 0;
|
||||
@@ -254,13 +256,13 @@ watch(
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lesson-line {
|
||||
.tutorial-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.lesson-title {
|
||||
.tutorial-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { parseTime, readableDuration } from 'utils/functions'
|
||||
import { useConfigStore } from 'shared/store/config'
|
||||
import UserName from 'shared/components/UserName.vue'
|
||||
import { groupClassOptions } from 'utils/classes'
|
||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson'
|
||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||
import 'oj/learn/learn.css'
|
||||
import ExerciseAttempts from './ExerciseAttempts.vue'
|
||||
import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue'
|
||||
@@ -30,7 +30,7 @@ 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 selectedTutorialId = ref<number | null>(null)
|
||||
const expanded = ref<number | null>(null)
|
||||
const side = ref<'exercise' | 'problem'>('exercise')
|
||||
const problemOpen = ref<number | null>(null)
|
||||
@@ -58,32 +58,32 @@ const openedCount = computed(() => {
|
||||
}
|
||||
return counts
|
||||
})
|
||||
const lessonProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])))
|
||||
const tutorialProblems = 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 steps = computed(() => new Map(groups.value.flatMap((group) => group.tutorials.map((tutorial) => [tutorial.id, tutorial.step]))))
|
||||
const maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
|
||||
const lessonOptions = computed(() =>
|
||||
const tutorialOptions = computed(() =>
|
||||
groups.value.flatMap((group) =>
|
||||
group.lessons.map((lesson) => ({
|
||||
label: `第 ${lesson.step} 课 ${splitTitle(lesson.title).kp}`,
|
||||
value: lesson.id,
|
||||
group.tutorials.map((tutorial) => ({
|
||||
label: `第 ${tutorial.step} 课 ${splitTitle(tutorial.title).kp}`,
|
||||
value: tutorial.id,
|
||||
})),
|
||||
),
|
||||
)
|
||||
const lessonExercises = computed(() => exercises.value.filter((row) => row.tutorialId === lessonId.value))
|
||||
const tutorialExercises = computed(() => exercises.value.filter((row) => row.tutorialId === selectedTutorialId.value))
|
||||
/** 读过(或做过练一练)这一课的人 */
|
||||
function readersOf(id: number) {
|
||||
return started.value.filter((row) => row.opened.includes(id) || row.lessons.some((lesson) => lesson.tutorialId === id))
|
||||
return started.value.filter((row) => row.opened.includes(id) || row.tutorials.some((tutorial) => tutorial.tutorialId === id))
|
||||
}
|
||||
const sideProblems = computed(() => {
|
||||
const id = lessonId.value
|
||||
const id = selectedTutorialId.value
|
||||
if (id === null) return []
|
||||
const readers = readersOf(id)
|
||||
return (lessonProblems.value.get(id) ?? []).map((problem) => {
|
||||
return (tutorialProblems.value.get(id) ?? []).map((problem) => {
|
||||
const people: ProblemReader[] = readers.map((row) => ({
|
||||
userId: row.userId,
|
||||
username: row.username,
|
||||
@@ -98,43 +98,43 @@ const sideProblems = computed(() => {
|
||||
})
|
||||
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 tutorialOf(row: LearnBoardStudent, id: number) {
|
||||
return row.tutorials.find((tutorial) => tutorial.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'
|
||||
const tutorial = tutorialOf(row, id)
|
||||
if (tutorial) return tutorial.solved ? 'partial' : 'partial stuck'
|
||||
if (row.opened.includes(id)) return 'opened'
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
|
||||
function cellStyle(row: LearnBoardStudent, id: number) {
|
||||
const lesson = lessonOf(row, id)
|
||||
const tutorial = tutorialOf(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)}%` }
|
||||
if (!tutorial || !total || row.done.includes(id)) return undefined
|
||||
return { '--fill': `${Math.round((tutorial.solved / total) * 100)}%` }
|
||||
}
|
||||
|
||||
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
|
||||
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 tutorial = tutorialOf(row, id)
|
||||
const wrong = tutorial?.wrongAttempts ? `,错了 ${tutorial.wrongAttempts} 次` : ''
|
||||
const problems = tutorialProblems.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 (tutorial) return `${head}:练一练 ${tutorial.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)])
|
||||
return new Set([...row.opened, ...row.tutorials.map((tutorial) => tutorial.tutorialId)])
|
||||
}
|
||||
|
||||
/** 应做 = 他碰过的那些课,练一练加起来几道 */
|
||||
@@ -148,7 +148,7 @@ function exerciseDue(row: LearnBoardStudent) {
|
||||
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)
|
||||
for (const problem of tutorialProblems.value.get(id) ?? []) ids.add(problem.id)
|
||||
}
|
||||
const states = problemStates.value.get(row.userId)
|
||||
let solved = 0
|
||||
@@ -180,7 +180,7 @@ async function load() {
|
||||
exercises.value = ex.results
|
||||
// 练一练默认看大家打开得最多的那课
|
||||
const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0]
|
||||
lessonId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null
|
||||
selectedTutorialId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -235,11 +235,11 @@ onMounted(load)
|
||||
<div class="line head">
|
||||
<span class="c-name strong">学到哪了</span>
|
||||
<div class="cells-head">
|
||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-head">
|
||||
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-head">
|
||||
<span class="group-no" :title="group.project">{{ group.no || group.name }}</span>
|
||||
<span class="group-steps">
|
||||
<span v-for="lesson in group.lessons" :key="lesson.id" class="step" :title="splitTitle(lesson.title).kp">
|
||||
{{ lesson.step }}
|
||||
<span v-for="tutorial in group.tutorials" :key="tutorial.id" class="step" :title="splitTitle(tutorial.title).kp">
|
||||
{{ tutorial.step }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -254,14 +254,14 @@ onMounted(load)
|
||||
<UserName :username="row.username" />
|
||||
</button>
|
||||
<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.tutorials[0]!.id" class="group-cells">
|
||||
<span
|
||||
v-for="lesson in group.lessons"
|
||||
:key="lesson.id"
|
||||
v-for="tutorial in group.tutorials"
|
||||
:key="tutorial.id"
|
||||
class="cell"
|
||||
:class="cellOf(row, lesson.id)"
|
||||
:style="cellStyle(row, lesson.id)"
|
||||
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)"
|
||||
:class="cellOf(row, tutorial.id)"
|
||||
:style="cellStyle(row, tutorial.id)"
|
||||
:title="cellTitle(row, tutorial.step, tutorial.title, tutorial.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,14 +284,14 @@ onMounted(load)
|
||||
<div v-else class="line foot">
|
||||
<span class="c-name muted small">每课打开过</span>
|
||||
<div class="cells bars">
|
||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
|
||||
<span v-for="lesson in group.lessons" :key="lesson.id" class="bar-col">
|
||||
<span class="bar-num num">{{ openedCount.get(lesson.id) || '' }}</span>
|
||||
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
|
||||
<span v-for="tutorial in group.tutorials" :key="tutorial.id" class="bar-col">
|
||||
<span class="bar-num num">{{ openedCount.get(tutorial.id) || '' }}</span>
|
||||
<span
|
||||
class="bar"
|
||||
:class="{ zero: !openedCount.get(lesson.id) }"
|
||||
:class="{ zero: !openedCount.get(tutorial.id) }"
|
||||
:style="{
|
||||
height: `${Math.max(2, ((openedCount.get(lesson.id) ?? 0) / maxOpened) * 40)}px`,
|
||||
height: `${Math.max(2, ((openedCount.get(tutorial.id) ?? 0) / maxOpened) * 40)}px`,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
@@ -316,7 +316,7 @@ onMounted(load)
|
||||
<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" size="small" class="lesson-select" />
|
||||
<n-select v-model:value="selectedTutorialId" :options="tutorialOptions" size="small" class="tutorial-select" />
|
||||
</div>
|
||||
<template v-if="side === 'problem'">
|
||||
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
|
||||
@@ -333,8 +333,8 @@ onMounted(load)
|
||||
</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">
|
||||
<div v-else-if="!tutorialExercises.length" class="muted none">这课没有练一练</div>
|
||||
<div v-for="row in side === 'exercise' ? tutorialExercises : []" :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>
|
||||
@@ -711,7 +711,7 @@ onMounted(load)
|
||||
.class-select {
|
||||
width: 170px;
|
||||
}
|
||||
.lesson-select {
|
||||
.tutorial-select {
|
||||
flex: 1 0 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
+5
-5
@@ -12,11 +12,11 @@ import { errorCode, errorMessage, isApiError } from 'utils/api'
|
||||
import { DIFFICULTY, STORAGE_KEY } from 'utils/constants'
|
||||
import { createZipBlob, getTagColor, unique } from 'utils/functions'
|
||||
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types'
|
||||
import PreviewStrip from './components/PreviewStrip.vue'
|
||||
import { usePreviewCollapsed } from './components/previewCollapse'
|
||||
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../api'
|
||||
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './components/problemForm'
|
||||
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './components/sqlScript'
|
||||
import PreviewStrip from './PreviewStrip.vue'
|
||||
import { usePreviewCollapsed } from './previewCollapse'
|
||||
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../../api'
|
||||
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './problemForm'
|
||||
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './sqlScript'
|
||||
|
||||
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
|
||||
/** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */
|
||||
@@ -3,7 +3,7 @@ import { isSqlProblem } from '@oj2/contract'
|
||||
import type { AdminProblem } from 'utils/types'
|
||||
import { getProblem } from '../api'
|
||||
import ProblemDetail from './detail.vue'
|
||||
import SqlDetail from './SqlDetail.vue'
|
||||
import SqlDetail from './components/SqlDetail.vue'
|
||||
|
||||
/**
|
||||
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
|
||||
|
||||
File renamed without changes.
File renamed without changes.
@@ -4,9 +4,9 @@ import type { AdminTutorialProblem } from '@oj2/contract'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import MarkdownEditor from 'shared/components/MarkdownEditor.vue'
|
||||
import type { Exercise, TutorialEdit, TutorialListItem } from 'utils/types'
|
||||
import { parseLesson } from 'oj/learn/composables/useLessonParse'
|
||||
import { joinTitle, lessonPath, splitTitle } from 'oj/learn/lesson'
|
||||
import LessonBody from 'oj/learn/components/LessonBody.vue'
|
||||
import { parseTutorial } from 'oj/learn/composables/useTutorialParse'
|
||||
import { joinTitle, tutorialPath, splitTitle } from 'oj/learn/utils'
|
||||
import TutorialBody from 'oj/learn/components/TutorialBody.vue'
|
||||
import 'oj/learn/learn.css'
|
||||
import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api'
|
||||
import ExerciseManager from './components/ExerciseManager.vue'
|
||||
@@ -69,7 +69,7 @@ const heading = computed(() => {
|
||||
const label = tutorial.type === 'c' ? 'C 语言' : 'Python'
|
||||
return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开`
|
||||
})
|
||||
const preview = computed(() => parseLesson(tutorial.content, exercises.value))
|
||||
const preview = computed(() => parseTutorial(tutorial.content, exercises.value))
|
||||
|
||||
async function init() {
|
||||
allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] }))
|
||||
@@ -131,7 +131,7 @@ function insertExercise(id: number) {
|
||||
}
|
||||
|
||||
function openStudentView() {
|
||||
if (step.value) window.open(router.resolve(lessonPath(tutorial.type, step.value)).href, '_blank')
|
||||
if (step.value) window.open(router.resolve(tutorialPath(tutorial.type, step.value)).href, '_blank')
|
||||
}
|
||||
|
||||
watch(() => props.tutorialID, init)
|
||||
@@ -199,7 +199,7 @@ onMounted(init)
|
||||
<MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" />
|
||||
</div>
|
||||
<div class="preview">
|
||||
<LessonBody
|
||||
<TutorialBody
|
||||
:segments="preview.segments"
|
||||
:lang="tutorial.type"
|
||||
:tutorial-id="tutorial.id"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { errorMessage } from 'utils/api'
|
||||
import type { TutorialListItem } from 'utils/types'
|
||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson'
|
||||
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||
import ProjectTile from 'oj/learn/components/ProjectTile.vue'
|
||||
import 'oj/learn/learn.css'
|
||||
import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api'
|
||||
@@ -28,11 +28,11 @@ const list = computed(() => all.value[type.value] ?? [])
|
||||
const groups = computed(() =>
|
||||
groupByProject(list.value)
|
||||
.map((group) => {
|
||||
const items = group.lessons.map((lesson) => list.value.find((item) => item.id === lesson.id)!)
|
||||
const items = group.tutorials.map((entry) => list.value.find((item) => item.id === entry.id)!)
|
||||
return {
|
||||
...group,
|
||||
items,
|
||||
key: `${group.project}:${group.lessons[0]!.id}`,
|
||||
key: `${group.project}:${group.tutorials[0]!.id}`,
|
||||
hidden: items.every((item) => !item.isPublic),
|
||||
}
|
||||
})
|
||||
@@ -189,7 +189,7 @@ onMounted(load)
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="row lesson"
|
||||
class="row tutorial"
|
||||
:class="{ off: !item.isPublic, dragging: dragId === item.id }"
|
||||
draggable="true"
|
||||
@dragstart="dragId = item.id"
|
||||
@@ -330,10 +330,10 @@ onMounted(load)
|
||||
.group-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
.lesson.off {
|
||||
.tutorial.off {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.lesson.dragging {
|
||||
.tutorial.dragging {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.handle {
|
||||
|
||||
@@ -4,23 +4,23 @@ import storage from 'utils/storage'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
|
||||
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
|
||||
const Dashboard = defineAsyncComponent(() => import('./dashboard.vue'))
|
||||
const StudentHome = defineAsyncComponent(() => import('./student.vue'))
|
||||
// 老师的首页单独一张(设计稿「首页重设计」HomeTeacher):今天哪些班在上课
|
||||
const TeacherHome = defineAsyncComponent(() => import('./TeacherHome.vue'))
|
||||
const TeacherHome = defineAsyncComponent(() => import('./teacher.vue'))
|
||||
const ProblemList = defineAsyncComponent(() => import('oj/problem/list.vue'))
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// profile 回来之前先按上次的登录标记猜一个:干等的话 /profile 一失败首页就是
|
||||
// 白的,而先按「没登录」渲染又会让每个学生都先闪一下题目列表
|
||||
const showDashboard = computed(() => (userStore.isFinished ? userStore.isAuthed : !!storage.get(STORAGE_KEY.AUTHED)))
|
||||
const showHome = computed(() => (userStore.isFinished ? userStore.isAuthed : !!storage.get(STORAGE_KEY.AUTHED)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="showDashboard">
|
||||
<template v-if="showHome">
|
||||
<!-- 资料回来之前分不出是不是老师,先空着,免得老师先闪一下学生的首页 -->
|
||||
<TeacherHome v-if="userStore.isFinished && userStore.isTeacherOrAbove" />
|
||||
<Dashboard v-else-if="userStore.isFinished" />
|
||||
<StudentHome v-else-if="userStore.isFinished" />
|
||||
</template>
|
||||
<ProblemList v-else />
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { continueStep, doneCount, lessonPath, splitTitle, type TutorialType } from 'oj/learn/lesson'
|
||||
import { continueStep, doneCount, tutorialPath, splitTitle, type TutorialType } from 'oj/learn/utils'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
|
||||
import { getClassActivity } from 'shared/api'
|
||||
@@ -74,7 +74,7 @@ const weeklyText = computed(() => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 接着学哪一课看的是**服务端留痕**(和自学首页同一套,oj/learn/lesson.ts),不是本机记的:
|
||||
* 接着学哪一课看的是**服务端留痕**(和自学首页同一套,oj/learn/utils.ts),不是本机记的:
|
||||
* 机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
|
||||
*/
|
||||
async function loadTrack(type: TutorialType, label: string): Promise<Track | null> {
|
||||
@@ -243,7 +243,7 @@ watch(
|
||||
)
|
||||
|
||||
function learnLink(track: Track) {
|
||||
return lessonPath(track.type, track.step)
|
||||
return tutorialPath(track.type, track.step)
|
||||
}
|
||||
|
||||
function openSubmission(row: SubmissionListItem) {
|
||||
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import type { ProjectGroup } from '../lesson'
|
||||
import type { ProjectGroup } from '../utils'
|
||||
|
||||
/** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */
|
||||
defineProps<{ icon: string; tone: ProjectGroup['tone']; size?: number }>()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import type { TutorialProgress } from '@oj2/contract'
|
||||
import { lessonPath, lessonState, type ProjectGroup, type TutorialType } from '../lesson'
|
||||
import LessonRow from './LessonRow.vue'
|
||||
import { tutorialPath, tutorialState, type ProjectGroup, type TutorialType } from '../utils'
|
||||
import TutorialRow from './TutorialRow.vue'
|
||||
import ProjectTile from './ProjectTile.vue'
|
||||
|
||||
/**
|
||||
@@ -21,14 +21,14 @@ const emit = defineEmits<{ go: [] }>()
|
||||
const open = ref(new Set<string>())
|
||||
|
||||
function keyOf(group: ProjectGroup) {
|
||||
return `${group.project}:${group.lessons[0]!.id}`
|
||||
return `${group.project}:${group.tutorials[0]!.id}`
|
||||
}
|
||||
|
||||
// 换课换到别的项目时,把那个项目也展开(别的已经展开的不收)
|
||||
watch(
|
||||
() => [props.groups, props.currentStep] as const,
|
||||
([groups, step]) => {
|
||||
const group = groups.find((item) => item.lessons.some((lesson) => lesson.step === step))
|
||||
const group = groups.find((item) => item.tutorials.some((tutorial) => tutorial.step === step))
|
||||
if (group) open.value = new Set([...open.value, keyOf(group)])
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -43,7 +43,7 @@ function toggle(group: ProjectGroup) {
|
||||
}
|
||||
|
||||
function doneIn(group: ProjectGroup) {
|
||||
return group.lessons.filter((lesson) => lessonState(props.progress[lesson.id]) === 'done').length
|
||||
return group.tutorials.filter((tutorial) => tutorialState(props.progress[tutorial.id]) === 'done').length
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -62,23 +62,23 @@ function doneIn(group: ProjectGroup) {
|
||||
{{ group.name }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="doneIn(group) === group.lessons.length" class="count all">
|
||||
<Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.lessons.length }}
|
||||
<span v-if="doneIn(group) === group.tutorials.length" class="count all">
|
||||
<Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.tutorials.length }}
|
||||
</span>
|
||||
<span v-else class="count">{{ doneIn(group) }}/{{ group.lessons.length }}</span>
|
||||
<span v-else class="count">{{ doneIn(group) }}/{{ group.tutorials.length }}</span>
|
||||
<span v-if="big" class="chev">
|
||||
<Icon :icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :width="14" />
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="open.has(keyOf(group))" class="lessons">
|
||||
<LessonRow
|
||||
v-for="lesson in group.lessons"
|
||||
:key="lesson.id"
|
||||
:title="lesson.title"
|
||||
:step="lesson.step"
|
||||
:to="lessonPath(type, lesson.step)"
|
||||
:progress="progress[lesson.id]"
|
||||
:active="lesson.step === currentStep"
|
||||
<div v-if="open.has(keyOf(group))" class="tutorials">
|
||||
<TutorialRow
|
||||
v-for="tutorial in group.tutorials"
|
||||
:key="tutorial.id"
|
||||
:title="tutorial.title"
|
||||
:step="tutorial.step"
|
||||
:to="tutorialPath(type, tutorial.step)"
|
||||
:progress="progress[tutorial.id]"
|
||||
:active="tutorial.step === currentStep"
|
||||
:height="big ? 50 : 46"
|
||||
@go="emit('go')"
|
||||
/>
|
||||
@@ -160,13 +160,13 @@ function doneIn(group: ProjectGroup) {
|
||||
color: var(--learn-green-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.lessons {
|
||||
.tutorials {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-left: 6px;
|
||||
}
|
||||
.big .lessons {
|
||||
.big .tutorials {
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { LessonState, ProjectGroup } from '../lesson'
|
||||
import type { TutorialState, ProjectGroup } from '../utils'
|
||||
|
||||
/**
|
||||
* 一门课的进度条:一课一小段,按项目分组隔开。绿 = 学完,橙 = 在学,灰 = 没开始,
|
||||
@@ -7,7 +7,7 @@ import type { LessonState, ProjectGroup } from '../lesson'
|
||||
*/
|
||||
defineProps<{
|
||||
groups: ProjectGroup[]
|
||||
stateOf: (id: number) => LessonState
|
||||
stateOf: (id: number) => TutorialState
|
||||
current?: number
|
||||
/** 每段的宽、高 */
|
||||
width?: number
|
||||
@@ -19,12 +19,12 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div class="seg" :style="{ gap: `${groupGap ?? 10}px` }" aria-hidden="true">
|
||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group" :style="{ gap: `${gap ?? 3}px` }">
|
||||
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group" :style="{ gap: `${gap ?? 3}px` }">
|
||||
<span
|
||||
v-for="lesson in group.lessons"
|
||||
:key="lesson.id"
|
||||
v-for="tutorial in group.tutorials"
|
||||
:key="tutorial.id"
|
||||
class="cell"
|
||||
:class="[stateOf(lesson.id), { current: lesson.id === current }]"
|
||||
:class="[stateOf(tutorial.id), { current: tutorial.id === current }]"
|
||||
:style="{
|
||||
width: `${width ?? 20}px`,
|
||||
height: `${height ?? 6}px`,
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import Markdown from 'shared/components/Markdown.vue'
|
||||
import type { LessonSegment } from '../composables/useLessonParse'
|
||||
import type { TutorialSegment } from '../composables/useTutorialParse'
|
||||
import CodeRunBlock from './CodeRunBlock.vue'
|
||||
|
||||
const ExerciseWidget = defineAsyncComponent(() => import('./ExerciseWidget.vue'))
|
||||
|
||||
/** 一课的正文:按 useLessonParse 拆好的段落逐段渲染 */
|
||||
/** 一课的正文:按 useTutorialParse 拆好的段落逐段渲染 */
|
||||
defineProps<{
|
||||
segments: LessonSegment[]
|
||||
segments: TutorialSegment[]
|
||||
lang: 'python' | 'c'
|
||||
tutorialId: number
|
||||
exerciseCount: number
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import type { LessonState } from '../lesson'
|
||||
import type { TutorialState } from '../utils'
|
||||
|
||||
/** 目录里每课前面的圆:学完打勾、在学橙圈、当前绿圈、没开始灰圈 */
|
||||
defineProps<{ step: number; state: LessonState; current?: boolean }>()
|
||||
defineProps<{ step: number; state: TutorialState; current?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import type { TutorialProblem } from '@oj2/contract'
|
||||
import { splitTitle, type LessonState, type ProjectGroup } from '../lesson'
|
||||
import { splitTitle, type TutorialState, type ProjectGroup } from '../utils'
|
||||
import SegBar from './SegBar.vue'
|
||||
|
||||
/**
|
||||
@@ -19,7 +19,7 @@ const props = defineProps<{
|
||||
problems: TutorialProblem[]
|
||||
next: { title: string; to: string } | null
|
||||
groups: ProjectGroup[]
|
||||
stateOf: (id: number) => LessonState
|
||||
stateOf: (id: number) => TutorialState
|
||||
doneCount: number
|
||||
/** 窄屏:进度条短一点、题号挪到题目名下面 */
|
||||
compact?: boolean
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import type { OutlineItem } from '../composables/useLessonParse'
|
||||
import type { OutlineItem } from '../composables/useTutorialParse'
|
||||
|
||||
/**
|
||||
* 右边「这一课」:各节标题,有练一练的节后面挂一支笔(做对了再打个勾),
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { TutorialProgress } from '@oj2/contract'
|
||||
import { lessonState, splitTitle } from '../lesson'
|
||||
import LessonDot from './LessonDot.vue'
|
||||
import { tutorialState, splitTitle } from '../utils'
|
||||
import TutorialDot from './TutorialDot.vue'
|
||||
|
||||
/** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */
|
||||
const props = defineProps<{
|
||||
@@ -16,12 +16,12 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{ go: [] }>()
|
||||
|
||||
const parts = computed(() => splitTitle(props.title))
|
||||
const state = computed(() => lessonState(props.progress))
|
||||
const state = computed(() => tutorialState(props.progress))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-link :to="to" class="row" :class="{ active }" :style="{ height: `${height ?? 46}px` }" @click="emit('go')">
|
||||
<LessonDot :step="step" :state="state" :current="active" />
|
||||
<TutorialDot :step="step" :state="state" :current="active" />
|
||||
<span class="text">
|
||||
<span class="kp">{{ parts.kp }}</span>
|
||||
<span v-if="parts.scene" class="scene">{{ parts.scene }}</span>
|
||||
+3
-3
@@ -14,7 +14,7 @@ import type { Exercise } from 'utils/types'
|
||||
* 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方
|
||||
* 已经有了,每课都重复一张同样的项目图只是把正文往下推。
|
||||
*/
|
||||
export type LessonSegment =
|
||||
export type TutorialSegment =
|
||||
| { type: 'md'; content: string }
|
||||
| { type: 'heading'; id: string; text: string }
|
||||
| { type: 'goals'; id: string; items: string[] }
|
||||
@@ -57,9 +57,9 @@ function stripPreamble(lines: string[]) {
|
||||
return lines.slice(i)
|
||||
}
|
||||
|
||||
export function parseLesson(content: string, exercises: Exercise[]) {
|
||||
export function parseTutorial(content: string, exercises: Exercise[]) {
|
||||
const byId = new Map(exercises.map((item) => [item.id, item]))
|
||||
const segments: LessonSegment[] = []
|
||||
const segments: TutorialSegment[] = []
|
||||
const outline: OutlineItem[] = []
|
||||
let md: string[] = []
|
||||
let codeIndex = 0
|
||||
@@ -1,516 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import type { TutorialProgress, TutorialSummary } from '@oj2/contract'
|
||||
import { getLearnProgress, getTutorials } from '../api'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useAuthModalStore } from 'shared/store/authModal'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { COURSES, continueStep, doneCount, groupByProject, lessonPath, lessonState, splitProject, splitTitle, type TutorialType } from './lesson'
|
||||
import LessonRow from './components/LessonRow.vue'
|
||||
import ProjectTile from './components/ProjectTile.vue'
|
||||
import ProjectTree from './components/ProjectTree.vue'
|
||||
import SegBar from './components/SegBar.vue'
|
||||
import './learn.css'
|
||||
|
||||
/**
|
||||
* 自学首页 /learn(设计稿「自学首页」):最上面「接着学」,下面按项目分组的课程地图。
|
||||
*
|
||||
* 「接着学」看的是**服务端留痕**里最近打开的那一课,不是本机记的:机房一台电脑
|
||||
* 轮着好几个班用,本地记的是上一个人学到哪。
|
||||
*/
|
||||
const userStore = useUserStore()
|
||||
const authModal = useAuthModalStore()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
interface Course {
|
||||
type: TutorialType
|
||||
label: string
|
||||
titles: TutorialSummary[]
|
||||
progress: Record<number, TutorialProgress>
|
||||
}
|
||||
|
||||
const courses = ref<Course[]>([])
|
||||
const loaded = ref(false)
|
||||
const tab = ref<TutorialType>(route.query.course === 'c' ? 'c' : 'python')
|
||||
|
||||
async function load() {
|
||||
const authed = userStore.isAuthed
|
||||
courses.value = await Promise.all(
|
||||
COURSES.map(async ({ type, label }) => {
|
||||
const [titles, rows] = await Promise.all([getTutorials(type), authed ? getLearnProgress(type).catch(() => []) : Promise.resolve([])])
|
||||
return {
|
||||
type,
|
||||
label,
|
||||
titles,
|
||||
progress: Object.fromEntries(rows.map((row) => [row.tutorialId, row])),
|
||||
}
|
||||
}),
|
||||
)
|
||||
// 没指定看哪门课时,打开最近在学的那门
|
||||
if (!route.query.course && resume.value) tab.value = resume.value.course.type
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
function latestViewed(course: Course) {
|
||||
return Object.values(course.progress).reduce<string>((acc, row) => (row.lastViewedAt && row.lastViewedAt > acc ? row.lastViewedAt : acc), '')
|
||||
}
|
||||
|
||||
/** 接着学:最近打开过的那门课里该接着学的那课 */
|
||||
const resume = computed(() => {
|
||||
const started = courses.value.filter((course) => course.titles.length && latestViewed(course)).sort((a, b) => latestViewed(b).localeCompare(latestViewed(a)))
|
||||
const course = started[0]
|
||||
if (!course) return null
|
||||
const step = continueStep(course.titles, course.progress)
|
||||
if (!step) return null
|
||||
const item = course.titles[step - 1]!
|
||||
return { course, step, item, parts: splitTitle(item.title), project: splitProject(item.project) }
|
||||
})
|
||||
|
||||
const others = computed(() => courses.value.filter((course) => course.titles.length && course.type !== resume.value?.course.type))
|
||||
const current = computed(() => courses.value.find((course) => course.type === tab.value))
|
||||
const groups = computed(() => groupByProject(current.value?.titles ?? []))
|
||||
|
||||
function stateOf(course: Course) {
|
||||
return (id: number) => lessonState(course.progress[id])
|
||||
}
|
||||
|
||||
function firstLesson(course: Course) {
|
||||
return course.titles[0] ? splitTitle(course.titles[0].title) : null
|
||||
}
|
||||
|
||||
function courseStep(course: Course) {
|
||||
return continueStep(course.titles, course.progress)
|
||||
}
|
||||
|
||||
function pickTab(type: TutorialType) {
|
||||
tab.value = type
|
||||
router.replace({ query: { ...route.query, course: type } })
|
||||
}
|
||||
|
||||
function doneIn(course: Course, ids: number[]) {
|
||||
return ids.filter((id) => lessonState(course.progress[id]) === 'done').length
|
||||
}
|
||||
|
||||
watch(() => userStore.isAuthed, load)
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<template v-if="loaded">
|
||||
<!-- 第一排:接着学 + 另一门课;一课没学过就是两张「开始学」 -->
|
||||
<div class="top" :class="{ two: !resume }">
|
||||
<section v-if="resume" class="resume">
|
||||
<div class="resume-main">
|
||||
<div class="resume-meta">
|
||||
<span class="resume-label">接着学</span>
|
||||
<span class="muted">
|
||||
{{ resume.course.label }}
|
||||
<template v-if="resume.item.project"> · {{ resume.item.project }}</template>
|
||||
· 第 {{ resume.step }} 课
|
||||
</span>
|
||||
</div>
|
||||
<div class="resume-title">
|
||||
<span class="resume-kp">{{ resume.parts.kp }}</span>
|
||||
<span v-if="resume.parts.scene" class="resume-scene">{{ resume.parts.scene }}</span>
|
||||
</div>
|
||||
<div class="resume-progress">
|
||||
<SegBar
|
||||
:groups="groupByProject(resume.course.titles)"
|
||||
:state-of="stateOf(resume.course)"
|
||||
:current="resume.item.id"
|
||||
:width="isDesktop ? 20 : 11"
|
||||
:gap="isDesktop ? 3 : 2"
|
||||
:group-gap="isDesktop ? 10 : 6"
|
||||
/>
|
||||
<span class="num sec"> 学完 {{ doneCount(resume.course.titles, resume.course.progress) }} / {{ resume.course.titles.length }} 课 </span>
|
||||
</div>
|
||||
</div>
|
||||
<n-button type="primary" size="large" class="resume-go" icon-placement="right" @click="router.push(lessonPath(resume.course.type, resume.step))">
|
||||
<template #icon><Icon icon="ph:caret-right-bold" /></template>
|
||||
接着学
|
||||
</n-button>
|
||||
</section>
|
||||
|
||||
<!-- 手机上有了「接着学」就不再摆另一门课的卡,下面的页签切得到 -->
|
||||
<section v-for="course in resume ? (isDesktop ? others : []) : courses.filter((c) => c.titles.length)" :key="course.type" class="course card">
|
||||
<div class="course-text">
|
||||
<div class="course-head">
|
||||
<span class="course-name">{{ course.label }}</span>
|
||||
<span class="muted small">
|
||||
{{ course.titles.length }} 课
|
||||
<template v-if="resume">
|
||||
·
|
||||
{{ courseStep(course) ? `学完 ${doneCount(course.titles, course.progress)} 课` : '还没开始' }}</template
|
||||
>
|
||||
<template v-else> · {{ groupByProject(course.titles).length }} 个项目</template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="!resume && firstLesson(course)" class="first">
|
||||
<span class="muted">第 1 课</span>
|
||||
<span class="first-kp">{{ firstLesson(course)!.kp }}</span>
|
||||
<span class="sec">{{ firstLesson(course)!.scene }}</span>
|
||||
</div>
|
||||
<SegBar :groups="groupByProject(course.titles)" :state-of="stateOf(course)" :width="resume ? 12 : 14" />
|
||||
<router-link v-if="resume" :to="lessonPath(course.type, courseStep(course) ?? 1)" class="course-link">
|
||||
{{ courseStep(course) ? `接着学第 ${courseStep(course)} 课` : '从第 1 课开始' }}
|
||||
<Icon icon="ph:caret-right-bold" :width="13" />
|
||||
</router-link>
|
||||
</div>
|
||||
<n-button v-if="!resume" type="primary" icon-placement="right" @click="router.push(lessonPath(course.type, 1))">
|
||||
<template #icon><Icon icon="ph:caret-right-bold" /></template>
|
||||
开始学
|
||||
</n-button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button
|
||||
v-for="course in courses.filter((c) => c.titles.length)"
|
||||
:key="course.type"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
:class="{ on: tab === course.type }"
|
||||
:aria-selected="tab === course.type"
|
||||
@click="pickTab(course.type)"
|
||||
>
|
||||
{{ course.label }}
|
||||
<span class="tab-sub">
|
||||
{{
|
||||
userStore.isAuthed && doneCount(course.titles, course.progress)
|
||||
? `学完 ${doneCount(course.titles, course.progress)}/${course.titles.length}`
|
||||
: `${course.titles.length} 课`
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
<span class="grow" />
|
||||
<button v-if="!userStore.isAuthed" type="button" class="login" @click="authModal.openLoginModal()">登录后记下学到哪</button>
|
||||
<div v-if="isDesktop && userStore.isTeacherOrAbove" class="admin-links">
|
||||
<AdminLink to="/admin/learn" size="small">自学情况</AdminLink>
|
||||
<AdminLink v-if="userStore.isSuperAdmin" :to="{ path: '/admin/tutorial/list', query: { type: tab } }" size="small"> 管理教程 </AdminLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="current && isDesktop" class="map">
|
||||
<section v-for="group in groups" :key="group.lessons[0]!.id" class="project card">
|
||||
<div class="project-head">
|
||||
<ProjectTile :icon="group.icon" :tone="group.tone" />
|
||||
<span class="project-name">
|
||||
<span v-if="group.no" class="muted small">{{ group.no }}</span>
|
||||
<span class="project-title">{{ group.name }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
userStore.isAuthed &&
|
||||
doneIn(
|
||||
current,
|
||||
group.lessons.map((l) => l.id),
|
||||
) === group.lessons.length
|
||||
"
|
||||
class="project-count all"
|
||||
>
|
||||
<Icon icon="ph:check-bold" :width="13" />{{ group.lessons.length }}/{{ group.lessons.length }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="
|
||||
userStore.isAuthed &&
|
||||
doneIn(
|
||||
current,
|
||||
group.lessons.map((l) => l.id),
|
||||
)
|
||||
"
|
||||
class="project-count"
|
||||
>
|
||||
{{
|
||||
doneIn(
|
||||
current,
|
||||
group.lessons.map((l) => l.id),
|
||||
)
|
||||
}}/{{ group.lessons.length }}
|
||||
</span>
|
||||
<span v-else class="project-count">{{ group.lessons.length }} 课</span>
|
||||
</div>
|
||||
<LessonRow
|
||||
v-for="lesson in group.lessons"
|
||||
:key="lesson.id"
|
||||
:title="lesson.title"
|
||||
:step="lesson.step"
|
||||
:to="lessonPath(current.type, lesson.step)"
|
||||
:progress="current.progress[lesson.id]"
|
||||
:active="resume?.course.type === current.type && resume.step === lesson.step"
|
||||
:height="48"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<ProjectTree
|
||||
v-else-if="current"
|
||||
:groups="groups"
|
||||
:progress="current.progress"
|
||||
:type="current.type"
|
||||
:current-step="resume?.course.type === current.type ? resume.step : 0"
|
||||
big
|
||||
/>
|
||||
<n-empty v-if="!courses.some((c) => c.titles.length)" description="还没有公开的教程" style="margin-top: 80px" />
|
||||
</template>
|
||||
<n-spin v-else class="spin" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
max-width: 1286px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.muted {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.sec {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.small {
|
||||
font-size: 13px;
|
||||
}
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
.spin {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 80px;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid var(--learn-line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.top {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 380px;
|
||||
gap: 20px;
|
||||
}
|
||||
.top.two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.resume {
|
||||
border: 1px solid var(--learn-green-line);
|
||||
background: var(--learn-green-bg);
|
||||
border-radius: 10px;
|
||||
padding: 20px 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.resume-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.resume-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.resume-label {
|
||||
color: var(--learn-green-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.resume-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.resume-kp {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.resume-scene {
|
||||
font-size: 15px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.resume-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.course {
|
||||
padding: 18px 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.course-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.course-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
.course-name {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.first {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.first-kp {
|
||||
font-weight: 600;
|
||||
}
|
||||
.course-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--learn-green-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 24px;
|
||||
height: 40px;
|
||||
border-bottom: 1px solid var(--learn-line);
|
||||
}
|
||||
.tab {
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.tab.on {
|
||||
color: var(--learn-green-text);
|
||||
font-weight: 700;
|
||||
border-bottom-color: var(--learn-green);
|
||||
}
|
||||
.tab-sub {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.admin-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.login {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--learn-green-text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.map {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.map {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.top {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.project {
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.project-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 8px 10px;
|
||||
}
|
||||
.project-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.project-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.project-count {
|
||||
font-size: 13px;
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
}
|
||||
.project-count.all {
|
||||
opacity: 1;
|
||||
color: var(--learn-green-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.home {
|
||||
gap: 14px;
|
||||
}
|
||||
.top,
|
||||
.top.two {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.resume {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.resume-kp {
|
||||
font-size: 22px;
|
||||
}
|
||||
.resume-go {
|
||||
width: 100%;
|
||||
}
|
||||
.course {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.tabs {
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+426
-518
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,608 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import type { Tutorial, TutorialProgress, TutorialSummary } from '@oj2/contract'
|
||||
import type { Exercise } from 'utils/types'
|
||||
import { getExercises, getLearnProgress, getTutorial, getTutorials } from '../api'
|
||||
import { parseTutorial } from './composables/useTutorialParse'
|
||||
import { useLearnTrace } from './composables/useLearnTrace'
|
||||
import { useBackClose } from 'shared/composables/backClose'
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import AdminLink from 'shared/components/AdminLink.vue'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { COURSES, doneCount, groupByProject, tutorialPath, tutorialState, splitTitle, type TutorialType } from './utils'
|
||||
import TutorialBody from './components/TutorialBody.vue'
|
||||
import TutorialEnd from './components/TutorialEnd.vue'
|
||||
import TutorialOutline from './components/TutorialOutline.vue'
|
||||
import ProjectTree from './components/ProjectTree.vue'
|
||||
import SegBar from './components/SegBar.vue'
|
||||
import './learn.css'
|
||||
|
||||
/**
|
||||
* 读一课。设计稿「自学重设计」页的阅读页 A(选定):左边目录按项目折叠、中间正文、
|
||||
* 右边这一课的小节和练一练;手机上是顶部课程条 + 底部弹出的目录 + 底部上一课/下一课。
|
||||
*/
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const userStore = useUserStore()
|
||||
const theme = useThemeVars()
|
||||
|
||||
// 未登录也能看教程,只是不留痕、不记进度
|
||||
const traced = computed(() => userStore.isAuthed)
|
||||
|
||||
const type = computed<TutorialType>(() => (route.params.type === 'c' ? 'c' : 'python'))
|
||||
const courseLabel = computed(() => COURSES.find((item) => item.type === type.value)!.label)
|
||||
const step = computed(() => {
|
||||
const value = parseInt((route.params.step as string | undefined) ?? '1')
|
||||
return Number.isFinite(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const titles = ref<TutorialSummary[]>([])
|
||||
const progress = ref<Record<number, TutorialProgress>>({})
|
||||
const tutorial = ref<Tutorial | null>(null)
|
||||
const exercises = ref<Exercise[]>([])
|
||||
const solvedIds = ref(new Set<number>())
|
||||
const loading = ref(true)
|
||||
const isEmpty = ref(false)
|
||||
const menuOpen = ref(false)
|
||||
// 手机上的目录是大半屏的底部抽屉:按返回键收起它,不是退出这一课
|
||||
useBackClose(menuOpen, () => !isDesktop.value)
|
||||
|
||||
const groups = computed(() => groupByProject(titles.value))
|
||||
const parsed = computed(() => (tutorial.value ? parseTutorial(tutorial.value.content, exercises.value) : { segments: [], outline: [], exerciseCount: 0 }))
|
||||
const parts = computed(() => splitTitle(tutorial.value?.title ?? ''))
|
||||
const solvedCount = computed(() => exercises.value.filter((item) => solvedIds.value.has(item.id)).length)
|
||||
const firstUnsolved = computed(() => {
|
||||
for (const seg of parsed.value.segments) {
|
||||
if (seg.type === 'exercise' && !solvedIds.value.has(seg.exercise.id)) {
|
||||
return { id: seg.exercise.id, index: seg.index + 1 }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
const prev = computed(() => tutorialAt(step.value - 1))
|
||||
const next = computed(() => tutorialAt(step.value + 1))
|
||||
const done = computed(() => doneCount(titles.value, progress.value))
|
||||
|
||||
function tutorialAt(at: number) {
|
||||
const item = titles.value[at - 1]
|
||||
return item ? { title: item.title, to: tutorialPath(type.value, at) } : null
|
||||
}
|
||||
|
||||
function stateOf(id: number) {
|
||||
return tutorialState(progress.value[id])
|
||||
}
|
||||
|
||||
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
|
||||
useLearnTrace(
|
||||
computed(() => tutorial.value?.id ?? 0),
|
||||
traced,
|
||||
)
|
||||
|
||||
/**
|
||||
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响学,
|
||||
* 但弹个错会把「我是不是没学」的焦虑塞给学生。
|
||||
*/
|
||||
async function loadProgress() {
|
||||
if (!traced.value) {
|
||||
progress.value = {}
|
||||
return
|
||||
}
|
||||
try {
|
||||
const rows = await getLearnProgress(type.value)
|
||||
progress.value = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
|
||||
} catch {
|
||||
progress.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
let generation = 0
|
||||
async function init() {
|
||||
const mine = ++generation
|
||||
loading.value = true
|
||||
menuOpen.value = false
|
||||
const list = await getTutorials(type.value)
|
||||
if (mine !== generation) return
|
||||
titles.value = list
|
||||
isEmpty.value = list.length === 0
|
||||
if (isEmpty.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
if (step.value > list.length) {
|
||||
router.replace(tutorialPath(type.value, list.length))
|
||||
return
|
||||
}
|
||||
const id = list[step.value - 1]!.id
|
||||
const [loaded, exs] = await Promise.allSettled([getTutorial(id), getExercises(id), loadProgress()])
|
||||
if (mine !== generation) return
|
||||
tutorial.value = loaded.status === 'fulfilled' ? loaded.value : null
|
||||
exercises.value = exs.status === 'fulfilled' ? exs.value : []
|
||||
solvedIds.value = new Set(exercises.value.filter((item) => item.solved).map((item) => item.id))
|
||||
loading.value = false
|
||||
activeId.value = ''
|
||||
await nextTick()
|
||||
reader.value?.scrollTo({ top: 0 })
|
||||
// 手机上滚的是外层 n-layout 的容器,不是 window —— 原来 window.scrollTo 什么都没滚,
|
||||
// 点「下一课」落在下一课的末尾
|
||||
if (!isDesktop.value) scroller()?.scrollTo({ top: 0 })
|
||||
pagerHidden.value = false
|
||||
atEnd.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.type, route.params.step],
|
||||
() => {
|
||||
if (route.name === 'learn tutorial') init()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 在教程页上登录/退出时把勾重新拉一遍(学生多半是点「运行」被拦下才登录的)
|
||||
watch(traced, async () => {
|
||||
await loadProgress()
|
||||
if (!tutorial.value) return
|
||||
const exs = await getExercises(tutorial.value.id).catch(() => exercises.value)
|
||||
exercises.value = exs
|
||||
solvedIds.value = new Set([...solvedIds.value, ...exs.filter((item) => item.solved).map((item) => item.id)])
|
||||
})
|
||||
|
||||
/** 刚做对一道:本课的进度就地加上,目录、进度条、课末跟着变,不用等刷新 */
|
||||
function onSolved(exerciseId: number) {
|
||||
if (solvedIds.value.has(exerciseId) || !tutorial.value) return
|
||||
solvedIds.value = new Set([...solvedIds.value, exerciseId])
|
||||
const id = tutorial.value.id
|
||||
const row = progress.value[id] ?? {
|
||||
tutorialId: id,
|
||||
viewCount: 1,
|
||||
totalSeconds: 0,
|
||||
firstViewedAt: null,
|
||||
lastViewedAt: null,
|
||||
exerciseTotal: exercises.value.length,
|
||||
exerciseSolved: 0,
|
||||
}
|
||||
progress.value = { ...progress.value, [id]: { ...row, exerciseSolved: solvedCount.value } }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 手机底栏
|
||||
// 往下读时收起上一课 / 目录 / 下一课,往回翻再出来;读到课末也收着 ——
|
||||
// 课末卡片里就有「下一课」,两个摆在一起是重复的
|
||||
const mobileRoot = ref<HTMLElement | null>(null)
|
||||
const endRef = ref<HTMLElement | null>(null)
|
||||
const pagerHidden = ref(false)
|
||||
const atEnd = ref(false)
|
||||
|
||||
/** 往上找真正在滚的那层。n-layout-content 自己也带 .n-layout-scroll-container 类却不滚,按类名找会找错 */
|
||||
function scroller(): HTMLElement | null {
|
||||
for (let el = mobileRoot.value?.parentElement; el; el = el.parentElement) {
|
||||
const overflow = getComputedStyle(el).overflowY
|
||||
if ((overflow === 'auto' || overflow === 'scroll') && el.scrollHeight > el.clientHeight) {
|
||||
return el
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
let lastTop = 0
|
||||
function onMobileScroll(event: Event) {
|
||||
const box = event.target as HTMLElement
|
||||
const top = box.scrollTop
|
||||
if (top < 60) pagerHidden.value = false
|
||||
else if (top > lastTop + 6) pagerHidden.value = true
|
||||
else if (top < lastTop - 6) pagerHidden.value = false
|
||||
lastTop = top
|
||||
const end = endRef.value?.getBoundingClientRect().top
|
||||
atEnd.value = end !== undefined && end < box.getBoundingClientRect().bottom
|
||||
}
|
||||
|
||||
// 滚动事件不冒泡,在 document 上用捕获拿,不用先找到容器再挂
|
||||
useEventListener(
|
||||
document,
|
||||
'scroll',
|
||||
(event: Event) => {
|
||||
const box = event.target
|
||||
if (isDesktop.value || !(box instanceof HTMLElement)) return
|
||||
// 只认包着本页的那层(目录抽屉、代码块里横着滚的不算)
|
||||
if (!mobileRoot.value || !box.contains(mobileRoot.value)) return
|
||||
onMobileScroll(event)
|
||||
},
|
||||
{ capture: true, passive: true },
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------ 读到哪一节
|
||||
const reader = ref<HTMLElement | null>(null)
|
||||
const activeId = ref('')
|
||||
|
||||
function onScroll() {
|
||||
const box = reader.value
|
||||
if (!box) return
|
||||
const top = box.getBoundingClientRect().top + 160
|
||||
let current = parsed.value.outline[0]?.id ?? ''
|
||||
for (const item of parsed.value.outline) {
|
||||
const el = document.getElementById(item.id)
|
||||
if (el && el.getBoundingClientRect().top <= top) current = item.id
|
||||
}
|
||||
activeId.value = current
|
||||
}
|
||||
|
||||
function jump(id: string) {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
function jumpExercise(id: number) {
|
||||
jump(`exercise-${id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="learn">
|
||||
<n-empty v-if="isEmpty" description="这门课还没有公开的课" style="margin-top: 80px" />
|
||||
|
||||
<!-- 桌面:目录 | 正文 | 这一课 -->
|
||||
<div v-else-if="isDesktop" class="layout">
|
||||
<aside class="rail">
|
||||
<router-link to="/learn" class="back"> <Icon icon="ph:caret-left-bold" :width="13" />自学首页 </router-link>
|
||||
<div class="course">
|
||||
<div class="course-head">
|
||||
<span class="course-name">{{ courseLabel }}</span>
|
||||
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
|
||||
</div>
|
||||
<SegBar :groups="groups" :state-of="stateOf" :current="tutorial?.id" :width="9" :height="5" :gap="2" :group-gap="6" />
|
||||
</div>
|
||||
<ProjectTree :groups="groups" :progress="progress" :type="type" :current-step="step" />
|
||||
<div v-if="!traced" class="login-tip muted">登录后记下学到哪</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<div ref="reader" class="reader" @scroll.passive="onScroll">
|
||||
<!-- 按课重建:复用的话正文里的编辑器是就地换内容,CodeMirror 会把光标滚进视野,
|
||||
刚滚回顶上的页面又被拽到某个代码块那儿 -->
|
||||
<article v-if="tutorial" :key="tutorial.id" class="article">
|
||||
<div class="crumb muted">
|
||||
<span v-if="tutorial.project">{{ tutorial.project }}</span>
|
||||
<span v-if="tutorial.project">·</span>
|
||||
<span class="num">第 {{ step }} / {{ titles.length }} 课</span>
|
||||
<template v-if="userStore.isSuperAdmin">
|
||||
<span class="grow" />
|
||||
<AdminLink :to="{ name: 'admin tutorial edit', params: { tutorialID: tutorial.id } }" size="tiny"> 编辑这一课 </AdminLink>
|
||||
</template>
|
||||
</div>
|
||||
<h1 class="h1">{{ parts.kp }}</h1>
|
||||
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
|
||||
<TutorialBody
|
||||
:segments="parsed.segments"
|
||||
:lang="type"
|
||||
:tutorial-id="tutorial.id"
|
||||
:exercise-count="parsed.exerciseCount"
|
||||
:solved-ids="solvedIds"
|
||||
@solved="onSolved"
|
||||
/>
|
||||
<TutorialEnd
|
||||
:step="step"
|
||||
:total="titles.length"
|
||||
:course-label="courseLabel"
|
||||
:exercise-count="parsed.exerciseCount"
|
||||
:solved-count="solvedCount"
|
||||
:first-unsolved="firstUnsolved"
|
||||
:problems="tutorial.problems"
|
||||
:next="next"
|
||||
:groups="groups"
|
||||
:state-of="stateOf"
|
||||
:done-count="done"
|
||||
@jump="jumpExercise"
|
||||
/>
|
||||
</article>
|
||||
<n-spin v-else-if="loading" class="spin" />
|
||||
</div>
|
||||
<nav class="pager">
|
||||
<div class="pager-inner">
|
||||
<n-button v-if="prev" tag="a" :href="prev.to" @click.prevent="router.push(prev.to)">
|
||||
<template #icon><Icon icon="ph:caret-left-bold" /></template>
|
||||
上一课<span class="pager-name muted">{{ splitTitle(prev.title).kp }}</span>
|
||||
</n-button>
|
||||
<span class="grow" />
|
||||
<n-button v-if="next" type="primary" icon-placement="right" tag="a" :href="next.to" @click.prevent="router.push(next.to)">
|
||||
<template #icon><Icon icon="ph:caret-right-bold" /></template>
|
||||
下一课<span class="pager-name next-name">{{ splitTitle(next.title).kp }}</span>
|
||||
</n-button>
|
||||
</div>
|
||||
</nav>
|
||||
</main>
|
||||
|
||||
<aside class="side">
|
||||
<TutorialOutline
|
||||
v-if="tutorial"
|
||||
:items="parsed.outline"
|
||||
:active-id="activeId || parsed.outline[0]?.id || ''"
|
||||
:solved-ids="solvedIds"
|
||||
:exercise-count="parsed.exerciseCount"
|
||||
@jump="jump"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- 手机 -->
|
||||
<div v-else ref="mobileRoot" class="mobile">
|
||||
<div class="mbar">
|
||||
<n-button @click="menuOpen = true">
|
||||
<template #icon><Icon icon="ph:list" /></template>
|
||||
目录
|
||||
</n-button>
|
||||
<span class="num muted-strong">第 {{ step }} / {{ titles.length }} 课</span>
|
||||
<span class="grow" />
|
||||
<span v-if="parsed.exerciseCount" class="mpractice">
|
||||
<span>练一练</span>
|
||||
<span class="mboxes">
|
||||
<span v-for="n in parsed.exerciseCount" :key="n" class="mbox" :class="{ on: n <= solvedCount }" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<article v-if="tutorial" :key="tutorial.id" class="marticle">
|
||||
<div v-if="tutorial.project" class="crumb muted">{{ tutorial.project }}</div>
|
||||
<h1 class="h1 small">{{ parts.kp }}</h1>
|
||||
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
|
||||
<TutorialBody
|
||||
:segments="parsed.segments"
|
||||
:lang="type"
|
||||
:tutorial-id="tutorial.id"
|
||||
:exercise-count="parsed.exerciseCount"
|
||||
:solved-ids="solvedIds"
|
||||
@solved="onSolved"
|
||||
/>
|
||||
<div ref="endRef">
|
||||
<TutorialEnd
|
||||
:step="step"
|
||||
:total="titles.length"
|
||||
:course-label="courseLabel"
|
||||
:exercise-count="parsed.exerciseCount"
|
||||
:solved-count="solvedCount"
|
||||
:first-unsolved="firstUnsolved"
|
||||
:problems="tutorial.problems"
|
||||
:next="next"
|
||||
:groups="groups"
|
||||
:state-of="stateOf"
|
||||
:done-count="done"
|
||||
compact
|
||||
@jump="jumpExercise"
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
<n-spin v-else-if="loading" class="spin" />
|
||||
<nav class="mpager" :class="{ hidden: pagerHidden || atEnd }">
|
||||
<n-button v-if="prev" size="large" @click="router.push(prev.to)">
|
||||
<template #icon><Icon icon="ph:caret-left-bold" /></template>
|
||||
上一课
|
||||
</n-button>
|
||||
<span class="grow" />
|
||||
<n-button size="large" quaternary @click="menuOpen = true">
|
||||
<template #icon><Icon icon="ph:list" /></template>
|
||||
目录
|
||||
</n-button>
|
||||
<span class="grow" />
|
||||
<n-button v-if="next" size="large" type="primary" icon-placement="right" @click="router.push(next.to)">
|
||||
<template #icon><Icon icon="ph:caret-right-bold" /></template>
|
||||
下一课
|
||||
</n-button>
|
||||
</nav>
|
||||
|
||||
<!-- 不自动聚焦:一打开焦点框套在「自学首页」上,手机上看着像出了错 -->
|
||||
<n-drawer v-model:show="menuOpen" placement="bottom" height="85%" class="sheet" :auto-focus="false">
|
||||
<n-drawer-content :native-scrollbar="false" closable>
|
||||
<template #header>
|
||||
<span class="sheet-head">
|
||||
<span class="course-name">{{ courseLabel }}</span>
|
||||
<span v-if="traced" class="muted num">学完 {{ done }} / {{ titles.length }}</span>
|
||||
<router-link to="/learn" class="sheet-home">自学首页</router-link>
|
||||
</span>
|
||||
</template>
|
||||
<ProjectTree :groups="groups" :progress="progress" :type="type" :current-step="step" big @go="menuOpen = false" />
|
||||
</n-drawer-content>
|
||||
</n-drawer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.muted {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
.spin {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
/* 桌面端固定高度,三栏各自滚动。72 = 顶栏 56 + 外框上内边距 16;
|
||||
底下的 16 内边距用负外边距吃掉,上一课 / 下一课那条贴着屏幕底 */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr) 220px;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: calc(100vh - 72px);
|
||||
margin-bottom: -16px;
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.layout {
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
.side {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.rail {
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid var(--learn-line);
|
||||
padding: 0 12px 16px 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
padding: 0 8px;
|
||||
color: var(--learn-green-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.course {
|
||||
padding: 10px 8px 12px;
|
||||
}
|
||||
.course-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.course-name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.login-tip {
|
||||
font-size: 13px;
|
||||
padding: 8px;
|
||||
}
|
||||
.main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.reader {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 40px 0;
|
||||
}
|
||||
.article {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.h1.small {
|
||||
font-size: 24px;
|
||||
}
|
||||
.scene {
|
||||
font-size: 15px;
|
||||
opacity: 0.8;
|
||||
margin: 4px 0 18px;
|
||||
}
|
||||
.pager {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--learn-line);
|
||||
padding: 12px 40px;
|
||||
}
|
||||
.pager-inner {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.pager-name {
|
||||
margin-left: 6px;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.next-name {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.side {
|
||||
box-sizing: border-box;
|
||||
padding: 24px 0 0 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 手机 */
|
||||
/* 外层 n-layout 是 overflow: hidden,sticky 粘不住:顶部的条跟着滚走,
|
||||
上一课 / 下一课(带目录按钮)固定在屏幕底部 */
|
||||
.mobile {
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
.mbar {
|
||||
height: 48px;
|
||||
margin: 0 -12px;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--learn-line);
|
||||
background: v-bind('theme.bodyColor');
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.muted-strong {
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.mpractice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mboxes {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
.mbox {
|
||||
width: 14px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--learn-todo);
|
||||
}
|
||||
.mbox.on {
|
||||
background: var(--learn-green);
|
||||
}
|
||||
.marticle {
|
||||
padding-top: 16px;
|
||||
}
|
||||
.mpager {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--learn-line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: v-bind('theme.bodyColor');
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.mpager.hidden {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
.sheet-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sheet-home {
|
||||
margin-left: 12px;
|
||||
color: var(--learn-green-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isLessonDone, type TutorialProgress, type TutorialSummary } from '@oj2/contract'
|
||||
import { isTutorialDone, type TutorialProgress, type TutorialSummary } from '@oj2/contract'
|
||||
|
||||
export type TutorialType = 'python' | 'c'
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface ProjectGroup {
|
||||
icon: string
|
||||
tone: 'blue' | 'purple' | 'green' | 'orange' | 'gray'
|
||||
/** 这个项目里的课,`step` 是在整门课里的序号(1 起) */
|
||||
lessons: { id: number; title: string; step: number }[]
|
||||
tutorials: { id: number; title: string; step: number }[]
|
||||
}
|
||||
|
||||
/** 「项目三 · 智慧农业·蜜橘园」拆成「项目三」和「智慧农业·蜜橘园」 */
|
||||
@@ -57,7 +57,7 @@ export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] {
|
||||
titles.forEach((item, index) => {
|
||||
const last = groups[groups.length - 1]
|
||||
if (last && last.project === item.project) {
|
||||
last.lessons.push({ id: item.id, title: item.title, step: index + 1 })
|
||||
last.tutorials.push({ id: item.id, title: item.title, step: index + 1 })
|
||||
return
|
||||
}
|
||||
const { no, name } = splitProject(item.project)
|
||||
@@ -66,21 +66,21 @@ export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] {
|
||||
no,
|
||||
name,
|
||||
...projectLook(name),
|
||||
lessons: [{ id: item.id, title: item.title, step: index + 1 }],
|
||||
tutorials: [{ id: item.id, title: item.title, step: index + 1 }],
|
||||
})
|
||||
})
|
||||
return groups
|
||||
}
|
||||
|
||||
export type LessonState = 'done' | 'started' | 'todo'
|
||||
export type TutorialState = 'done' | 'started' | 'todo'
|
||||
|
||||
/**
|
||||
* 目录上的三态。学完 = 练一练全对(契约的 `isLessonDone`);打开过或者做过练一练但没
|
||||
* 目录上的三态。学完 = 练一练全对(契约的 `isTutorialDone`);打开过或者做过练一练但没
|
||||
* 全对是「在学」;别的是没开始。
|
||||
*/
|
||||
export function lessonState(progress: TutorialProgress | undefined): LessonState {
|
||||
export function tutorialState(progress: TutorialProgress | undefined): TutorialState {
|
||||
if (!progress) return 'todo'
|
||||
if (isLessonDone(progress)) return 'done'
|
||||
if (isTutorialDone(progress)) return 'done'
|
||||
if (progress.viewCount > 0 || progress.exerciseSolved > 0) return 'started'
|
||||
return 'todo'
|
||||
}
|
||||
@@ -98,17 +98,17 @@ export function continueStep(titles: TutorialSummary[], progress: Record<number,
|
||||
if (!latest) return null
|
||||
const index = titles.findIndex((item) => item.id === latest!.tutorialId)
|
||||
if (index < 0) return null
|
||||
if (lessonState(latest) !== 'done') return index + 1
|
||||
if (tutorialState(latest) !== 'done') return index + 1
|
||||
for (let i = index + 1; i < titles.length; i++) {
|
||||
if (lessonState(progress[titles[i]!.id]) !== 'done') return i + 1
|
||||
if (tutorialState(progress[titles[i]!.id]) !== 'done') return i + 1
|
||||
}
|
||||
return index + 1
|
||||
}
|
||||
|
||||
export function doneCount(titles: TutorialSummary[], progress: Record<number, TutorialProgress>) {
|
||||
return titles.filter((item) => lessonState(progress[item.id]) === 'done').length
|
||||
return titles.filter((item) => tutorialState(progress[item.id]) === 'done').length
|
||||
}
|
||||
|
||||
export function lessonPath(type: TutorialType, step: number) {
|
||||
export function tutorialPath(type: TutorialType, step: number) {
|
||||
return `/learn/${type}/${step.toString().padStart(2, '0')}`
|
||||
}
|
||||
@@ -558,7 +558,7 @@ function lastText(student: TaskBoardStudent) {
|
||||
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : '请假' }}
|
||||
</n-button>
|
||||
<!-- 布置过的每一节课:改错了班、隔天想起来补,去后台 -->
|
||||
<AdminLink v-if="isDesktop" :to="{ name: 'admin lesson list', query: className ? { className } : {} }">布置过的课</AdminLink>
|
||||
<AdminLink v-if="isDesktop" :to="{ name: 'admin task list', query: className ? { className } : {} }">布置过的课</AdminLink>
|
||||
<!-- 任务页只管今天;以前几节课的、任意时间段的去统计(带上这个班) -->
|
||||
<n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })"> 回头看 → </n-button>
|
||||
</div>
|
||||
|
||||
+13
-13
@@ -113,13 +113,13 @@ export const ojs: RouteRecordRaw = {
|
||||
},
|
||||
{
|
||||
path: 'learn',
|
||||
name: 'learn home',
|
||||
component: () => import('oj/learn/home.vue'),
|
||||
name: 'learn',
|
||||
component: () => import('oj/learn/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'learn/:type/:step',
|
||||
name: 'learn',
|
||||
component: () => import('oj/learn/index.vue'),
|
||||
name: 'learn tutorial',
|
||||
component: () => import('oj/learn/tutorial.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
@@ -164,13 +164,13 @@ export const admins: RouteRecordRaw = {
|
||||
{
|
||||
path: '',
|
||||
name: 'admin home',
|
||||
component: () => import('admin/setting/home.vue'),
|
||||
component: () => import('admin/home/index.vue'),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'config',
|
||||
name: 'admin config',
|
||||
component: () => import('admin/setting/config.vue'),
|
||||
component: () => import('admin/config/index.vue'),
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
@@ -291,22 +291,22 @@ export const admins: RouteRecordRaw = {
|
||||
meta: { requiresSuperAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'problem/stuck',
|
||||
path: 'analytics/stuck',
|
||||
name: 'admin stuck problems',
|
||||
component: () => import('admin/problem/Stuck.vue'),
|
||||
component: () => import('admin/analytics/stuck.vue'),
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
path: 'problem/trend',
|
||||
path: 'analytics/trend',
|
||||
name: 'admin grade trend',
|
||||
component: () => import('admin/problem/Trend.vue'),
|
||||
component: () => import('admin/analytics/trend.vue'),
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
{
|
||||
// 课堂任务布置的每一节课:改、删、补(数据统计按布置切课)
|
||||
path: 'lesson',
|
||||
name: 'admin lesson list',
|
||||
component: () => import('admin/lesson/list.vue'),
|
||||
path: 'task',
|
||||
name: 'admin task list',
|
||||
component: () => import('admin/task/list.vue'),
|
||||
meta: { requiresTeacherAdmin: true },
|
||||
},
|
||||
// 题单管理路由
|
||||
|
||||
@@ -21,7 +21,7 @@ const { isDark, toggleDark } = useDarkTransition()
|
||||
* 菜单的唯一一张表。`role` 必须和 routes.ts 里对应路由的 meta 对齐:
|
||||
* 菜单里露出一个守卫不放行的入口,点下去就是被静默踢回首页。
|
||||
* `path` 同时用来判定高亮:取当前路径能匹配上的**最长**那一条,所以
|
||||
* /admin/problem/stuck 亮「卡点分析」而不是「题目」。
|
||||
* /admin/analytics/stuck 亮「卡点分析」而不是「首页」。
|
||||
*/
|
||||
type Role = 'problem' | 'teacher' | 'super'
|
||||
interface NavItem {
|
||||
@@ -67,7 +67,7 @@ const NAV: NavGroup[] = [
|
||||
to: '/admin/problemset/list',
|
||||
role: 'teacher',
|
||||
},
|
||||
{ label: '课堂任务', icon: 'ph:chalkboard-teacher', path: '/admin/lesson', role: 'teacher' },
|
||||
{ label: '课堂任务', icon: 'ph:chalkboard-teacher', path: '/admin/task', role: 'teacher' },
|
||||
{
|
||||
label: '教程',
|
||||
icon: 'ph:book-open',
|
||||
@@ -83,13 +83,13 @@ const NAV: NavGroup[] = [
|
||||
{
|
||||
label: '卡点分析',
|
||||
icon: 'ph:warning-diamond',
|
||||
path: '/admin/problem/stuck',
|
||||
path: '/admin/analytics/stuck',
|
||||
role: 'teacher',
|
||||
},
|
||||
{
|
||||
label: '年度趋势',
|
||||
icon: 'ph:chart-line-up',
|
||||
path: '/admin/problem/trend',
|
||||
path: '/admin/analytics/trend',
|
||||
role: 'teacher',
|
||||
},
|
||||
{ label: '自学情况', icon: 'ph:student', path: '/admin/learn', role: 'teacher' },
|
||||
|
||||
@@ -675,7 +675,7 @@ const learnProblemStateSchema = z.object({
|
||||
* 「自学情况」的方块格:一个班的学生 × 这门课的每一课。
|
||||
*
|
||||
* **没打开过任何一课的学生也要有一行**(`opened` 为空)—— 页面上把他们收成一串名单,
|
||||
* 这张表首要回答的就是「谁还没开始」。`done` 是学完的课(练一练全对,见 `isLessonDone`),
|
||||
* 这张表首要回答的就是「谁还没开始」。`done` 是学完的课(练一练全对,见 `isTutorialDone`),
|
||||
* 一定也在 `opened` 里或者是没打开却把练一练做完了的(老链接直达练习的情况)。
|
||||
*/
|
||||
export const learnBoardStudentSchema = z.object({
|
||||
@@ -690,7 +690,7 @@ export const learnBoardStudentSchema = z.object({
|
||||
* 碰过练一练的课,一课一项(一道没做的课不在里面)。方块格靠它分出「做了一部分」,
|
||||
* `wrongAttempts` 是这课所有练习错的次数加起来
|
||||
*/
|
||||
lessons: z.array(
|
||||
tutorials: z.array(
|
||||
z.object({
|
||||
tutorialId: z.number().int(),
|
||||
tried: z.number().int(),
|
||||
@@ -774,7 +774,7 @@ export const learnStudentDetailSchema = z.object({
|
||||
userId: z.number().int(),
|
||||
username: z.string(),
|
||||
className: z.string().nullable(),
|
||||
lessons: z.array(
|
||||
tutorials: z.array(
|
||||
z.object({
|
||||
tutorialId: z.number().int(),
|
||||
title: z.string(),
|
||||
|
||||
@@ -191,7 +191,7 @@ export type ExerciseAttemptRequest = z.input<typeof exerciseAttemptRequestSchema
|
||||
*
|
||||
* 前端目录、首页、后台「自学情况」都走这一个函数,别各写一份。
|
||||
*/
|
||||
export function isLessonDone(progress: { viewCount: number; exerciseTotal: number; exerciseSolved: number }) {
|
||||
export function isTutorialDone(progress: { viewCount: number; exerciseTotal: number; exerciseSolved: number }) {
|
||||
if (progress.exerciseTotal > 0) return progress.exerciseSolved >= progress.exerciseTotal
|
||||
return progress.viewCount > 0
|
||||
}
|
||||
Reference in new issue
Block a user