Files
OJ2/apps/web/src/oj/learn/index.vue
T
xuyue d25b5dbe6d
Deploy / deploy (push) Canceled after 0s
update
2026-09-27 19:36:28 +08:00

224 lines
6.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="learn-container">
<template v-if="tutorial.id">
<!-- 桌面端:目录 | 正文(居中限宽) | 可收起的示例代码 -->
<div v-if="isDesktop" class="learn-layout" :class="{ 'with-code': codeOpen }">
<aside class="rail">
<LearnSummary :titles="titles" :progress="progress" :traced="traced" />
<LessonList
:titles="titles"
:step="step"
:progress="progress"
:traced="traced"
@select="goToLesson"
/>
</aside>
<main class="reader">
<article class="reader-body">
<header class="lesson-head">
<n-text depth="3">第 {{ step }} / {{ titles.length }} 课</n-text>
<n-flex align="center" justify="space-between" :wrap="false">
<span />
<n-button v-if="tutorial.code" size="small" secondary @click="codeOpen = !codeOpen">
{{ codeOpen ? "收起示例代码" : "展开示例代码" }}
</n-button>
</n-flex>
</header>
<LessonBody :segments="segments" :lang="tutorial.type" />
</article>
<PagerBar :step="step" :total="titles.length" @go="goToLesson" />
</main>
<aside v-if="tutorial.code && codeOpen" class="code-panel">
<CodeEditor :language="editorLanguage" v-model="tutorial.code" height="100%" />
</aside>
</div>
<!-- 手机端 -->
<template v-else>
<LearnSummary :titles="titles" :progress="progress" :traced="traced" />
<n-tabs type="line" animated v-model:value="activeTab">
<n-tab-pane name="catalog" tab="目录">
<LessonList
:titles="titles"
:step="step"
:progress="progress"
:traced="traced"
@select="goToLesson"
/>
</n-tab-pane>
<n-tab-pane name="content" :tab="`第 ${step} 课`">
<LessonBody :segments="segments" :lang="tutorial.type" />
</n-tab-pane>
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
<CodeEditor :language="editorLanguage" v-model="tutorial.code" />
</n-tab-pane>
</n-tabs>
<PagerBar :step="step" :total="titles.length" @go="goToLesson" />
</template>
</template>
<n-empty v-if="isEmpty" description="该教程还没有公开" style="margin-top: 80px" />
</div>
</template>
<script setup lang="ts">
import type { Tutorial, Exercise, LANGUAGE, TutorialProgress } from "utils/types"
import { getTutorial, getTutorials, getExercises, getLearnProgress } from "../api"
import { parseExercises } from "./composables/useExerciseParse"
import { useLearnTrace } from "./composables/useLearnTrace"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useUserStore } from "shared/store/user"
import LessonList from "./components/LessonList.vue"
import LearnSummary from "./components/LearnSummary.vue"
import LessonBody from "./components/LessonBody.vue"
import PagerBar from "./components/PagerBar.vue"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const userStore = useUserStore()
// 未登录也能看教程(学习页本来就不要求登录),只是不留痕
const traced = computed(() => userStore.isAuthed)
const step = computed(() => {
const value = route.params.step as string | undefined
if (!value) return 1
return parseInt(value)
})
const type = computed<"python" | "c">(() => (route.params.type === "c" ? "c" : "python"))
const tutorial = ref<Partial<Tutorial>>({
id: 0,
title: "",
content: "",
code: "",
})
const editorLanguage = computed<LANGUAGE>(() => (tutorial.value.type === "c" ? "C" : "Python"))
const titles = ref<{ id: number; title: string }[]>([])
const progress = ref<Record<number, TutorialProgress>>({})
const exercises = ref<Exercise[]>([])
const activeTab = ref("content")
// 示例代码栏默认展开,收起后正文独占版面;偏好记在本机
const codeOpen = useStorage("oj2:learn-code-open", true)
const isEmpty = ref(false)
const segments = computed(() => parseExercises(tutorial.value.content ?? "", exercises.value))
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
useLearnTrace(
computed(() => tutorial.value.id ?? 0),
traced,
)
function goToLesson(lessonNumber: number) {
activeTab.value = "content"
router.push(`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`)
}
/**
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响上课,
* 但弹个错会把「我是不是没学」的焦虑塞给学生。
*/
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 = {}
}
}
async function init() {
const res1 = await getTutorials(type.value)
titles.value = res1
isEmpty.value = titles.value.length === 0
if (isEmpty.value) return
const id = titles.value[step.value - 1].id
const [res2, exs] = await Promise.allSettled([getTutorial(id), getExercises(id)])
if (res2.status === "fulfilled") tutorial.value = res2.value
exercises.value = exs.status === "fulfilled" ? exs.value : []
learnStep.value[type.value] = step.value
loadProgress()
}
watch(
() => [route.params.type, route.params.step],
async () => {
if (route.name === "learn") init()
},
{ immediate: true },
)
// 在教程页上登录/退出时把目录的勾重新拉一遍。学生多半是先点开教程、
// 被弹窗拦下才登录的,不盯着这个的话勾要等他刷新页面才出现
watch(traced, loadProgress)
</script>
<style scoped>
/* 桌面端固定高度,目录/正文/代码各自内部滚动;移动端交给页面整体滚动 */
@media (min-width: 769px) {
.learn-container {
height: calc(100vh - 138px);
}
}
.learn-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 24px;
height: 100%;
}
.learn-layout.with-code {
grid-template-columns: 240px minmax(0, 1fr) minmax(360px, 40%);
}
.rail,
.reader {
overflow-y: auto;
height: 100%;
}
.reader {
display: flex;
flex-direction: column;
}
.reader-body {
flex: 1;
width: 100%;
max-width: 820px;
margin: 0 auto;
}
.reader :deep(.pager) {
max-width: 820px;
width: 100%;
margin-left: auto;
margin-right: auto;
}
.lesson-head h1,
.mobile-title {
margin: 4px 0 12px;
font-size: 26px;
line-height: 1.3;
}
.mobile-title {
font-size: 20px;
}
.code-panel {
height: 100%;
overflow: hidden;
border-radius: 8px;
}
</style>