- 顶栏「自学」进新的自学首页 /learn:接着学(按服务端记录,不再串到上一个学生)+ 按项目分组的课程地图 - 读一课:左边目录按项目折叠,正文加宽,右边本课大纲带练一练标记;去掉了不能运行的示例代码栏 - 正文里的代码块能改能运行,报错用题目页那套中文解释;读输入的代码出输入框 - 课末:练一练做到几道 → 学完了 → 老师挑的几道题(必会标出来)→ 下一课 - 练一练统一成绿色提交、结果一行字;手机版目录从底部弹出 - 后台:教程列表按项目分组拖动排序,编辑页左写右看、练一练插到光标处、挑相关题; 自学情况改成方块格 + 还没打开过的名单 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
11194543a3
commit
845e41c060
43 files changed
+4675
-1294
No files matched your search
+522
-150
@@ -1,129 +1,97 @@
|
||||
<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 { 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 { parseLesson } from "./composables/useLessonParse"
|
||||
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 {
|
||||
COURSES,
|
||||
doneCount,
|
||||
groupByProject,
|
||||
lessonPath,
|
||||
lessonState,
|
||||
splitTitle,
|
||||
type TutorialType,
|
||||
} from "./lesson"
|
||||
import LessonBody from "./components/LessonBody.vue"
|
||||
import PagerBar from "./components/PagerBar.vue"
|
||||
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
||||
import LessonEnd from "./components/LessonEnd.vue"
|
||||
import LessonOutline from "./components/LessonOutline.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 { learnStep } = useLearnProgress()
|
||||
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 = route.params.step as string | undefined
|
||||
if (!value) return 1
|
||||
return parseInt(value)
|
||||
const value = parseInt((route.params.step as string | undefined) ?? "1")
|
||||
return Number.isFinite(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
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 titles = ref<TutorialSummary[]>([])
|
||||
const progress = ref<Record<number, TutorialProgress>>({})
|
||||
const tutorial = ref<Tutorial | null>(null)
|
||||
const exercises = ref<Exercise[]>([])
|
||||
const activeTab = ref("content")
|
||||
// 示例代码栏默认展开,收起后正文独占版面;偏好记在本机
|
||||
const codeOpen = useStorage("oj2:learn-code-open", true)
|
||||
const solvedIds = ref(new Set<number>())
|
||||
const loading = ref(true)
|
||||
const isEmpty = ref(false)
|
||||
const menuOpen = ref(false)
|
||||
|
||||
const segments = computed(() => parseExercises(tutorial.value.content ?? "", exercises.value))
|
||||
const groups = computed(() => groupByProject(titles.value))
|
||||
const parsed = computed(() =>
|
||||
tutorial.value
|
||||
? parseLesson(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(() => lessonAt(step.value - 1))
|
||||
const next = computed(() => lessonAt(step.value + 1))
|
||||
const done = computed(() => doneCount(titles.value, progress.value))
|
||||
|
||||
function lessonAt(at: number) {
|
||||
const item = titles.value[at - 1]
|
||||
return item ? { title: item.title, to: lessonPath(type.value, at) } : null
|
||||
}
|
||||
|
||||
function stateOf(id: number) {
|
||||
return lessonState(progress.value[id])
|
||||
}
|
||||
|
||||
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
|
||||
useLearnTrace(
|
||||
computed(() => tutorial.value.id ?? 0),
|
||||
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() {
|
||||
@@ -139,85 +107,489 @@ async function loadProgress() {
|
||||
}
|
||||
}
|
||||
|
||||
let generation = 0
|
||||
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
|
||||
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(lessonPath(type.value, list.length))
|
||||
return
|
||||
}
|
||||
const id = list[step.value - 1]!.id
|
||||
const [lesson, exs] = await Promise.allSettled([
|
||||
getTutorial(id),
|
||||
getExercises(id),
|
||||
loadProgress(),
|
||||
])
|
||||
if (mine !== generation) return
|
||||
tutorial.value = lesson.status === "fulfilled" ? lesson.value : null
|
||||
exercises.value = exs.status === "fulfilled" ? exs.value : []
|
||||
learnStep.value[type.value] = step.value
|
||||
loadProgress()
|
||||
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 })
|
||||
if (!isDesktop.value) window.scrollTo({ top: 0 })
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.params.type, route.params.step],
|
||||
async () => {
|
||||
() => {
|
||||
if (route.name === "learn") init()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 在教程页上登录/退出时把目录的勾重新拉一遍。学生多半是先点开教程、
|
||||
// 被弹窗拦下才登录的,不盯着这个的话勾要等他刷新页面才出现
|
||||
watch(traced, loadProgress)
|
||||
// 在教程页上登录/退出时把勾重新拉一遍(学生多半是点「运行」被拦下才登录的)
|
||||
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 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">
|
||||
<article v-if="tutorial" 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>
|
||||
</div>
|
||||
<h1 class="h1">{{ parts.kp }}</h1>
|
||||
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
|
||||
<LessonBody
|
||||
:segments="parsed.segments"
|
||||
:lang="type"
|
||||
:tutorial-id="tutorial.id"
|
||||
:exercise-count="parsed.exerciseCount"
|
||||
:solved-ids="solvedIds"
|
||||
@solved="onSolved"
|
||||
/>
|
||||
<LessonEnd
|
||||
: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">
|
||||
<LessonOutline
|
||||
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 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" 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>
|
||||
<LessonBody
|
||||
:segments="parsed.segments"
|
||||
:lang="type"
|
||||
:tutorial-id="tutorial.id"
|
||||
:exercise-count="parsed.exerciseCount"
|
||||
:solved-ids="solvedIds"
|
||||
@solved="onSolved"
|
||||
/>
|
||||
<LessonEnd
|
||||
: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"
|
||||
/>
|
||||
</article>
|
||||
<n-spin v-else-if="loading" class="spin" />
|
||||
<nav class="mpager">
|
||||
<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">
|
||||
<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>
|
||||
/* 桌面端固定高度,目录/正文/代码各自内部滚动;移动端交给页面整体滚动 */
|
||||
@media (min-width: 769px) {
|
||||
.learn-container {
|
||||
height: calc(100vh - 134px);
|
||||
.muted {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
.spin {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
/* 桌面端固定高度,三栏各自滚动 */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr) 220px;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
height: calc(100vh - 134px);
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.layout {
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
.side {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
.rail {
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid var(--learn-line);
|
||||
padding: 0 12px 16px 0;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.reader {
|
||||
.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-body {
|
||||
.reader {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 820px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 40px 0;
|
||||
}
|
||||
.article {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.reader :deep(.pager) {
|
||||
max-width: 820px;
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
.crumb {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.lesson-head h1,
|
||||
.mobile-title {
|
||||
margin: 4px 0 12px;
|
||||
font-size: 26px;
|
||||
.h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.mobile-title {
|
||||
font-size: 20px;
|
||||
.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;
|
||||
}
|
||||
|
||||
.code-panel {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
/* 手机 */
|
||||
/* 外层 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");
|
||||
}
|
||||
.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>
|
||||
Reference in new issue
Block a user