- 后台课堂任务 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
+1228
-1226
No files matched your search
@@ -1195,7 +1195,7 @@ export const problemsetBadge = pgTable(
|
|||||||
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
|
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
|
||||||
*
|
*
|
||||||
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
|
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
|
||||||
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isLessonDone`)。
|
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isTutorialDone`)。
|
||||||
*
|
*
|
||||||
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
|
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
|
||||||
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
|
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
FLOWCHART_PASS_GRADES,
|
FLOWCHART_PASS_GRADES,
|
||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
isLessonDone,
|
isTutorialDone,
|
||||||
type LearnBoard,
|
type LearnBoard,
|
||||||
type LearnBoardStudent,
|
type LearnBoardStudent,
|
||||||
type LearnExerciseAttemptList,
|
type LearnExerciseAttemptList,
|
||||||
@@ -198,7 +198,7 @@ adminLearnRoutes.get('/learn-analytics/board', requireTeacher, async (c) => {
|
|||||||
students: students.map((student) => {
|
students: students.map((student) => {
|
||||||
const opened: number[] = []
|
const opened: number[] = []
|
||||||
const done: number[] = []
|
const done: number[] = []
|
||||||
const lessons: LearnBoardStudent['lessons'] = []
|
const attempts: LearnBoardStudent['tutorials'] = []
|
||||||
let exerciseSolved = 0
|
let exerciseSolved = 0
|
||||||
let totalSeconds = 0
|
let totalSeconds = 0
|
||||||
let lastViewedAt: string | null = null
|
let lastViewedAt: string | null = null
|
||||||
@@ -210,26 +210,26 @@ adminLearnRoutes.get('/learn-analytics/board', requireTeacher, async (c) => {
|
|||||||
exerciseSolved += solvedHere
|
exerciseSolved += solvedHere
|
||||||
if (exercises) {
|
if (exercises) {
|
||||||
const { tried, solved, wrongAttempts } = exercises
|
const { tried, solved, wrongAttempts } = exercises
|
||||||
lessons.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts })
|
attempts.push({ tutorialId: tutorial.id, tried, solved, wrongAttempts })
|
||||||
}
|
}
|
||||||
if (row) {
|
if (row) {
|
||||||
opened.push(tutorial.id)
|
opened.push(tutorial.id)
|
||||||
totalSeconds += row.totalSeconds
|
totalSeconds += row.totalSeconds
|
||||||
if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt
|
if (!lastViewedAt || row.lastViewedAt > lastViewedAt) lastViewedAt = row.lastViewedAt
|
||||||
}
|
}
|
||||||
const lesson = {
|
const here = {
|
||||||
viewCount: row?.viewCount ?? 0,
|
viewCount: row?.viewCount ?? 0,
|
||||||
exerciseTotal: tutorial.exerciseTotal,
|
exerciseTotal: tutorial.exerciseTotal,
|
||||||
exerciseSolved: solvedHere,
|
exerciseSolved: solvedHere,
|
||||||
}
|
}
|
||||||
if (isLessonDone(lesson)) done.push(tutorial.id)
|
if (isTutorialDone(here)) done.push(tutorial.id)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...student,
|
...student,
|
||||||
opened,
|
opened,
|
||||||
done,
|
done,
|
||||||
exerciseSolved,
|
exerciseSolved,
|
||||||
lessons,
|
tutorials: attempts,
|
||||||
problems: problemsOf.get(student.userId) ?? [],
|
problems: problemsOf.get(student.userId) ?? [],
|
||||||
totalSeconds,
|
totalSeconds,
|
||||||
lastViewedAt,
|
lastViewedAt,
|
||||||
@@ -394,27 +394,27 @@ adminLearnRoutes.get('/learn-analytics/students/:id', requireTeacher, async (c)
|
|||||||
).map((row) => [row.problemId, row]),
|
).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) {
|
for (const { tutorialId, ...row } of exercises) {
|
||||||
const list = byLesson.get(tutorialId) ?? []
|
const list = byTutorial.get(tutorialId) ?? []
|
||||||
list.push({
|
list.push({
|
||||||
...row,
|
...row,
|
||||||
attempts: row.attempts ?? 0,
|
attempts: row.attempts ?? 0,
|
||||||
solved: row.solved ?? false,
|
solved: row.solved ?? false,
|
||||||
})
|
})
|
||||||
byLesson.set(tutorialId, list)
|
byTutorial.set(tutorialId, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
return success(c, {
|
return success(c, {
|
||||||
...student,
|
...student,
|
||||||
lessons: tutorials
|
tutorials: tutorials
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
tutorialId: row.tutorialId,
|
tutorialId: row.tutorialId,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
viewCount: row.viewCount ?? 0,
|
viewCount: row.viewCount ?? 0,
|
||||||
totalSeconds: row.totalSeconds ?? 0,
|
totalSeconds: row.totalSeconds ?? 0,
|
||||||
lastViewedAt: row.lastViewedAt,
|
lastViewedAt: row.lastViewedAt,
|
||||||
exercises: byLesson.get(row.tutorialId) ?? [],
|
exercises: byTutorial.get(row.tutorialId) ?? [],
|
||||||
problems: related
|
problems: related
|
||||||
.filter((problem) => problem.tutorialId === row.tutorialId)
|
.filter((problem) => problem.tutorialId === row.tutorialId)
|
||||||
.map(({ id: problemId, displayId, title }) => {
|
.map(({ id: problemId, displayId, title }) => {
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import { avgTries, rate } from './stuckMath'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
|
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
|
||||||
* 还没做对的人)。从 Stuck.vue 拆出来,取数和三栏的联动还在那边。
|
* 还没做对的人)。从 stuck.vue 拆出来,取数和三栏的联动还在那边。
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
detail: StuckProblemDetail
|
detail: StuckProblemDetail
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
/** 卡点分析(Stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
|
/** 卡点分析(stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
|
||||||
|
|
||||||
/** 百分比,取整;分母是 0 时给 0 */
|
/** 百分比,取整;分母是 0 时给 0 */
|
||||||
export function rate(part: number, whole: number) {
|
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 UserName from 'shared/components/UserName.vue'
|
||||||
import { useBackClose } from 'shared/composables/backClose'
|
import { useBackClose } from 'shared/composables/backClose'
|
||||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
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'
|
import { EXERCISE_KIND } from './kinds'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,31 +30,31 @@ const theme = useThemeVars()
|
|||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
useBackClose(show, () => !isDesktop.value)
|
useBackClose(show, () => !isDesktop.value)
|
||||||
|
|
||||||
type Lesson = LearnStudentDetail['lessons'][number]
|
type Tutorial = LearnStudentDetail['tutorials'][number]
|
||||||
type Exercise = Lesson['exercises'][number]
|
type Exercise = Tutorial['exercises'][number]
|
||||||
type Problem = Lesson['problems'][number]
|
type Problem = Tutorial['problems'][number]
|
||||||
|
|
||||||
const detail = ref<LearnStudentDetail | null>(null)
|
const detail = ref<LearnStudentDetail | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const expanded = ref(new Set<number>())
|
const expanded = ref(new Set<number>())
|
||||||
|
|
||||||
const summary = computed(() => {
|
const summary = computed(() => {
|
||||||
const lessons = detail.value?.lessons ?? []
|
const tutorials = detail.value?.tutorials ?? []
|
||||||
const exercises = lessons.flatMap((lesson) => lesson.exercises)
|
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 {
|
return {
|
||||||
problemSolved: [...problems.values()].filter((p) => p.solved).length,
|
problemSolved: [...problems.values()].filter((p) => p.solved).length,
|
||||||
problemTotal: problems.size,
|
problemTotal: problems.size,
|
||||||
opened: lessons.filter((lesson) => lesson.viewCount > 0).length,
|
opened: tutorials.filter((tutorial) => tutorial.viewCount > 0).length,
|
||||||
seconds: lessons.reduce((n, lesson) => n + lesson.totalSeconds, 0),
|
seconds: tutorials.reduce((n, tutorial) => n + tutorial.totalSeconds, 0),
|
||||||
solved: exercises.filter((ex) => ex.solved).length,
|
solved: exercises.filter((ex) => ex.solved).length,
|
||||||
total: exercises.length,
|
total: exercises.length,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function solvedOf(lesson: Lesson) {
|
function solvedOf(tutorial: Tutorial) {
|
||||||
return lesson.exercises.filter((ex) => ex.solved).length
|
return tutorial.exercises.filter((ex) => ex.solved).length
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateOf(ex: Exercise) {
|
function stateOf(ex: Exercise) {
|
||||||
@@ -92,13 +92,13 @@ watch(
|
|||||||
const data = await getLearnStudent(userId, { type })
|
const data = await getLearnStudent(userId, { type })
|
||||||
detail.value = data
|
detail.value = data
|
||||||
expanded.value = new Set(
|
expanded.value = new Set(
|
||||||
data.lessons
|
data.tutorials
|
||||||
.filter(
|
.filter(
|
||||||
(lesson) =>
|
(tutorial) =>
|
||||||
(lesson.exercises.some((ex) => ex.attempts > 0) && lesson.exercises.some((ex) => !ex.solved)) ||
|
(tutorial.exercises.some((ex) => ex.attempts > 0) && tutorial.exercises.some((ex) => !ex.solved)) ||
|
||||||
lesson.problems.some((p) => p.submissions > 0 && !p.solved),
|
tutorial.problems.some((p) => p.submissions > 0 && !p.solved),
|
||||||
)
|
)
|
||||||
.map((lesson) => lesson.tutorialId),
|
.map((tutorial) => tutorial.tutorialId),
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
detail.value = null
|
detail.value = null
|
||||||
@@ -133,40 +133,42 @@ watch(
|
|||||||
'--bad': theme.errorColor,
|
'--bad': theme.errorColor,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div v-if="detail && !detail.lessons.length" class="muted none">还没打开过</div>
|
<div v-if="detail && !detail.tutorials.length" class="muted none">还没打开过</div>
|
||||||
<div v-for="lesson in detail?.lessons ?? []" :key="lesson.tutorialId" class="lesson">
|
<div v-for="tutorial in detail?.tutorials ?? []" :key="tutorial.tutorialId" class="tutorial">
|
||||||
<button type="button" class="lesson-head" @click="toggle(lesson.tutorialId)">
|
<button type="button" class="tutorial-head" @click="toggle(tutorial.tutorialId)">
|
||||||
<span class="lesson-line">
|
<span class="tutorial-line">
|
||||||
<span class="lesson-title">
|
<span class="tutorial-title">
|
||||||
<span class="muted num">第 {{ steps.get(lesson.tutorialId) }} 课</span>
|
<span class="muted num">第 {{ steps.get(tutorial.tutorialId) }} 课</span>
|
||||||
{{ splitTitle(lesson.title).kp }}
|
{{ splitTitle(tutorial.title).kp }}
|
||||||
</span>
|
</span>
|
||||||
<span class="grow" />
|
<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 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>
|
</span>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="lesson.exercises.length || lesson.problems.length"
|
v-if="tutorial.exercises.length || tutorial.problems.length"
|
||||||
:icon="expanded.has(lesson.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
|
:icon="expanded.has(tutorial.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
|
||||||
:width="12"
|
:width="12"
|
||||||
class="muted"
|
class="muted"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="lesson-line small muted num">
|
<span class="tutorial-line small muted num">
|
||||||
<template v-if="lesson.viewCount">
|
<template v-if="tutorial.viewCount">
|
||||||
打开 {{ lesson.viewCount }} 次
|
打开 {{ tutorial.viewCount }} 次
|
||||||
<template v-if="lesson.totalSeconds"> · 停留 {{ readableDuration(lesson.totalSeconds) }}</template>
|
<template v-if="tutorial.totalSeconds"> · 停留 {{ readableDuration(tutorial.totalSeconds) }}</template>
|
||||||
<template v-if="lesson.lastViewedAt"> · {{ parseTime(lesson.lastViewedAt, 'M月D日 HH:mm') }}</template>
|
<template v-if="tutorial.lastViewedAt"> · {{ parseTime(tutorial.lastViewedAt, 'M月D日 HH:mm') }}</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>没打开过,直接做的练一练</template>
|
<template v-else>没打开过,直接做的练一练</template>
|
||||||
<template v-if="!lesson.exercises.length"> · 这课没有练一练</template>
|
<template v-if="!tutorial.exercises.length"> · 这课没有练一练</template>
|
||||||
<template v-if="lesson.problems.length"> · 配套题 {{ lesson.problems.filter((p) => p.solved).length }}/{{ lesson.problems.length }}</template>
|
<template v-if="tutorial.problems.length">
|
||||||
|
· 配套题 {{ tutorial.problems.filter((p) => p.solved).length }}/{{ tutorial.problems.length }}</template
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="expanded.has(lesson.tutorialId) && (lesson.exercises.length || lesson.problems.length)" class="ex-list">
|
<div v-if="expanded.has(tutorial.tutorialId) && (tutorial.exercises.length || tutorial.problems.length)" class="ex-list">
|
||||||
<div v-for="ex in lesson.exercises" :key="ex.exerciseId" class="ex">
|
<div v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="ex">
|
||||||
<span class="square" :class="stateOf(ex)" />
|
<span class="square" :class="stateOf(ex)" />
|
||||||
<div class="ex-body">
|
<div class="ex-body">
|
||||||
<div class="ex-question">
|
<div class="ex-question">
|
||||||
@@ -183,8 +185,8 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="lesson.problems.length" class="sub-head small muted">配套题</div>
|
<div v-if="tutorial.problems.length" class="sub-head small muted">配套题</div>
|
||||||
<div v-for="problem in lesson.problems" :key="problem.id" class="ex">
|
<div v-for="problem in tutorial.problems" :key="problem.id" class="ex">
|
||||||
<span class="square" :class="problemStateOf(problem)" />
|
<span class="square" :class="problemStateOf(problem)" />
|
||||||
<div class="ex-body">
|
<div class="ex-body">
|
||||||
<div class="ex-question">
|
<div class="ex-question">
|
||||||
@@ -235,13 +237,13 @@ watch(
|
|||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.lesson {
|
.tutorial {
|
||||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||||
}
|
}
|
||||||
.lesson:first-child {
|
.tutorial:first-child {
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
.lesson-head {
|
.tutorial-head {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -254,13 +256,13 @@ watch(
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.lesson-line {
|
.tutorial-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.lesson-title {
|
.tutorial-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { parseTime, readableDuration } from 'utils/functions'
|
|||||||
import { useConfigStore } from 'shared/store/config'
|
import { useConfigStore } from 'shared/store/config'
|
||||||
import UserName from 'shared/components/UserName.vue'
|
import UserName from 'shared/components/UserName.vue'
|
||||||
import { groupClassOptions } from 'utils/classes'
|
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 'oj/learn/learn.css'
|
||||||
import ExerciseAttempts from './ExerciseAttempts.vue'
|
import ExerciseAttempts from './ExerciseAttempts.vue'
|
||||||
import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue'
|
import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue'
|
||||||
@@ -30,7 +30,7 @@ const type = ref<TutorialType>('python')
|
|||||||
const className = ref<string | null>(null)
|
const className = ref<string | null>(null)
|
||||||
const board = ref<LearnBoard | null>(null)
|
const board = ref<LearnBoard | null>(null)
|
||||||
const exercises = ref<LearnExerciseProgress[]>([])
|
const exercises = ref<LearnExerciseProgress[]>([])
|
||||||
const lessonId = ref<number | null>(null)
|
const selectedTutorialId = ref<number | null>(null)
|
||||||
const expanded = ref<number | null>(null)
|
const expanded = ref<number | null>(null)
|
||||||
const side = ref<'exercise' | 'problem'>('exercise')
|
const side = ref<'exercise' | 'problem'>('exercise')
|
||||||
const problemOpen = ref<number | null>(null)
|
const problemOpen = ref<number | null>(null)
|
||||||
@@ -58,32 +58,32 @@ const openedCount = computed(() => {
|
|||||||
}
|
}
|
||||||
return counts
|
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(
|
const problemStates = computed(
|
||||||
() => new Map((board.value?.students ?? []).map((row) => [row.userId, new Map(row.problems.map((problem) => [problem.problemId, problem]))])),
|
() => 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 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 maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
|
||||||
const lessonOptions = computed(() =>
|
const tutorialOptions = computed(() =>
|
||||||
groups.value.flatMap((group) =>
|
groups.value.flatMap((group) =>
|
||||||
group.lessons.map((lesson) => ({
|
group.tutorials.map((tutorial) => ({
|
||||||
label: `第 ${lesson.step} 课 ${splitTitle(lesson.title).kp}`,
|
label: `第 ${tutorial.step} 课 ${splitTitle(tutorial.title).kp}`,
|
||||||
value: lesson.id,
|
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) {
|
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 sideProblems = computed(() => {
|
||||||
const id = lessonId.value
|
const id = selectedTutorialId.value
|
||||||
if (id === null) return []
|
if (id === null) return []
|
||||||
const readers = readersOf(id)
|
const readers = readersOf(id)
|
||||||
return (lessonProblems.value.get(id) ?? []).map((problem) => {
|
return (tutorialProblems.value.get(id) ?? []).map((problem) => {
|
||||||
const people: ProblemReader[] = readers.map((row) => ({
|
const people: ProblemReader[] = readers.map((row) => ({
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
@@ -98,43 +98,43 @@ const sideProblems = computed(() => {
|
|||||||
})
|
})
|
||||||
const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value))
|
const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value))
|
||||||
|
|
||||||
function lessonOf(row: LearnBoardStudent, id: number) {
|
function tutorialOf(row: LearnBoardStudent, id: number) {
|
||||||
return row.lessons.find((lesson) => lesson.tutorialId === id)
|
return row.tutorials.find((tutorial) => tutorial.tutorialId === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellOf(row: LearnBoardStudent, id: number) {
|
function cellOf(row: LearnBoardStudent, id: number) {
|
||||||
if (row.done.includes(id)) return 'done'
|
if (row.done.includes(id)) return 'done'
|
||||||
const lesson = lessonOf(row, id)
|
const tutorial = tutorialOf(row, id)
|
||||||
if (lesson) return lesson.solved ? 'partial' : 'partial stuck'
|
if (tutorial) return tutorial.solved ? 'partial' : 'partial stuck'
|
||||||
if (row.opened.includes(id)) return 'opened'
|
if (row.opened.includes(id)) return 'opened'
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
|
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
|
||||||
function cellStyle(row: LearnBoardStudent, id: number) {
|
function cellStyle(row: LearnBoardStudent, id: number) {
|
||||||
const lesson = lessonOf(row, id)
|
const tutorial = tutorialOf(row, id)
|
||||||
const total = exerciseTotal.value.get(id) ?? 0
|
const total = exerciseTotal.value.get(id) ?? 0
|
||||||
if (!lesson || !total || row.done.includes(id)) return undefined
|
if (!tutorial || !total || row.done.includes(id)) return undefined
|
||||||
return { '--fill': `${Math.round((lesson.solved / total) * 100)}%` }
|
return { '--fill': `${Math.round((tutorial.solved / total) * 100)}%` }
|
||||||
}
|
}
|
||||||
|
|
||||||
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
|
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
|
||||||
const head = `第 ${step} 课 ${splitTitle(title).kp}`
|
const head = `第 ${step} 课 ${splitTitle(title).kp}`
|
||||||
const total = exerciseTotal.value.get(id) ?? 0
|
const total = exerciseTotal.value.get(id) ?? 0
|
||||||
const lesson = lessonOf(row, id)
|
const tutorial = tutorialOf(row, id)
|
||||||
const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : ''
|
const wrong = tutorial?.wrongAttempts ? `,错了 ${tutorial.wrongAttempts} 次` : ''
|
||||||
const problems = lessonProblems.value.get(id) ?? []
|
const problems = tutorialProblems.value.get(id) ?? []
|
||||||
const states = problemStates.value.get(row.userId)
|
const states = problemStates.value.get(row.userId)
|
||||||
const tail = problems.length ? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}` : ''
|
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 (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}`
|
if (row.opened.includes(id)) return `${head}:${total ? '打开过,练一练没做' : '打开过'}${tail}`
|
||||||
return `${head}:没打开`
|
return `${head}:没打开`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 他打开过或做过练一练的那些课 */
|
/** 他打开过或做过练一练的那些课 */
|
||||||
function touched(row: LearnBoardStudent) {
|
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) {
|
function problemCount(row: LearnBoardStudent) {
|
||||||
const ids = new Set<number>()
|
const ids = new Set<number>()
|
||||||
for (const id of touched(row)) {
|
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)
|
const states = problemStates.value.get(row.userId)
|
||||||
let solved = 0
|
let solved = 0
|
||||||
@@ -180,7 +180,7 @@ async function load() {
|
|||||||
exercises.value = ex.results
|
exercises.value = ex.results
|
||||||
// 练一练默认看大家打开得最多的那课
|
// 练一练默认看大家打开得最多的那课
|
||||||
const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0]
|
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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -235,11 +235,11 @@ onMounted(load)
|
|||||||
<div class="line head">
|
<div class="line head">
|
||||||
<span class="c-name strong">学到哪了</span>
|
<span class="c-name strong">学到哪了</span>
|
||||||
<div class="cells-head">
|
<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-no" :title="group.project">{{ group.no || group.name }}</span>
|
||||||
<span class="group-steps">
|
<span class="group-steps">
|
||||||
<span v-for="lesson in group.lessons" :key="lesson.id" class="step" :title="splitTitle(lesson.title).kp">
|
<span v-for="tutorial in group.tutorials" :key="tutorial.id" class="step" :title="splitTitle(tutorial.title).kp">
|
||||||
{{ lesson.step }}
|
{{ tutorial.step }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,14 +254,14 @@ onMounted(load)
|
|||||||
<UserName :username="row.username" />
|
<UserName :username="row.username" />
|
||||||
</button>
|
</button>
|
||||||
<div class="cells">
|
<div class="cells">
|
||||||
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
|
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
|
||||||
<span
|
<span
|
||||||
v-for="lesson in group.lessons"
|
v-for="tutorial in group.tutorials"
|
||||||
:key="lesson.id"
|
:key="tutorial.id"
|
||||||
class="cell"
|
class="cell"
|
||||||
:class="cellOf(row, lesson.id)"
|
:class="cellOf(row, tutorial.id)"
|
||||||
:style="cellStyle(row, lesson.id)"
|
:style="cellStyle(row, tutorial.id)"
|
||||||
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)"
|
:title="cellTitle(row, tutorial.step, tutorial.title, tutorial.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,14 +284,14 @@ onMounted(load)
|
|||||||
<div v-else class="line foot">
|
<div v-else class="line foot">
|
||||||
<span class="c-name muted small">每课打开过</span>
|
<span class="c-name muted small">每课打开过</span>
|
||||||
<div class="cells bars">
|
<div class="cells bars">
|
||||||
<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" class="bar-col">
|
<span v-for="tutorial in group.tutorials" :key="tutorial.id" class="bar-col">
|
||||||
<span class="bar-num num">{{ openedCount.get(lesson.id) || '' }}</span>
|
<span class="bar-num num">{{ openedCount.get(tutorial.id) || '' }}</span>
|
||||||
<span
|
<span
|
||||||
class="bar"
|
class="bar"
|
||||||
:class="{ zero: !openedCount.get(lesson.id) }"
|
:class="{ zero: !openedCount.get(tutorial.id) }"
|
||||||
:style="{
|
: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>
|
</span>
|
||||||
@@ -316,7 +316,7 @@ onMounted(load)
|
|||||||
<button type="button" :class="{ on: side === 'exercise' }" @click="side = 'exercise'">练一练</button>
|
<button type="button" :class="{ on: side === 'exercise' }" @click="side = 'exercise'">练一练</button>
|
||||||
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">配套题</button>
|
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">配套题</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<template v-if="side === 'problem'">
|
<template v-if="side === 'problem'">
|
||||||
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
|
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
|
||||||
@@ -333,8 +333,8 @@ onMounted(load)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
|
<div v-else-if="!tutorialExercises.length" class="muted none">这课没有练一练</div>
|
||||||
<div v-for="row in side === 'exercise' ? lessonExercises : []" :key="row.exerciseId" class="ex">
|
<div v-for="row in side === 'exercise' ? tutorialExercises : []" :key="row.exerciseId" class="ex">
|
||||||
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
|
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
|
||||||
<span class="ex-line">
|
<span class="ex-line">
|
||||||
<span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
|
<span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
|
||||||
@@ -711,7 +711,7 @@ onMounted(load)
|
|||||||
.class-select {
|
.class-select {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
}
|
}
|
||||||
.lesson-select {
|
.tutorial-select {
|
||||||
flex: 1 0 100%;
|
flex: 1 0 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -12,11 +12,11 @@ import { errorCode, errorMessage, isApiError } from 'utils/api'
|
|||||||
import { DIFFICULTY, STORAGE_KEY } from 'utils/constants'
|
import { DIFFICULTY, STORAGE_KEY } from 'utils/constants'
|
||||||
import { createZipBlob, getTagColor, unique } from 'utils/functions'
|
import { createZipBlob, getTagColor, unique } from 'utils/functions'
|
||||||
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types'
|
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types'
|
||||||
import PreviewStrip from './components/PreviewStrip.vue'
|
import PreviewStrip from './PreviewStrip.vue'
|
||||||
import { usePreviewCollapsed } from './components/previewCollapse'
|
import { usePreviewCollapsed } from './previewCollapse'
|
||||||
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../api'
|
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../../api'
|
||||||
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './components/problemForm'
|
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './problemForm'
|
||||||
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './components/sqlScript'
|
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './sqlScript'
|
||||||
|
|
||||||
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
|
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
|
||||||
/** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */
|
/** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */
|
||||||
@@ -3,7 +3,7 @@ import { isSqlProblem } from '@oj2/contract'
|
|||||||
import type { AdminProblem } from 'utils/types'
|
import type { AdminProblem } from 'utils/types'
|
||||||
import { getProblem } from '../api'
|
import { getProblem } from '../api'
|
||||||
import ProblemDetail from './detail.vue'
|
import ProblemDetail from './detail.vue'
|
||||||
import SqlDetail from './SqlDetail.vue'
|
import SqlDetail from './components/SqlDetail.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
|
* 出题 / 改题的入口:编程题和 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 { errorMessage } from 'utils/api'
|
||||||
import MarkdownEditor from 'shared/components/MarkdownEditor.vue'
|
import MarkdownEditor from 'shared/components/MarkdownEditor.vue'
|
||||||
import type { Exercise, TutorialEdit, TutorialListItem } from 'utils/types'
|
import type { Exercise, TutorialEdit, TutorialListItem } from 'utils/types'
|
||||||
import { parseLesson } from 'oj/learn/composables/useLessonParse'
|
import { parseTutorial } from 'oj/learn/composables/useTutorialParse'
|
||||||
import { joinTitle, lessonPath, splitTitle } from 'oj/learn/lesson'
|
import { joinTitle, tutorialPath, splitTitle } from 'oj/learn/utils'
|
||||||
import LessonBody from 'oj/learn/components/LessonBody.vue'
|
import TutorialBody from 'oj/learn/components/TutorialBody.vue'
|
||||||
import 'oj/learn/learn.css'
|
import 'oj/learn/learn.css'
|
||||||
import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api'
|
import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api'
|
||||||
import ExerciseManager from './components/ExerciseManager.vue'
|
import ExerciseManager from './components/ExerciseManager.vue'
|
||||||
@@ -69,7 +69,7 @@ const heading = computed(() => {
|
|||||||
const label = tutorial.type === 'c' ? 'C 语言' : 'Python'
|
const label = tutorial.type === 'c' ? 'C 语言' : 'Python'
|
||||||
return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开`
|
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() {
|
async function init() {
|
||||||
allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] }))
|
allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] }))
|
||||||
@@ -131,7 +131,7 @@ function insertExercise(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openStudentView() {
|
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)
|
watch(() => props.tutorialID, init)
|
||||||
@@ -199,7 +199,7 @@ onMounted(init)
|
|||||||
<MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" />
|
<MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" />
|
||||||
</div>
|
</div>
|
||||||
<div class="preview">
|
<div class="preview">
|
||||||
<LessonBody
|
<TutorialBody
|
||||||
:segments="preview.segments"
|
:segments="preview.segments"
|
||||||
:lang="tutorial.type"
|
:lang="tutorial.type"
|
||||||
:tutorial-id="tutorial.id"
|
:tutorial-id="tutorial.id"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { errorMessage } from 'utils/api'
|
import { errorMessage } from 'utils/api'
|
||||||
import type { TutorialListItem } from 'utils/types'
|
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 ProjectTile from 'oj/learn/components/ProjectTile.vue'
|
||||||
import 'oj/learn/learn.css'
|
import 'oj/learn/learn.css'
|
||||||
import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api'
|
import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api'
|
||||||
@@ -28,11 +28,11 @@ const list = computed(() => all.value[type.value] ?? [])
|
|||||||
const groups = computed(() =>
|
const groups = computed(() =>
|
||||||
groupByProject(list.value)
|
groupByProject(list.value)
|
||||||
.map((group) => {
|
.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 {
|
return {
|
||||||
...group,
|
...group,
|
||||||
items,
|
items,
|
||||||
key: `${group.project}:${group.lessons[0]!.id}`,
|
key: `${group.project}:${group.tutorials[0]!.id}`,
|
||||||
hidden: items.every((item) => !item.isPublic),
|
hidden: items.every((item) => !item.isPublic),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -189,7 +189,7 @@ onMounted(load)
|
|||||||
<div
|
<div
|
||||||
v-for="item in group.items"
|
v-for="item in group.items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="row lesson"
|
class="row tutorial"
|
||||||
:class="{ off: !item.isPublic, dragging: dragId === item.id }"
|
:class="{ off: !item.isPublic, dragging: dragId === item.id }"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@dragstart="dragId = item.id"
|
@dragstart="dragId = item.id"
|
||||||
@@ -330,10 +330,10 @@ onMounted(load)
|
|||||||
.group-name {
|
.group-name {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.lesson.off {
|
.tutorial.off {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
.lesson.dragging {
|
.tutorial.dragging {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
.handle {
|
.handle {
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ import storage from 'utils/storage'
|
|||||||
import { useUserStore } from 'shared/store/user'
|
import { useUserStore } from 'shared/store/user'
|
||||||
|
|
||||||
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
|
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
|
||||||
const Dashboard = defineAsyncComponent(() => import('./dashboard.vue'))
|
const StudentHome = defineAsyncComponent(() => import('./student.vue'))
|
||||||
// 老师的首页单独一张(设计稿「首页重设计」HomeTeacher):今天哪些班在上课
|
// 老师的首页单独一张(设计稿「首页重设计」HomeTeacher):今天哪些班在上课
|
||||||
const TeacherHome = defineAsyncComponent(() => import('./TeacherHome.vue'))
|
const TeacherHome = defineAsyncComponent(() => import('./teacher.vue'))
|
||||||
const ProblemList = defineAsyncComponent(() => import('oj/problem/list.vue'))
|
const ProblemList = defineAsyncComponent(() => import('oj/problem/list.vue'))
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// profile 回来之前先按上次的登录标记猜一个:干等的话 /profile 一失败首页就是
|
// 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="showDashboard">
|
<template v-if="showHome">
|
||||||
<!-- 资料回来之前分不出是不是老师,先空着,免得老师先闪一下学生的首页 -->
|
<!-- 资料回来之前分不出是不是老师,先空着,免得老师先闪一下学生的首页 -->
|
||||||
<TeacherHome v-if="userStore.isFinished && userStore.isTeacherOrAbove" />
|
<TeacherHome v-if="userStore.isFinished && userStore.isTeacherOrAbove" />
|
||||||
<Dashboard v-else-if="userStore.isFinished" />
|
<StudentHome v-else-if="userStore.isFinished" />
|
||||||
</template>
|
</template>
|
||||||
<ProblemList v-else />
|
<ProblemList v-else />
|
||||||
</template>
|
</template>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { Icon } from '@iconify/vue'
|
||||||
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
|
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
|
||||||
import { getClassActivity } from 'shared/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> {
|
async function loadTrack(type: TutorialType, label: string): Promise<Track | null> {
|
||||||
@@ -243,7 +243,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
function learnLink(track: Track) {
|
function learnLink(track: Track) {
|
||||||
return lessonPath(track.type, track.step)
|
return tutorialPath(track.type, track.step)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openSubmission(row: SubmissionListItem) {
|
function openSubmission(row: SubmissionListItem) {
|
||||||
File renamed without changes.
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import type { ProjectGroup } from '../lesson'
|
import type { ProjectGroup } from '../utils'
|
||||||
|
|
||||||
/** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */
|
/** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */
|
||||||
defineProps<{ icon: string; tone: ProjectGroup['tone']; size?: number }>()
|
defineProps<{ icon: string; tone: ProjectGroup['tone']; size?: number }>()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import type { TutorialProgress } from '@oj2/contract'
|
import type { TutorialProgress } from '@oj2/contract'
|
||||||
import { lessonPath, lessonState, type ProjectGroup, type TutorialType } from '../lesson'
|
import { tutorialPath, tutorialState, type ProjectGroup, type TutorialType } from '../utils'
|
||||||
import LessonRow from './LessonRow.vue'
|
import TutorialRow from './TutorialRow.vue'
|
||||||
import ProjectTile from './ProjectTile.vue'
|
import ProjectTile from './ProjectTile.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,14 +21,14 @@ const emit = defineEmits<{ go: [] }>()
|
|||||||
const open = ref(new Set<string>())
|
const open = ref(new Set<string>())
|
||||||
|
|
||||||
function keyOf(group: ProjectGroup) {
|
function keyOf(group: ProjectGroup) {
|
||||||
return `${group.project}:${group.lessons[0]!.id}`
|
return `${group.project}:${group.tutorials[0]!.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 换课换到别的项目时,把那个项目也展开(别的已经展开的不收)
|
// 换课换到别的项目时,把那个项目也展开(别的已经展开的不收)
|
||||||
watch(
|
watch(
|
||||||
() => [props.groups, props.currentStep] as const,
|
() => [props.groups, props.currentStep] as const,
|
||||||
([groups, step]) => {
|
([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)])
|
if (group) open.value = new Set([...open.value, keyOf(group)])
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
@@ -43,7 +43,7 @@ function toggle(group: ProjectGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function doneIn(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>
|
</script>
|
||||||
|
|
||||||
@@ -62,23 +62,23 @@ function doneIn(group: ProjectGroup) {
|
|||||||
{{ group.name }}
|
{{ group.name }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="doneIn(group) === group.lessons.length" class="count all">
|
<span v-if="doneIn(group) === group.tutorials.length" class="count all">
|
||||||
<Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.lessons.length }}
|
<Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.tutorials.length }}
|
||||||
</span>
|
</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">
|
<span v-if="big" class="chev">
|
||||||
<Icon :icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :width="14" />
|
<Icon :icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'" :width="14" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="open.has(keyOf(group))" class="lessons">
|
<div v-if="open.has(keyOf(group))" class="tutorials">
|
||||||
<LessonRow
|
<TutorialRow
|
||||||
v-for="lesson in group.lessons"
|
v-for="tutorial in group.tutorials"
|
||||||
:key="lesson.id"
|
:key="tutorial.id"
|
||||||
:title="lesson.title"
|
:title="tutorial.title"
|
||||||
:step="lesson.step"
|
:step="tutorial.step"
|
||||||
:to="lessonPath(type, lesson.step)"
|
:to="tutorialPath(type, tutorial.step)"
|
||||||
:progress="progress[lesson.id]"
|
:progress="progress[tutorial.id]"
|
||||||
:active="lesson.step === currentStep"
|
:active="tutorial.step === currentStep"
|
||||||
:height="big ? 50 : 46"
|
:height="big ? 50 : 46"
|
||||||
@go="emit('go')"
|
@go="emit('go')"
|
||||||
/>
|
/>
|
||||||
@@ -160,13 +160,13 @@ function doneIn(group: ProjectGroup) {
|
|||||||
color: var(--learn-green-text);
|
color: var(--learn-green-text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.lessons {
|
.tutorials {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding-left: 6px;
|
padding-left: 6px;
|
||||||
}
|
}
|
||||||
.big .lessons {
|
.big .tutorials {
|
||||||
padding: 0 0 8px;
|
padding: 0 0 8px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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<{
|
defineProps<{
|
||||||
groups: ProjectGroup[]
|
groups: ProjectGroup[]
|
||||||
stateOf: (id: number) => LessonState
|
stateOf: (id: number) => TutorialState
|
||||||
current?: number
|
current?: number
|
||||||
/** 每段的宽、高 */
|
/** 每段的宽、高 */
|
||||||
width?: number
|
width?: number
|
||||||
@@ -19,12 +19,12 @@ defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="seg" :style="{ gap: `${groupGap ?? 10}px` }" aria-hidden="true">
|
<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
|
<span
|
||||||
v-for="lesson in group.lessons"
|
v-for="tutorial in group.tutorials"
|
||||||
:key="lesson.id"
|
:key="tutorial.id"
|
||||||
class="cell"
|
class="cell"
|
||||||
:class="[stateOf(lesson.id), { current: lesson.id === current }]"
|
:class="[stateOf(tutorial.id), { current: tutorial.id === current }]"
|
||||||
:style="{
|
:style="{
|
||||||
width: `${width ?? 20}px`,
|
width: `${width ?? 20}px`,
|
||||||
height: `${height ?? 6}px`,
|
height: `${height ?? 6}px`,
|
||||||
|
|||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Markdown from 'shared/components/Markdown.vue'
|
import Markdown from 'shared/components/Markdown.vue'
|
||||||
import type { LessonSegment } from '../composables/useLessonParse'
|
import type { TutorialSegment } from '../composables/useTutorialParse'
|
||||||
import CodeRunBlock from './CodeRunBlock.vue'
|
import CodeRunBlock from './CodeRunBlock.vue'
|
||||||
|
|
||||||
const ExerciseWidget = defineAsyncComponent(() => import('./ExerciseWidget.vue'))
|
const ExerciseWidget = defineAsyncComponent(() => import('./ExerciseWidget.vue'))
|
||||||
|
|
||||||
/** 一课的正文:按 useLessonParse 拆好的段落逐段渲染 */
|
/** 一课的正文:按 useTutorialParse 拆好的段落逐段渲染 */
|
||||||
defineProps<{
|
defineProps<{
|
||||||
segments: LessonSegment[]
|
segments: TutorialSegment[]
|
||||||
lang: 'python' | 'c'
|
lang: 'python' | 'c'
|
||||||
tutorialId: number
|
tutorialId: number
|
||||||
exerciseCount: number
|
exerciseCount: number
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import type { TutorialProblem } from '@oj2/contract'
|
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'
|
import SegBar from './SegBar.vue'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,7 +19,7 @@ const props = defineProps<{
|
|||||||
problems: TutorialProblem[]
|
problems: TutorialProblem[]
|
||||||
next: { title: string; to: string } | null
|
next: { title: string; to: string } | null
|
||||||
groups: ProjectGroup[]
|
groups: ProjectGroup[]
|
||||||
stateOf: (id: number) => LessonState
|
stateOf: (id: number) => TutorialState
|
||||||
doneCount: number
|
doneCount: number
|
||||||
/** 窄屏:进度条短一点、题号挪到题目名下面 */
|
/** 窄屏:进度条短一点、题号挪到题目名下面 */
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
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">
|
<script setup lang="ts">
|
||||||
import type { TutorialProgress } from '@oj2/contract'
|
import type { TutorialProgress } from '@oj2/contract'
|
||||||
import { lessonState, splitTitle } from '../lesson'
|
import { tutorialState, splitTitle } from '../utils'
|
||||||
import LessonDot from './LessonDot.vue'
|
import TutorialDot from './TutorialDot.vue'
|
||||||
|
|
||||||
/** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */
|
/** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -16,12 +16,12 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{ go: [] }>()
|
const emit = defineEmits<{ go: [] }>()
|
||||||
|
|
||||||
const parts = computed(() => splitTitle(props.title))
|
const parts = computed(() => splitTitle(props.title))
|
||||||
const state = computed(() => lessonState(props.progress))
|
const state = computed(() => tutorialState(props.progress))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<router-link :to="to" class="row" :class="{ active }" :style="{ height: `${height ?? 46}px` }" @click="emit('go')">
|
<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="text">
|
||||||
<span class="kp">{{ parts.kp }}</span>
|
<span class="kp">{{ parts.kp }}</span>
|
||||||
<span v-if="parts.scene" class="scene">{{ parts.scene }}</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: 'md'; content: string }
|
||||||
| { type: 'heading'; id: string; text: string }
|
| { type: 'heading'; id: string; text: string }
|
||||||
| { type: 'goals'; id: string; items: string[] }
|
| { type: 'goals'; id: string; items: string[] }
|
||||||
@@ -57,9 +57,9 @@ function stripPreamble(lines: string[]) {
|
|||||||
return lines.slice(i)
|
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 byId = new Map(exercises.map((item) => [item.id, item]))
|
||||||
const segments: LessonSegment[] = []
|
const segments: TutorialSegment[] = []
|
||||||
const outline: OutlineItem[] = []
|
const outline: OutlineItem[] = []
|
||||||
let md: string[] = []
|
let md: string[] = []
|
||||||
let codeIndex = 0
|
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>
|
|
||||||
+416
-508
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'
|
export type TutorialType = 'python' | 'c'
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ export interface ProjectGroup {
|
|||||||
icon: string
|
icon: string
|
||||||
tone: 'blue' | 'purple' | 'green' | 'orange' | 'gray'
|
tone: 'blue' | 'purple' | 'green' | 'orange' | 'gray'
|
||||||
/** 这个项目里的课,`step` 是在整门课里的序号(1 起) */
|
/** 这个项目里的课,`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) => {
|
titles.forEach((item, index) => {
|
||||||
const last = groups[groups.length - 1]
|
const last = groups[groups.length - 1]
|
||||||
if (last && last.project === item.project) {
|
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
|
return
|
||||||
}
|
}
|
||||||
const { no, name } = splitProject(item.project)
|
const { no, name } = splitProject(item.project)
|
||||||
@@ -66,21 +66,21 @@ export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] {
|
|||||||
no,
|
no,
|
||||||
name,
|
name,
|
||||||
...projectLook(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
|
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 (!progress) return 'todo'
|
||||||
if (isLessonDone(progress)) return 'done'
|
if (isTutorialDone(progress)) return 'done'
|
||||||
if (progress.viewCount > 0 || progress.exerciseSolved > 0) return 'started'
|
if (progress.viewCount > 0 || progress.exerciseSolved > 0) return 'started'
|
||||||
return 'todo'
|
return 'todo'
|
||||||
}
|
}
|
||||||
@@ -98,17 +98,17 @@ export function continueStep(titles: TutorialSummary[], progress: Record<number,
|
|||||||
if (!latest) return null
|
if (!latest) return null
|
||||||
const index = titles.findIndex((item) => item.id === latest!.tutorialId)
|
const index = titles.findIndex((item) => item.id === latest!.tutorialId)
|
||||||
if (index < 0) return null
|
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++) {
|
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
|
return index + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
export function doneCount(titles: TutorialSummary[], progress: Record<number, TutorialProgress>) {
|
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')}`
|
return `/learn/${type}/${step.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
@@ -558,7 +558,7 @@ function lastText(student: TaskBoardStudent) {
|
|||||||
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : '请假' }}
|
{{ leaveStudents.length ? `请假 ${leaveStudents.length} 人` : '请假' }}
|
||||||
</n-button>
|
</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>
|
<n-button @click="router.push({ name: 'statistics', query: className ? { className } : {} })"> 回头看 → </n-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+13
-13
@@ -113,13 +113,13 @@ export const ojs: RouteRecordRaw = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'learn',
|
path: 'learn',
|
||||||
name: 'learn home',
|
name: 'learn',
|
||||||
component: () => import('oj/learn/home.vue'),
|
component: () => import('oj/learn/index.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'learn/:type/:step',
|
path: 'learn/:type/:step',
|
||||||
name: 'learn',
|
name: 'learn tutorial',
|
||||||
component: () => import('oj/learn/index.vue'),
|
component: () => import('oj/learn/tutorial.vue'),
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -164,13 +164,13 @@ export const admins: RouteRecordRaw = {
|
|||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
name: 'admin home',
|
name: 'admin home',
|
||||||
component: () => import('admin/setting/home.vue'),
|
component: () => import('admin/home/index.vue'),
|
||||||
meta: { requiresSuperAdmin: true },
|
meta: { requiresSuperAdmin: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'config',
|
path: 'config',
|
||||||
name: 'admin config',
|
name: 'admin config',
|
||||||
component: () => import('admin/setting/config.vue'),
|
component: () => import('admin/config/index.vue'),
|
||||||
meta: { requiresSuperAdmin: true },
|
meta: { requiresSuperAdmin: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -291,22 +291,22 @@ export const admins: RouteRecordRaw = {
|
|||||||
meta: { requiresSuperAdmin: true },
|
meta: { requiresSuperAdmin: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'problem/stuck',
|
path: 'analytics/stuck',
|
||||||
name: 'admin stuck problems',
|
name: 'admin stuck problems',
|
||||||
component: () => import('admin/problem/Stuck.vue'),
|
component: () => import('admin/analytics/stuck.vue'),
|
||||||
meta: { requiresTeacherAdmin: true },
|
meta: { requiresTeacherAdmin: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'problem/trend',
|
path: 'analytics/trend',
|
||||||
name: 'admin grade trend',
|
name: 'admin grade trend',
|
||||||
component: () => import('admin/problem/Trend.vue'),
|
component: () => import('admin/analytics/trend.vue'),
|
||||||
meta: { requiresTeacherAdmin: true },
|
meta: { requiresTeacherAdmin: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 课堂任务布置的每一节课:改、删、补(数据统计按布置切课)
|
// 课堂任务布置的每一节课:改、删、补(数据统计按布置切课)
|
||||||
path: 'lesson',
|
path: 'task',
|
||||||
name: 'admin lesson list',
|
name: 'admin task list',
|
||||||
component: () => import('admin/lesson/list.vue'),
|
component: () => import('admin/task/list.vue'),
|
||||||
meta: { requiresTeacherAdmin: true },
|
meta: { requiresTeacherAdmin: true },
|
||||||
},
|
},
|
||||||
// 题单管理路由
|
// 题单管理路由
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const { isDark, toggleDark } = useDarkTransition()
|
|||||||
* 菜单的唯一一张表。`role` 必须和 routes.ts 里对应路由的 meta 对齐:
|
* 菜单的唯一一张表。`role` 必须和 routes.ts 里对应路由的 meta 对齐:
|
||||||
* 菜单里露出一个守卫不放行的入口,点下去就是被静默踢回首页。
|
* 菜单里露出一个守卫不放行的入口,点下去就是被静默踢回首页。
|
||||||
* `path` 同时用来判定高亮:取当前路径能匹配上的**最长**那一条,所以
|
* `path` 同时用来判定高亮:取当前路径能匹配上的**最长**那一条,所以
|
||||||
* /admin/problem/stuck 亮「卡点分析」而不是「题目」。
|
* /admin/analytics/stuck 亮「卡点分析」而不是「首页」。
|
||||||
*/
|
*/
|
||||||
type Role = 'problem' | 'teacher' | 'super'
|
type Role = 'problem' | 'teacher' | 'super'
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -67,7 +67,7 @@ const NAV: NavGroup[] = [
|
|||||||
to: '/admin/problemset/list',
|
to: '/admin/problemset/list',
|
||||||
role: 'teacher',
|
role: 'teacher',
|
||||||
},
|
},
|
||||||
{ label: '课堂任务', icon: 'ph:chalkboard-teacher', path: '/admin/lesson', role: 'teacher' },
|
{ label: '课堂任务', icon: 'ph:chalkboard-teacher', path: '/admin/task', role: 'teacher' },
|
||||||
{
|
{
|
||||||
label: '教程',
|
label: '教程',
|
||||||
icon: 'ph:book-open',
|
icon: 'ph:book-open',
|
||||||
@@ -83,13 +83,13 @@ const NAV: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
label: '卡点分析',
|
label: '卡点分析',
|
||||||
icon: 'ph:warning-diamond',
|
icon: 'ph:warning-diamond',
|
||||||
path: '/admin/problem/stuck',
|
path: '/admin/analytics/stuck',
|
||||||
role: 'teacher',
|
role: 'teacher',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: '年度趋势',
|
label: '年度趋势',
|
||||||
icon: 'ph:chart-line-up',
|
icon: 'ph:chart-line-up',
|
||||||
path: '/admin/problem/trend',
|
path: '/admin/analytics/trend',
|
||||||
role: 'teacher',
|
role: 'teacher',
|
||||||
},
|
},
|
||||||
{ label: '自学情况', icon: 'ph:student', path: '/admin/learn', role: 'teacher' },
|
{ label: '自学情况', icon: 'ph:student', path: '/admin/learn', role: 'teacher' },
|
||||||
|
|||||||
@@ -675,7 +675,7 @@ const learnProblemStateSchema = z.object({
|
|||||||
* 「自学情况」的方块格:一个班的学生 × 这门课的每一课。
|
* 「自学情况」的方块格:一个班的学生 × 这门课的每一课。
|
||||||
*
|
*
|
||||||
* **没打开过任何一课的学生也要有一行**(`opened` 为空)—— 页面上把他们收成一串名单,
|
* **没打开过任何一课的学生也要有一行**(`opened` 为空)—— 页面上把他们收成一串名单,
|
||||||
* 这张表首要回答的就是「谁还没开始」。`done` 是学完的课(练一练全对,见 `isLessonDone`),
|
* 这张表首要回答的就是「谁还没开始」。`done` 是学完的课(练一练全对,见 `isTutorialDone`),
|
||||||
* 一定也在 `opened` 里或者是没打开却把练一练做完了的(老链接直达练习的情况)。
|
* 一定也在 `opened` 里或者是没打开却把练一练做完了的(老链接直达练习的情况)。
|
||||||
*/
|
*/
|
||||||
export const learnBoardStudentSchema = z.object({
|
export const learnBoardStudentSchema = z.object({
|
||||||
@@ -690,7 +690,7 @@ export const learnBoardStudentSchema = z.object({
|
|||||||
* 碰过练一练的课,一课一项(一道没做的课不在里面)。方块格靠它分出「做了一部分」,
|
* 碰过练一练的课,一课一项(一道没做的课不在里面)。方块格靠它分出「做了一部分」,
|
||||||
* `wrongAttempts` 是这课所有练习错的次数加起来
|
* `wrongAttempts` 是这课所有练习错的次数加起来
|
||||||
*/
|
*/
|
||||||
lessons: z.array(
|
tutorials: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
tutorialId: z.number().int(),
|
tutorialId: z.number().int(),
|
||||||
tried: z.number().int(),
|
tried: z.number().int(),
|
||||||
@@ -774,7 +774,7 @@ export const learnStudentDetailSchema = z.object({
|
|||||||
userId: z.number().int(),
|
userId: z.number().int(),
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
className: z.string().nullable(),
|
className: z.string().nullable(),
|
||||||
lessons: z.array(
|
tutorials: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
tutorialId: z.number().int(),
|
tutorialId: z.number().int(),
|
||||||
title: z.string(),
|
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
|
if (progress.exerciseTotal > 0) return progress.exerciseSolved >= progress.exerciseTotal
|
||||||
return progress.viewCount > 0
|
return progress.viewCount > 0
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user