refactor(目录): 目录名对上界面上的名字,自学的「一课」不再也叫 lesson
Deploy / deploy (push) Canceled after 0s

- 后台课堂任务 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:
xuyueandClaude Opus 5.5 committed 2026-11-02 22:59:43 +08:00
1 parent 29c31640b5
commit 48590eee60
37 files changed
+1228 -1226

No files matched your search

+1 -1
View File
@@ -1195,7 +1195,7 @@ export const problemsetBadge = pgTable(
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
*
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isLessonDone`)。
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isTutorialDone`)。
*
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
+11 -11
View File
@@ -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 }) => {
@@ -8,7 +8,7 @@ import { avgTries, rate } from './stuckMath'
/**
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
* 还没做对的人)。从 Stuck.vue 拆出来,取数和三栏的联动还在那边。
* 还没做对的人)。从 stuck.vue 拆出来,取数和三栏的联动还在那边。
*/
const props = defineProps<{
detail: StuckProblemDetail
@@ -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.
+45 -43
View File
@@ -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;
+44 -44
View File
@@ -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;
}
@@ -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 的注释 */
+1 -1
View File
@@ -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.
+6 -6
View File
@@ -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"
+6 -6
View File
@@ -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 {
+5 -5
View File
@@ -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>
+6 -6
View File
@@ -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`,
@@ -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
@@ -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>
@@ -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,6 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import type { OutlineItem } from '../composables/useLessonParse'
import type { OutlineItem } from '../composables/useTutorialParse'
/**
* 右边「这一课」:各节标题,有练一练的节后面挂一支笔(做对了再打个勾),
@@ -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>
@@ -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
-516
View File
@@ -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>
File diff suppressed because it is too large. Load diff
+608
View File
@@ -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')}`
}
+1 -1
View File
@@ -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
View File
@@ -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 },
},
// 题单管理路由
+4 -4
View File
@@ -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' },
+3 -3
View File
@@ -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(),
+1 -1
View File
@@ -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
}