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

- 后台课堂任务 admin/lesson → admin/task(/admin/task),和前台 oj/task 对上
- 后台首页 admin/setting/home.vue → admin/home/,设置 → admin/config/
- 卡点分析、年度趋势从 admin/problem 搬到 admin/analytics(/admin/analytics/stuck|trend),和菜单的「学情分析」、后端 analytics.ts 对上
- 首页 dashboard.vue / TeacherHome.vue → student.vue / teacher.vue;自学首页 home.vue → index.vue,看一课 index.vue → tutorial.vue
- lesson 只表示课堂上的一节:自学那边改叫 tutorial(oj/learn/lesson.ts → utils.ts、isLessonDone → isTutorialDone、Lesson*.vue → Tutorial*.vue、自学情况的 lessons 字段 → tutorials)
- 旧地址不留跳转

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-02 22:59:43 +08:00
1 parent 29c31640b5
commit 48590eee60
37 files changed
+1238 -1236

No files matched your search

@@ -8,7 +8,7 @@ import { avgTries, rate } from './stuckMath'
/**
* 卡点分析右栏:一道题的详情(做过几人、一次就对、错在哪、各班、交了几次才对、历年、
* 还没做对的人)。从 Stuck.vue 拆出来,取数和三栏的联动还在那边。
* 还没做对的人)。从 stuck.vue 拆出来,取数和三栏的联动还在那边。
*/
const props = defineProps<{
detail: StuckProblemDetail
@@ -1,4 +1,4 @@
/** 卡点分析(Stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
/** 卡点分析(stuck.vue)和它的单题详情(StuckDetail.vue)共用的两个算法 */
/** 百分比,取整;分母是 0 时给 0 */
export function rate(part: number, whole: number) {
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+45 -43
View File
@@ -7,7 +7,7 @@ import type { LearnStudentDetail } from 'utils/types'
import UserName from 'shared/components/UserName.vue'
import { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints'
import { splitTitle, type TutorialType } from 'oj/learn/lesson'
import { splitTitle, type TutorialType } from 'oj/learn/utils'
import { EXERCISE_KIND } from './kinds'
/**
@@ -30,31 +30,31 @@ const theme = useThemeVars()
const { isDesktop } = useBreakpoints()
useBackClose(show, () => !isDesktop.value)
type Lesson = LearnStudentDetail['lessons'][number]
type Exercise = Lesson['exercises'][number]
type Problem = Lesson['problems'][number]
type Tutorial = LearnStudentDetail['tutorials'][number]
type Exercise = Tutorial['exercises'][number]
type Problem = Tutorial['problems'][number]
const detail = ref<LearnStudentDetail | null>(null)
const loading = ref(false)
const expanded = ref(new Set<number>())
const summary = computed(() => {
const lessons = detail.value?.lessons ?? []
const exercises = lessons.flatMap((lesson) => lesson.exercises)
const tutorials = detail.value?.tutorials ?? []
const exercises = tutorials.flatMap((tutorial) => tutorial.exercises)
// 一道题挂在两课只算一次
const problems = new Map(lessons.flatMap((lesson) => lesson.problems).map((p) => [p.id, p]))
const problems = new Map(tutorials.flatMap((tutorial) => tutorial.problems).map((p) => [p.id, p]))
return {
problemSolved: [...problems.values()].filter((p) => p.solved).length,
problemTotal: problems.size,
opened: lessons.filter((lesson) => lesson.viewCount > 0).length,
seconds: lessons.reduce((n, lesson) => n + lesson.totalSeconds, 0),
opened: tutorials.filter((tutorial) => tutorial.viewCount > 0).length,
seconds: tutorials.reduce((n, tutorial) => n + tutorial.totalSeconds, 0),
solved: exercises.filter((ex) => ex.solved).length,
total: exercises.length,
}
})
function solvedOf(lesson: Lesson) {
return lesson.exercises.filter((ex) => ex.solved).length
function solvedOf(tutorial: Tutorial) {
return tutorial.exercises.filter((ex) => ex.solved).length
}
function stateOf(ex: Exercise) {
@@ -92,13 +92,13 @@ watch(
const data = await getLearnStudent(userId, { type })
detail.value = data
expanded.value = new Set(
data.lessons
data.tutorials
.filter(
(lesson) =>
(lesson.exercises.some((ex) => ex.attempts > 0) && lesson.exercises.some((ex) => !ex.solved)) ||
lesson.problems.some((p) => p.submissions > 0 && !p.solved),
(tutorial) =>
(tutorial.exercises.some((ex) => ex.attempts > 0) && tutorial.exercises.some((ex) => !ex.solved)) ||
tutorial.problems.some((p) => p.submissions > 0 && !p.solved),
)
.map((lesson) => lesson.tutorialId),
.map((tutorial) => tutorial.tutorialId),
)
} catch {
detail.value = null
@@ -133,40 +133,42 @@ watch(
'--bad': theme.errorColor,
}"
>
<div v-if="detail && !detail.lessons.length" class="muted none">还没打开过</div>
<div v-for="lesson in detail?.lessons ?? []" :key="lesson.tutorialId" class="lesson">
<button type="button" class="lesson-head" @click="toggle(lesson.tutorialId)">
<span class="lesson-line">
<span class="lesson-title">
<span class="muted num">第 {{ steps.get(lesson.tutorialId) }} 课</span>
{{ splitTitle(lesson.title).kp }}
<div v-if="detail && !detail.tutorials.length" class="muted none">还没打开过</div>
<div v-for="tutorial in detail?.tutorials ?? []" :key="tutorial.tutorialId" class="tutorial">
<button type="button" class="tutorial-head" @click="toggle(tutorial.tutorialId)">
<span class="tutorial-line">
<span class="tutorial-title">
<span class="muted num">第 {{ steps.get(tutorial.tutorialId) }} 课</span>
{{ splitTitle(tutorial.title).kp }}
</span>
<span class="grow" />
<span v-if="lesson.exercises.length" class="small num"> {{ solvedOf(lesson) }}/{{ lesson.exercises.length }} </span>
<span v-if="tutorial.exercises.length" class="small num"> {{ solvedOf(tutorial) }}/{{ tutorial.exercises.length }} </span>
<span class="squares">
<span v-for="ex in lesson.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" />
<span v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="square" :class="stateOf(ex)" />
</span>
<Icon
v-if="lesson.exercises.length || lesson.problems.length"
:icon="expanded.has(lesson.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
v-if="tutorial.exercises.length || tutorial.problems.length"
:icon="expanded.has(tutorial.tutorialId) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
:width="12"
class="muted"
/>
</span>
<span class="lesson-line small muted num">
<template v-if="lesson.viewCount">
打开 {{ lesson.viewCount }} 次
<template v-if="lesson.totalSeconds"> · 停留 {{ readableDuration(lesson.totalSeconds) }}</template>
<template v-if="lesson.lastViewedAt"> · {{ parseTime(lesson.lastViewedAt, 'M月D日 HH:mm') }}</template>
<span class="tutorial-line small muted num">
<template v-if="tutorial.viewCount">
打开 {{ tutorial.viewCount }} 次
<template v-if="tutorial.totalSeconds"> · 停留 {{ readableDuration(tutorial.totalSeconds) }}</template>
<template v-if="tutorial.lastViewedAt"> · {{ parseTime(tutorial.lastViewedAt, 'M月D日 HH:mm') }}</template>
</template>
<template v-else>没打开过,直接做的练一练</template>
<template v-if="!lesson.exercises.length"> · 这课没有练一练</template>
<template v-if="lesson.problems.length"> · 配套题 {{ lesson.problems.filter((p) => p.solved).length }}/{{ lesson.problems.length }}</template>
<template v-if="!tutorial.exercises.length"> · 这课没有练一练</template>
<template v-if="tutorial.problems.length">
· 配套题 {{ tutorial.problems.filter((p) => p.solved).length }}/{{ tutorial.problems.length }}</template
>
</span>
</button>
<div v-if="expanded.has(lesson.tutorialId) && (lesson.exercises.length || lesson.problems.length)" class="ex-list">
<div v-for="ex in lesson.exercises" :key="ex.exerciseId" class="ex">
<div v-if="expanded.has(tutorial.tutorialId) && (tutorial.exercises.length || tutorial.problems.length)" class="ex-list">
<div v-for="ex in tutorial.exercises" :key="ex.exerciseId" class="ex">
<span class="square" :class="stateOf(ex)" />
<div class="ex-body">
<div class="ex-question">
@@ -183,8 +185,8 @@ watch(
</div>
</div>
</div>
<div v-if="lesson.problems.length" class="sub-head small muted">配套题</div>
<div v-for="problem in lesson.problems" :key="problem.id" class="ex">
<div v-if="tutorial.problems.length" class="sub-head small muted">配套题</div>
<div v-for="problem in tutorial.problems" :key="problem.id" class="ex">
<span class="square" :class="problemStateOf(problem)" />
<div class="ex-body">
<div class="ex-question">
@@ -235,13 +237,13 @@ watch(
padding: 24px 0;
text-align: center;
}
.lesson {
.tutorial {
border-top: 1px solid rgba(128, 128, 128, 0.15);
}
.lesson:first-child {
.tutorial:first-child {
border-top: 0;
}
.lesson-head {
.tutorial-head {
width: 100%;
padding: 12px 0;
border: 0;
@@ -254,13 +256,13 @@ watch(
gap: 4px;
cursor: pointer;
}
.lesson-line {
.tutorial-line {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.lesson-title {
.tutorial-title {
font-size: 14px;
font-weight: 600;
min-width: 0;
+44 -44
View File
@@ -7,7 +7,7 @@ import { parseTime, readableDuration } from 'utils/functions'
import { useConfigStore } from 'shared/store/config'
import UserName from 'shared/components/UserName.vue'
import { groupClassOptions } from 'utils/classes'
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson'
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
import 'oj/learn/learn.css'
import ExerciseAttempts from './ExerciseAttempts.vue'
import ProblemReaders, { type ProblemReader } from './ProblemReaders.vue'
@@ -30,7 +30,7 @@ const type = ref<TutorialType>('python')
const className = ref<string | null>(null)
const board = ref<LearnBoard | null>(null)
const exercises = ref<LearnExerciseProgress[]>([])
const lessonId = ref<number | null>(null)
const selectedTutorialId = ref<number | null>(null)
const expanded = ref<number | null>(null)
const side = ref<'exercise' | 'problem'>('exercise')
const problemOpen = ref<number | null>(null)
@@ -58,32 +58,32 @@ const openedCount = computed(() => {
}
return counts
})
const lessonProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])))
const tutorialProblems = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.problems])))
/** 学生 → 题 → 交过的情况 */
const problemStates = computed(
() => new Map((board.value?.students ?? []).map((row) => [row.userId, new Map(row.problems.map((problem) => [problem.problemId, problem]))])),
)
const exerciseTotal = computed(() => new Map((board.value?.tutorials ?? []).map((row) => [row.id, row.exerciseTotal])))
const steps = computed(() => new Map(groups.value.flatMap((group) => group.lessons.map((lesson) => [lesson.id, lesson.step]))))
const steps = computed(() => new Map(groups.value.flatMap((group) => group.tutorials.map((tutorial) => [tutorial.id, tutorial.step]))))
const maxOpened = computed(() => Math.max(1, ...openedCount.value.values()))
const lessonOptions = computed(() =>
const tutorialOptions = computed(() =>
groups.value.flatMap((group) =>
group.lessons.map((lesson) => ({
label: `第 ${lesson.step} 课 ${splitTitle(lesson.title).kp}`,
value: lesson.id,
group.tutorials.map((tutorial) => ({
label: `第 ${tutorial.step} 课 ${splitTitle(tutorial.title).kp}`,
value: tutorial.id,
})),
),
)
const lessonExercises = computed(() => exercises.value.filter((row) => row.tutorialId === lessonId.value))
const tutorialExercises = computed(() => exercises.value.filter((row) => row.tutorialId === selectedTutorialId.value))
/** 读过(或做过练一练)这一课的人 */
function readersOf(id: number) {
return started.value.filter((row) => row.opened.includes(id) || row.lessons.some((lesson) => lesson.tutorialId === id))
return started.value.filter((row) => row.opened.includes(id) || row.tutorials.some((tutorial) => tutorial.tutorialId === id))
}
const sideProblems = computed(() => {
const id = lessonId.value
const id = selectedTutorialId.value
if (id === null) return []
const readers = readersOf(id)
return (lessonProblems.value.get(id) ?? []).map((problem) => {
return (tutorialProblems.value.get(id) ?? []).map((problem) => {
const people: ProblemReader[] = readers.map((row) => ({
userId: row.userId,
username: row.username,
@@ -98,43 +98,43 @@ const sideProblems = computed(() => {
})
const openProblem = computed(() => sideProblems.value.find((row) => row.id === problemOpen.value))
function lessonOf(row: LearnBoardStudent, id: number) {
return row.lessons.find((lesson) => lesson.tutorialId === id)
function tutorialOf(row: LearnBoardStudent, id: number) {
return row.tutorials.find((tutorial) => tutorial.tutorialId === id)
}
function cellOf(row: LearnBoardStudent, id: number) {
if (row.done.includes(id)) return 'done'
const lesson = lessonOf(row, id)
if (lesson) return lesson.solved ? 'partial' : 'partial stuck'
const tutorial = tutorialOf(row, id)
if (tutorial) return tutorial.solved ? 'partial' : 'partial stuck'
if (row.opened.includes(id)) return 'opened'
return ''
}
/** 做了一部分的格子:做对几成,绿就从下往上涨几成 */
function cellStyle(row: LearnBoardStudent, id: number) {
const lesson = lessonOf(row, id)
const tutorial = tutorialOf(row, id)
const total = exerciseTotal.value.get(id) ?? 0
if (!lesson || !total || row.done.includes(id)) return undefined
return { '--fill': `${Math.round((lesson.solved / total) * 100)}%` }
if (!tutorial || !total || row.done.includes(id)) return undefined
return { '--fill': `${Math.round((tutorial.solved / total) * 100)}%` }
}
function cellTitle(row: LearnBoardStudent, step: number, title: string, id: number) {
const head = `第 ${step} 课 ${splitTitle(title).kp}`
const total = exerciseTotal.value.get(id) ?? 0
const lesson = lessonOf(row, id)
const wrong = lesson?.wrongAttempts ? `,错了 ${lesson.wrongAttempts} 次` : ''
const problems = lessonProblems.value.get(id) ?? []
const tutorial = tutorialOf(row, id)
const wrong = tutorial?.wrongAttempts ? `,错了 ${tutorial.wrongAttempts} 次` : ''
const problems = tutorialProblems.value.get(id) ?? []
const states = problemStates.value.get(row.userId)
const tail = problems.length ? ` · 配套题 ${problems.filter((problem) => states?.get(problem.id)?.solved).length}/${problems.length}` : ''
if (row.done.includes(id)) return `${head}:学完了${wrong}${tail}`
if (lesson) return `${head}:练一练 ${lesson.solved}/${total}${wrong}${tail}`
if (tutorial) return `${head}:练一练 ${tutorial.solved}/${total}${wrong}${tail}`
if (row.opened.includes(id)) return `${head}:${total ? '打开过,练一练没做' : '打开过'}${tail}`
return `${head}:没打开`
}
/** 他打开过或做过练一练的那些课 */
function touched(row: LearnBoardStudent) {
return new Set([...row.opened, ...row.lessons.map((lesson) => lesson.tutorialId)])
return new Set([...row.opened, ...row.tutorials.map((tutorial) => tutorial.tutorialId)])
}
/** 应做 = 他碰过的那些课,练一练加起来几道 */
@@ -148,7 +148,7 @@ function exerciseDue(row: LearnBoardStudent) {
function problemCount(row: LearnBoardStudent) {
const ids = new Set<number>()
for (const id of touched(row)) {
for (const problem of lessonProblems.value.get(id) ?? []) ids.add(problem.id)
for (const problem of tutorialProblems.value.get(id) ?? []) ids.add(problem.id)
}
const states = problemStates.value.get(row.userId)
let solved = 0
@@ -180,7 +180,7 @@ async function load() {
exercises.value = ex.results
// 练一练默认看大家打开得最多的那课
const busiest = [...openedCount.value.entries()].sort((a, b) => b[1] - a[1])[0]
lessonId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null
selectedTutorialId.value = busiest?.[0] ?? data.tutorials[0]?.id ?? null
} finally {
loading.value = false
}
@@ -235,11 +235,11 @@ onMounted(load)
<div class="line head">
<span class="c-name strong">学到哪了</span>
<div class="cells-head">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-head">
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-head">
<span class="group-no" :title="group.project">{{ group.no || group.name }}</span>
<span class="group-steps">
<span v-for="lesson in group.lessons" :key="lesson.id" class="step" :title="splitTitle(lesson.title).kp">
{{ lesson.step }}
<span v-for="tutorial in group.tutorials" :key="tutorial.id" class="step" :title="splitTitle(tutorial.title).kp">
{{ tutorial.step }}
</span>
</span>
</div>
@@ -254,14 +254,14 @@ onMounted(load)
<UserName :username="row.username" />
</button>
<div class="cells">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
<span
v-for="lesson in group.lessons"
:key="lesson.id"
v-for="tutorial in group.tutorials"
:key="tutorial.id"
class="cell"
:class="cellOf(row, lesson.id)"
:style="cellStyle(row, lesson.id)"
:title="cellTitle(row, lesson.step, lesson.title, lesson.id)"
:class="cellOf(row, tutorial.id)"
:style="cellStyle(row, tutorial.id)"
:title="cellTitle(row, tutorial.step, tutorial.title, tutorial.id)"
/>
</div>
</div>
@@ -284,14 +284,14 @@ onMounted(load)
<div v-else class="line foot">
<span class="c-name muted small">每课打开过</span>
<div class="cells bars">
<div v-for="group in groups" :key="group.lessons[0]!.id" class="group-cells">
<span v-for="lesson in group.lessons" :key="lesson.id" class="bar-col">
<span class="bar-num num">{{ openedCount.get(lesson.id) || '' }}</span>
<div v-for="group in groups" :key="group.tutorials[0]!.id" class="group-cells">
<span v-for="tutorial in group.tutorials" :key="tutorial.id" class="bar-col">
<span class="bar-num num">{{ openedCount.get(tutorial.id) || '' }}</span>
<span
class="bar"
:class="{ zero: !openedCount.get(lesson.id) }"
:class="{ zero: !openedCount.get(tutorial.id) }"
:style="{
height: `${Math.max(2, ((openedCount.get(lesson.id) ?? 0) / maxOpened) * 40)}px`,
height: `${Math.max(2, ((openedCount.get(tutorial.id) ?? 0) / maxOpened) * 40)}px`,
}"
/>
</span>
@@ -316,7 +316,7 @@ onMounted(load)
<button type="button" :class="{ on: side === 'exercise' }" @click="side = 'exercise'">练一练</button>
<button type="button" :class="{ on: side === 'problem' }" @click="side = 'problem'">配套题</button>
</div>
<n-select v-model:value="lessonId" :options="lessonOptions" size="small" class="lesson-select" />
<n-select v-model:value="selectedTutorialId" :options="tutorialOptions" size="small" class="tutorial-select" />
</div>
<template v-if="side === 'problem'">
<div v-if="!sideProblems.length" class="muted none">这课没有配套题</div>
@@ -333,8 +333,8 @@ onMounted(load)
</button>
</div>
</template>
<div v-else-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
<div v-for="row in side === 'exercise' ? lessonExercises : []" :key="row.exerciseId" class="ex">
<div v-else-if="!tutorialExercises.length" class="muted none">这课没有练一练</div>
<div v-for="row in side === 'exercise' ? tutorialExercises : []" :key="row.exerciseId" class="ex">
<button type="button" class="ex-main" @click="expanded = row.exerciseId">
<span class="ex-line">
<span class="kind">{{ EXERCISE_KIND[row.type] ?? row.type }}</span>
@@ -711,7 +711,7 @@ onMounted(load)
.class-select {
width: 170px;
}
.lesson-select {
.tutorial-select {
flex: 1 0 100%;
min-width: 0;
}
@@ -12,11 +12,11 @@ import { errorCode, errorMessage, isApiError } from 'utils/api'
import { DIFFICULTY, STORAGE_KEY } from 'utils/constants'
import { createZipBlob, getTagColor, unique } from 'utils/functions'
import type { AdminProblem, BlankProblem, SqlConfig, Tag, Testcase } from 'utils/types'
import PreviewStrip from './components/PreviewStrip.vue'
import { usePreviewCollapsed } from './components/previewCollapse'
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../api'
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './components/problemForm'
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './components/sqlScript'
import PreviewStrip from './PreviewStrip.vue'
import { usePreviewCollapsed } from './previewCollapse'
import { createContestProblem, createProblem, editProblem, generateSQLTestcase, getSQLTestcaseScripts, previewSQLTestcase, uploadTestcases } from '../../api'
import { cleanTags, difficultyOptions, isBlankHtml, problemListRoute, saveErrorText, tagTooLongText, useCreateDraft } from './problemForm'
import { explainSqlError, groupSummary, type GroupStatus, type ScriptGroup } from './sqlScript'
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
/** 标准答案常是一长行,出题页左栏才 560 宽:折行,别横着滚。给稳定的数组,见 CodeEditor 的注释 */
+1 -1
View File
@@ -3,7 +3,7 @@ import { isSqlProblem } from '@oj2/contract'
import type { AdminProblem } from 'utils/types'
import { getProblem } from '../api'
import ProblemDetail from './detail.vue'
import SqlDetail from './SqlDetail.vue'
import SqlDetail from './components/SqlDetail.vue'
/**
* 出题 / 改题的入口:编程题和 SQL 题是两张不同的页。
File renamed without changes.
+6 -6
View File
@@ -4,9 +4,9 @@ import type { AdminTutorialProblem } from '@oj2/contract'
import { errorMessage } from 'utils/api'
import MarkdownEditor from 'shared/components/MarkdownEditor.vue'
import type { Exercise, TutorialEdit, TutorialListItem } from 'utils/types'
import { parseLesson } from 'oj/learn/composables/useLessonParse'
import { joinTitle, lessonPath, splitTitle } from 'oj/learn/lesson'
import LessonBody from 'oj/learn/components/LessonBody.vue'
import { parseTutorial } from 'oj/learn/composables/useTutorialParse'
import { joinTitle, tutorialPath, splitTitle } from 'oj/learn/utils'
import TutorialBody from 'oj/learn/components/TutorialBody.vue'
import 'oj/learn/learn.css'
import { createTutorial, deleteTutorial, getTutorial, getTutorialList, updateTutorial } from '../api'
import ExerciseManager from './components/ExerciseManager.vue'
@@ -69,7 +69,7 @@ const heading = computed(() => {
const label = tutorial.type === 'c' ? 'C 语言' : 'Python'
return step.value ? `${label} 第 ${step.value} 课` : `${label} · 没公开`
})
const preview = computed(() => parseLesson(tutorial.content, exercises.value))
const preview = computed(() => parseTutorial(tutorial.content, exercises.value))
async function init() {
allTutorials.value = await getTutorialList().catch(() => ({ python: [], c: [] }))
@@ -131,7 +131,7 @@ function insertExercise(id: number) {
}
function openStudentView() {
if (step.value) window.open(router.resolve(lessonPath(tutorial.type, step.value)).href, '_blank')
if (step.value) window.open(router.resolve(tutorialPath(tutorial.type, step.value)).href, '_blank')
}
watch(() => props.tutorialID, init)
@@ -199,7 +199,7 @@ onMounted(init)
<MarkdownEditor ref="editor" v-model:value="tutorial.content" :code-lang="tutorial.type" height="100%" />
</div>
<div class="preview">
<LessonBody
<TutorialBody
:segments="preview.segments"
:lang="tutorial.type"
:tutorial-id="tutorial.id"
+6 -6
View File
@@ -2,7 +2,7 @@
import { Icon } from '@iconify/vue'
import { errorMessage } from 'utils/api'
import type { TutorialListItem } from 'utils/types'
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/lesson'
import { groupByProject, splitTitle, type TutorialType } from 'oj/learn/utils'
import ProjectTile from 'oj/learn/components/ProjectTile.vue'
import 'oj/learn/learn.css'
import { getTutorialList, renameTutorialProject, reorderTutorials, setTutorialVisibility } from '../api'
@@ -28,11 +28,11 @@ const list = computed(() => all.value[type.value] ?? [])
const groups = computed(() =>
groupByProject(list.value)
.map((group) => {
const items = group.lessons.map((lesson) => list.value.find((item) => item.id === lesson.id)!)
const items = group.tutorials.map((entry) => list.value.find((item) => item.id === entry.id)!)
return {
...group,
items,
key: `${group.project}:${group.lessons[0]!.id}`,
key: `${group.project}:${group.tutorials[0]!.id}`,
hidden: items.every((item) => !item.isPublic),
}
})
@@ -189,7 +189,7 @@ onMounted(load)
<div
v-for="item in group.items"
:key="item.id"
class="row lesson"
class="row tutorial"
:class="{ off: !item.isPublic, dragging: dragId === item.id }"
draggable="true"
@dragstart="dragId = item.id"
@@ -330,10 +330,10 @@ onMounted(load)
.group-name {
font-weight: 700;
}
.lesson.off {
.tutorial.off {
opacity: 0.6;
}
.lesson.dragging {
.tutorial.dragging {
opacity: 0.35;
}
.handle {