feat(自学): 自学重做——首页课程地图、读一课按项目分目录、代码块就地运行、学完给题做
Deploy / deploy (push) Canceled after 0s

- 顶栏「自学」进新的自学首页 /learn:接着学(按服务端记录,不再串到上一个学生)+ 按项目分组的课程地图
- 读一课:左边目录按项目折叠,正文加宽,右边本课大纲带练一练标记;去掉了不能运行的示例代码栏
- 正文里的代码块能改能运行,报错用题目页那套中文解释;读输入的代码出输入框
- 课末:练一练做到几道 → 学完了 → 老师挑的几道题(必会标出来)→ 下一课
- 练一练统一成绿色提交、结果一行字;手机版目录从底部弹出
- 后台:教程列表按项目分组拖动排序,编辑页左写右看、练一练插到光标处、挑相关题;
  自学情况改成方块格 + 还没打开过的名单

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-12 09:40:34 +08:00
1 parent 11194543a3
commit 845e41c060
43 files changed
+4675 -1294

No files matched your search

+5 -25
View File
@@ -4,7 +4,6 @@ import { NSwitch, useThemeVars } from "naive-ui"
import { RouterLink } from "vue-router"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useDarkTransition } from "shared/composables/darkTransition"
import { useLearnProgress } from "shared/composables/learnProgress"
import { useAuthModalStore } from "shared/store/authModal"
import { useCollabStore } from "shared/store/collab"
import { useConfigStore } from "../store/config"
@@ -20,7 +19,6 @@ const route = useRoute()
const router = useRouter()
const { isMobile, isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const { isDark, toggleDark } = useDarkTransition()
const theme = useThemeVars()
@@ -77,16 +75,12 @@ function renderIcon(icon: string) {
return () => h(Icon, { icon, width: 18 })
}
function learnLink(type: "python" | "c") {
return `/learn/${type}/${learnStep.value[type].toString().padStart(2, "0")}`
}
/**
* 顶栏导航的唯一一张表:桌面端渲染成一排纯文字,窄屏收进「菜单」下拉。
* `key` 就是一级路径,用来判定高亮(见上面的 active)。
*
* 顺序是 自学 / 题目 / 提交 / 题单 / 比赛 / 排名。「自学」是下拉,单独渲染在最前面,
* 不在这张表里。提交留在一级:学生天天回来看自己交的结果,老师课上翻全班代码也走它。
* 顺序是 自学 / 题目 / 提交 / 题单 / 比赛 / 排名。「自学」进自学首页(/learn),
* 两门课、接着学哪一课都在那儿挑,不再是下拉。提交留在一级:学生天天回来看自己交的结果,老师课上翻全班代码也走它。
* 公告不上顶栏 —— 一年五六条、基本是版本更新,首页有公告卡片和「全部」入口;
* 只有手机菜单里留一项(见 mobileMenus)。
* 不用 n-menu:它横排每项固定占 100px,1280 宽的机房屏上会把顶栏挤成两行。
@@ -100,6 +94,7 @@ interface NavLink {
const navLinks = computed<NavLink[]>(() =>
[
{ key: "learn", label: "自学", to: "/learn" },
{ key: "problem", label: "题目", to: "/problem" },
{ key: "submission", label: "提交", to: "/submission", show: userStore.showSubmissions },
{ key: "problemset", label: "题单", to: "/problemset" },
@@ -110,24 +105,14 @@ const navLinks = computed<NavLink[]>(() =>
const announcementLink: NavLink = { key: "announcement", label: "公告", to: "/announcement" }
const learnOptions: DropdownOption[] = [
{ label: "Python", key: "learn-python", icon: renderIcon("ph:book-open") },
{ label: "C 语言", key: "learn-c", icon: renderIcon("ph:book-open") },
]
const mobileMenus = computed<DropdownOption[]>(() => [
{ label: "自学", key: "learn", children: learnOptions },
...navLinks.value.map((link) => ({ label: link.label, key: link.key })),
{ label: announcementLink.label, key: announcementLink.key },
])
function handleNavSelect(key: string) {
if (key === "learn-python") router.push(learnLink("python"))
else if (key === "learn-c") router.push(learnLink("c"))
else {
const link = [...navLinks.value, announcementLink].find((item) => item.key === key)
if (link) router.push(link.to)
}
const link = [...navLinks.value, announcementLink].find((item) => item.key === key)
if (link) router.push(link.to)
}
const adminPath = computed(() => (userStore.isSuperAdmin ? "/admin" : "/admin/problem/list"))
@@ -236,11 +221,6 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => {
</n-tag>
</RouterLink>
<nav v-if="isDesktop" class="nav">
<n-dropdown trigger="hover" :options="learnOptions" @select="handleNavSelect">
<button type="button" class="nav-link" :class="{ active: active === 'learn' }">
自学<Icon icon="ph:caret-down-bold" :width="11" />
</button>
</n-dropdown>
<RouterLink
v-for="link in navLinks"
:key="link.key"
@@ -1,17 +1,44 @@
<template>
<MdEditor :theme="isDark ? 'dark' : 'light'" v-model="modelValue" @onUploadImg="onUploadImg" />
<MdEditor
ref="editor"
:theme="isDark ? 'dark' : 'light'"
v-model="modelValue"
:preview="preview"
:style="height ? { height } : undefined"
@onUploadImg="onUploadImg"
/>
</template>
<script setup lang="ts">
import { MdEditor } from "md-editor-v3"
import { MdEditor, type ExposeParam } from "md-editor-v3"
import "md-editor-v3/lib/style.css"
import { uploadImage } from "../../admin/api"
/**
* `preview` 关掉自带的预览:教程编辑页右边要用学生端同一套组件预览(练一练、能运行的
* 代码块),md-editor 自己那份预览认不出 `[[exercise:N]]`。
*/
const { preview = true, height } = defineProps<{ preview?: boolean; height?: string }>()
const isDark = useDark()
const editor = useTemplateRef<ExposeParam>("editor")
const modelValue = defineModel<string>("value")
const message = useMessage()
/** 在光标处插一段文字(教程编辑页插练一练占位符用),自成一行 */
function insert(text: string) {
editor.value?.insert(() => ({
targetValue: `\n${text}\n`,
select: false,
deviationStart: 0,
deviationEnd: 0,
}))
}
defineExpose({ insert })
const onUploadImg = async (files: File[], callback: (urls: string[]) => void) => {
try {
const res = await Promise.all(
@@ -1,15 +0,0 @@
import { useStorage } from "@vueuse/core"
import { STORAGE_KEY } from "utils/constants"
export type TutorialType = "python" | "c"
// 模块级单例:学习页写入、导航栏读取,必须共用同一个响应式引用,
// 否则导航栏的链接不会随学习进度更新
const learnStep = useStorage<Record<TutorialType, number>>(STORAGE_KEY.LEARN_CURRENT_STEP, {
python: 1,
c: 1,
})
export function useLearnProgress() {
return { learnStep }
}