diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index c5d47f5..4dcef82 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -59,7 +59,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap Shared logic lives in `shared/`: - `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码) -- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂看板的「请假」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity` +- `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `leaveStudents` (课堂看板的「请假」), `chartTheme`, `breakpoints`, `maxkb`, `rarity` - `layout/` — `default.vue` and `admin.vue` layout wrappers - `api.ts` — shared API calls (auth, profile, tags, captcha) diff --git a/apps/web/src/admin/api.ts b/apps/web/src/admin/api.ts index 0ed99a4..db949aa 100644 --- a/apps/web/src/admin/api.ts +++ b/apps/web/src/admin/api.ts @@ -9,8 +9,7 @@ import type { AdminAiReport, AdminAiReportList, StuckProblem, - LearnStudentProgressList, - LearnTutorialProgressList, + LearnBoard, LearnExerciseProgressList, LearnExerciseAttempt, AdminContestList, @@ -41,9 +40,11 @@ import type { AdminProblemSetProblem, AddProblemsToSetResult, TutorialListItem, + TutorialEdit, TagCategory, } from "utils/types" import type { + TutorialProblemCandidate, AstCheckRequest, AstCheckResponse, GenerateTestInputsRequest, @@ -416,25 +417,44 @@ export function getTutorial(id: number) { return api.get(`admin/tutorials/${id}`) } -function toTutorialBody(data: Partial) { +/** 「示例代码」界面上去掉了,`code` 不带:后端照原样留着发布脚本写进去的那份 */ +function toTutorialBody(data: TutorialEdit) { return { title: data.title, content: data.content, - code: data.code ?? null, - isPublic: data.isPublic ?? false, - order: data.order ?? 0, + project: data.project, + isPublic: data.isPublic, + order: data.order, type: data.type, } } -export function createTutorial(data: Partial) { +export function createTutorial(data: TutorialEdit) { return api.post("admin/tutorials", toTutorialBody(data)) } -export function updateTutorial(data: Partial) { +export function updateTutorial(data: TutorialEdit) { return api.put(`admin/tutorials/${data.id}`, toTutorialBody(data)) } +export function setTutorialProblems(id: number, problemIds: number[]) { + return api.put(`admin/tutorials/${id}/problems`, { problemIds }) +} + +export function getTutorialProblemCandidates(keyword: string) { + return api.get("admin/tutorial-problem-candidates", { + params: keyword ? { keyword } : {}, + }) +} + +export function reorderTutorials(ids: number[]) { + return api.put("admin/tutorial-order", { ids }) +} + +export function renameTutorialProject(type: "python" | "c", from: string, to: string) { + return api.put("admin/tutorial-projects", { type, from, to }) +} + export function deleteTutorial(id: number) { return api.delete(`admin/tutorials/${id}`) } @@ -583,16 +603,8 @@ export function getStuckProblems() { return api.get("admin/problem-analytics/stuck") } -export function getLearnStudents(params: { type: "python" | "c"; className?: string }) { - return api.get("admin/learn-analytics/students", { - params, - }) -} - -export function getLearnTutorials(params: { type: "python" | "c"; className?: string }) { - return api.get("admin/learn-analytics/tutorials", { - params, - }) +export function getLearnBoard(params: { type: "python" | "c"; className?: string }) { + return api.get("admin/learn-analytics/board", { params }) } export function getLearnExercises(params: { type: "python" | "c"; className?: string }) { diff --git a/apps/web/src/admin/learn/index.vue b/apps/web/src/admin/learn/index.vue index 2049f6e..7a43c7a 100644 --- a/apps/web/src/admin/learn/index.vue +++ b/apps/web/src/admin/learn/index.vue @@ -1,17 +1,25 @@ + + diff --git a/apps/web/src/admin/tutorial/components/Actions.vue b/apps/web/src/admin/tutorial/components/Actions.vue deleted file mode 100644 index 3f1054b..0000000 --- a/apps/web/src/admin/tutorial/components/Actions.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/apps/web/src/admin/tutorial/components/ExerciseManager.vue b/apps/web/src/admin/tutorial/components/ExerciseManager.vue index 7f1cfdc..3974803 100644 --- a/apps/web/src/admin/tutorial/components/ExerciseManager.vue +++ b/apps/web/src/admin/tutorial/components/ExerciseManager.vue @@ -1,4 +1,5 @@ + + diff --git a/apps/web/src/admin/tutorial/components/RelatedProblems.vue b/apps/web/src/admin/tutorial/components/RelatedProblems.vue new file mode 100644 index 0000000..4786fb2 --- /dev/null +++ b/apps/web/src/admin/tutorial/components/RelatedProblems.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/apps/web/src/admin/tutorial/detail.vue b/apps/web/src/admin/tutorial/detail.vue index ea3b788..c78dc1a 100644 --- a/apps/web/src/admin/tutorial/detail.vue +++ b/apps/web/src/admin/tutorial/detail.vue @@ -1,123 +1,362 @@ + - diff --git a/apps/web/src/admin/tutorial/list.vue b/apps/web/src/admin/tutorial/list.vue index 119aa06..c16c357 100644 --- a/apps/web/src/admin/tutorial/list.vue +++ b/apps/web/src/admin/tutorial/list.vue @@ -1,96 +1,429 @@ + + diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 41bdba2..d81200a 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -9,6 +9,7 @@ import { type FormatCodeResponse, type Metrics, type TutorialSummary, + type Tutorial, type ClassActivity, type LastVisit, type ClassBoard, @@ -67,7 +68,6 @@ import type { Submission, SubmissionListPayload, SubmitCodePayload, - Tutorial, TutorialProgress, } from "utils/types" diff --git a/apps/web/src/oj/home/dashboard.vue b/apps/web/src/oj/home/dashboard.vue index 87464a1..d37bec6 100644 --- a/apps/web/src/oj/home/dashboard.vue +++ b/apps/web/src/oj/home/dashboard.vue @@ -1,4 +1,5 @@ + + + + diff --git a/apps/web/src/oj/learn/components/ExerciseDebug.vue b/apps/web/src/oj/learn/components/ExerciseDebug.vue index 0f7e479..765a7f3 100644 --- a/apps/web/src/oj/learn/components/ExerciseDebug.vue +++ b/apps/web/src/oj/learn/components/ExerciseDebug.vue @@ -1,4 +1,5 @@ diff --git a/apps/web/src/oj/learn/components/ExerciseFill.vue b/apps/web/src/oj/learn/components/ExerciseFill.vue index 26909f7..b1f2bc6 100644 --- a/apps/web/src/oj/learn/components/ExerciseFill.vue +++ b/apps/web/src/oj/learn/components/ExerciseFill.vue @@ -1,4 +1,5 @@ - - 提交 + 提交 重置 - + diff --git a/apps/web/src/oj/learn/components/ExerciseGroup.vue b/apps/web/src/oj/learn/components/ExerciseGroup.vue index 2fa3c10..169d460 100644 --- a/apps/web/src/oj/learn/components/ExerciseGroup.vue +++ b/apps/web/src/oj/learn/components/ExerciseGroup.vue @@ -1,4 +1,5 @@ diff --git a/apps/web/src/oj/learn/components/ExerciseMatch.vue b/apps/web/src/oj/learn/components/ExerciseMatch.vue index abf8c89..2c83557 100644 --- a/apps/web/src/oj/learn/components/ExerciseMatch.vue +++ b/apps/web/src/oj/learn/components/ExerciseMatch.vue @@ -1,4 +1,5 @@ diff --git a/apps/web/src/oj/learn/components/ExerciseMcq.vue b/apps/web/src/oj/learn/components/ExerciseMcq.vue index 041fdb0..77b498c 100644 --- a/apps/web/src/oj/learn/components/ExerciseMcq.vue +++ b/apps/web/src/oj/learn/components/ExerciseMcq.vue @@ -1,4 +1,5 @@ + + diff --git a/apps/web/src/oj/learn/components/ExercisePredict.vue b/apps/web/src/oj/learn/components/ExercisePredict.vue index af260a9..e9b5e84 100644 --- a/apps/web/src/oj/learn/components/ExercisePredict.vue +++ b/apps/web/src/oj/learn/components/ExercisePredict.vue @@ -1,4 +1,5 @@ diff --git a/apps/web/src/oj/learn/components/ExerciseResult.vue b/apps/web/src/oj/learn/components/ExerciseResult.vue new file mode 100644 index 0000000..b6387be --- /dev/null +++ b/apps/web/src/oj/learn/components/ExerciseResult.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/apps/web/src/oj/learn/components/ExerciseSort.vue b/apps/web/src/oj/learn/components/ExerciseSort.vue index 8aafebe..9a045cb 100644 --- a/apps/web/src/oj/learn/components/ExerciseSort.vue +++ b/apps/web/src/oj/learn/components/ExerciseSort.vue @@ -1,4 +1,5 @@ diff --git a/apps/web/src/oj/learn/components/ExerciseWidget.vue b/apps/web/src/oj/learn/components/ExerciseWidget.vue index 1c65304..800baa5 100644 --- a/apps/web/src/oj/learn/components/ExerciseWidget.vue +++ b/apps/web/src/oj/learn/components/ExerciseWidget.vue @@ -1,5 +1,6 @@ + + diff --git a/apps/web/src/oj/learn/components/LearnSummary.vue b/apps/web/src/oj/learn/components/LearnSummary.vue deleted file mode 100644 index d58b4f8..0000000 --- a/apps/web/src/oj/learn/components/LearnSummary.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - - - diff --git a/apps/web/src/oj/learn/components/LessonBody.vue b/apps/web/src/oj/learn/components/LessonBody.vue index 97a7e8f..501b810 100644 --- a/apps/web/src/oj/learn/components/LessonBody.vue +++ b/apps/web/src/oj/learn/components/LessonBody.vue @@ -1,22 +1,87 @@ + + diff --git a/apps/web/src/oj/learn/components/LessonDot.vue b/apps/web/src/oj/learn/components/LessonDot.vue new file mode 100644 index 0000000..e8e6f2f --- /dev/null +++ b/apps/web/src/oj/learn/components/LessonDot.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/apps/web/src/oj/learn/components/LessonEnd.vue b/apps/web/src/oj/learn/components/LessonEnd.vue new file mode 100644 index 0000000..43b84f9 --- /dev/null +++ b/apps/web/src/oj/learn/components/LessonEnd.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/apps/web/src/oj/learn/components/LessonList.vue b/apps/web/src/oj/learn/components/LessonList.vue deleted file mode 100644 index 8c0a298..0000000 --- a/apps/web/src/oj/learn/components/LessonList.vue +++ /dev/null @@ -1,124 +0,0 @@ - - - - - diff --git a/apps/web/src/oj/learn/components/LessonOutline.vue b/apps/web/src/oj/learn/components/LessonOutline.vue new file mode 100644 index 0000000..59f806c --- /dev/null +++ b/apps/web/src/oj/learn/components/LessonOutline.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/apps/web/src/oj/learn/components/LessonRow.vue b/apps/web/src/oj/learn/components/LessonRow.vue new file mode 100644 index 0000000..28bea57 --- /dev/null +++ b/apps/web/src/oj/learn/components/LessonRow.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/apps/web/src/oj/learn/components/PagerBar.vue b/apps/web/src/oj/learn/components/PagerBar.vue deleted file mode 100644 index b0c0425..0000000 --- a/apps/web/src/oj/learn/components/PagerBar.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - - - diff --git a/apps/web/src/oj/learn/components/ProjectTile.vue b/apps/web/src/oj/learn/components/ProjectTile.vue new file mode 100644 index 0000000..b21ac2e --- /dev/null +++ b/apps/web/src/oj/learn/components/ProjectTile.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/apps/web/src/oj/learn/components/ProjectTree.vue b/apps/web/src/oj/learn/components/ProjectTree.vue new file mode 100644 index 0000000..98c6fde --- /dev/null +++ b/apps/web/src/oj/learn/components/ProjectTree.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/apps/web/src/oj/learn/components/SegBar.vue b/apps/web/src/oj/learn/components/SegBar.vue new file mode 100644 index 0000000..c9d022a --- /dev/null +++ b/apps/web/src/oj/learn/components/SegBar.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/apps/web/src/oj/learn/composables/useExerciseParse.ts b/apps/web/src/oj/learn/composables/useExerciseParse.ts deleted file mode 100644 index 635f3da..0000000 --- a/apps/web/src/oj/learn/composables/useExerciseParse.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Exercise } from "utils/types" - -export type Segment = { type: "md"; content: string } | { type: "exercise"; exercise: Exercise } - -export function parseExercises(content: string, exercises: Exercise[]): Segment[] { - const exerciseMap = new Map(exercises.map((e) => [e.id, e])) - const segments: Segment[] = [] - const regex = /\[\[exercise:(\d+)\]\]/g - let lastIndex = 0 - let match: RegExpExecArray | null - - while ((match = regex.exec(content)) !== null) { - if (match.index > lastIndex) { - segments.push({ - type: "md", - content: content.slice(lastIndex, match.index), - }) - } - const id = parseInt(match[1]) - const exercise = exerciseMap.get(id) - if (exercise) { - segments.push({ type: "exercise", exercise }) - } - lastIndex = regex.lastIndex - } - - if (lastIndex < content.length) { - segments.push({ type: "md", content: content.slice(lastIndex) }) - } - - return segments -} diff --git a/apps/web/src/oj/learn/composables/useLessonParse.ts b/apps/web/src/oj/learn/composables/useLessonParse.ts new file mode 100644 index 0000000..346a93b --- /dev/null +++ b/apps/web/src/oj/learn/composables/useLessonParse.ts @@ -0,0 +1,158 @@ +import type { Exercise } from "utils/types" + +/** + * 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染: + * + * - `heading`:二级标题单独拿出来,自己出 id —— 右边「这一课」的大纲靠它跳转、高亮; + * - `goals`:「## 学习目标」那一节画成「学完这课你能」的方框; + * - `code`:带语言的代码块(```python / ```c)换成能改、能运行的代码块; + * 不带语言的(「输出结果」那种)留在 Markdown 里照常显示; + * - `exercise`:`[[exercise:36]]` 占位符换成练一练; + * - `md`:其余的交给 MdPreview。 + * + * 正文开头的「# 标题」「**所属项目:…**」和紧跟着的项目配图不渲染:标题和项目页面上方 + * 已经有了,每课都重复一张同样的项目图只是把正文往下推。 + */ +export type LessonSegment = + | { type: "md"; content: string } + | { type: "heading"; id: string; text: string } + | { type: "goals"; id: string; items: string[] } + | { type: "code"; lang: "python" | "c"; code: string; index: number } + | { type: "exercise"; exercise: Exercise; index: number } + +/** 大纲里的一项:一个二级标题,挂着它这一节里的练一练 */ +export interface OutlineItem { + id: string + text: string + exerciseIds: number[] +} + +const CODE_LANGS: Record = { + python: "python", + py: "python", + python3: "python", + c: "c", + cpp: "c", + "c++": "c", +} + +const EXERCISE_RE = /\[\[exercise:(\d+)\]\]/g +const GOALS_TITLE = "学习目标" + +/** 去掉开头的标题、所属项目、项目配图(中间的空行一起) */ +function stripPreamble(lines: string[]) { + let i = 0 + const skipBlank = () => { + while (i < lines.length && !lines[i]!.trim()) i++ + } + skipBlank() + if (lines[i]?.startsWith("# ")) i++ + skipBlank() + if (/^\*\*所属项目[::]/.test(lines[i]?.trim() ?? "")) { + i++ + skipBlank() + if (/^!\[[^\]]*\]\([^)]*\)\s*$/.test(lines[i]?.trim() ?? "")) i++ + } + return lines.slice(i) +} + +export function parseLesson(content: string, exercises: Exercise[]) { + const byId = new Map(exercises.map((item) => [item.id, item])) + const segments: LessonSegment[] = [] + const outline: OutlineItem[] = [] + let md: string[] = [] + let codeIndex = 0 + let exerciseIndex = 0 + let headingIndex = 0 + + const flush = () => { + const text = md.join("\n") + if (text.trim()) segments.push({ type: "md", content: text }) + md = [] + } + + const lines = stripPreamble(content.replace(/\r\n/g, "\n").split("\n")) + let i = 0 + while (i < lines.length) { + const line = lines[i]! + const fence = line.match(/^(\s*)(```+|~~~+)\s*([\w+#-]*)\s*$/) + if (fence) { + const marker = fence[2]! + const lang = CODE_LANGS[fence[3]!.toLowerCase()] + const body: string[] = [] + let j = i + 1 + while (j < lines.length && !lines[j]!.trim().startsWith(marker)) body.push(lines[j++]!) + if (lang) { + flush() + segments.push({ type: "code", lang, code: body.join("\n"), index: codeIndex++ }) + } else { + md.push(line, ...body, lines[j] ?? marker) + } + i = j + 1 + continue + } + + const heading = line.match(/^##\s+(.+?)\s*#*\s*$/) + if (heading) { + flush() + const text = heading[1]!.trim() + const id = `section-${headingIndex++}` + if (text === GOALS_TITLE) { + // 学习目标:收到下一个分隔线或标题为止,只要列表项 + const items: string[] = [] + let j = i + 1 + while (j < lines.length && !/^(---+|##\s)/.test(lines[j]!.trim())) { + const item = lines[j]!.match(/^\s*[-*+]\s+(.+)$/) + if (item) items.push(item[1]!.trim()) + j++ + } + segments.push({ type: "goals", id, items }) + outline.push({ id, text: "学完这课你能", exerciseIds: [] }) + i = j + continue + } + segments.push({ type: "heading", id, text }) + outline.push({ id, text, exerciseIds: [] }) + i++ + continue + } + + // 节与节之间的分隔线:二级标题本身就隔开了,再画一道线是重复 + if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) { + i++ + continue + } + + if (EXERCISE_RE.test(line)) { + EXERCISE_RE.lastIndex = 0 + let last = 0 + for (const match of line.matchAll(EXERCISE_RE)) { + md.push(line.slice(last, match.index)) + last = match.index! + match[0].length + const exercise = byId.get(Number(match[1])) + if (!exercise) continue + flush() + segments.push({ type: "exercise", exercise, index: exerciseIndex++ }) + outline[outline.length - 1]?.exerciseIds.push(exercise.id) + } + md.push(line.slice(last)) + i++ + continue + } + + md.push(line) + i++ + } + flush() + + // 正文里没放占位符的练一练接在最后:学完一课要全做对,看不见的题会让这课永远学不完 + const placed = new Set( + segments.flatMap((item) => (item.type === "exercise" ? [item.exercise.id] : [])), + ) + for (const exercise of exercises) { + if (placed.has(exercise.id)) continue + segments.push({ type: "exercise", exercise, index: exerciseIndex++ }) + outline[outline.length - 1]?.exerciseIds.push(exercise.id) + } + return { segments, outline, exerciseCount: exerciseIndex } +} diff --git a/apps/web/src/oj/learn/home.vue b/apps/web/src/oj/learn/home.vue new file mode 100644 index 0000000..cac9104 --- /dev/null +++ b/apps/web/src/oj/learn/home.vue @@ -0,0 +1,575 @@ + + + + + diff --git a/apps/web/src/oj/learn/index.vue b/apps/web/src/oj/learn/index.vue index d412dce..72df816 100644 --- a/apps/web/src/oj/learn/index.vue +++ b/apps/web/src/oj/learn/index.vue @@ -1,129 +1,97 @@ - - + + diff --git a/apps/web/src/oj/learn/learn.css b/apps/web/src/oj/learn/learn.css new file mode 100644 index 0000000..8025afa --- /dev/null +++ b/apps/web/src/oj/learn/learn.css @@ -0,0 +1,24 @@ +/* 自学模块的几个颜色。深色模式下深绿、深橙的字在暗底上读不出来,换成亮一档的。 + 设计稿在「自学重设计」画布页(Learn*.dc.html)。 */ +:root { + --learn-green: #18a058; + --learn-green-text: #0c7a43; + --learn-green-bg: rgba(24, 160, 88, 0.1); + --learn-green-line: rgba(24, 160, 88, 0.35); + --learn-orange: #f0a020; + --learn-orange-text: #8a5200; + --learn-orange-bg: rgba(240, 160, 32, 0.14); + --learn-red-text: #b0263c; + --learn-line: rgba(128, 128, 128, 0.22); + --learn-soft: rgba(128, 128, 128, 0.07); + --learn-pane: #ffffff; + --learn-todo: rgba(128, 128, 128, 0.22); + --learn-code-font: Consolas, Monaco, "Microsoft YaHei", monospace; +} + +html.dark { + --learn-green-text: #63e2b7; + --learn-orange-text: #f2c97d; + --learn-red-text: #f08a9b; + --learn-pane: rgba(255, 255, 255, 0.03); +} diff --git a/apps/web/src/oj/learn/lesson.ts b/apps/web/src/oj/learn/lesson.ts new file mode 100644 index 0000000..23f72b1 --- /dev/null +++ b/apps/web/src/oj/learn/lesson.ts @@ -0,0 +1,117 @@ +import { isLessonDone, type TutorialProgress, type TutorialSummary } from "@oj2/contract" + +export type TutorialType = "python" | "c" + +export const COURSES: { type: TutorialType; label: string }[] = [ + { type: "python", label: "Python" }, + { type: "c", label: "C 语言" }, +] + +/** + * 标题是「情境——知识点」(「蜜橘园环境预警——条件语句基础」)。学生找的是知识点, + * 所以显示时知识点在前、情境在后。没有「——」的老教程整个当知识点。 + */ +export function splitTitle(title: string) { + const at = title.indexOf("——") + if (at < 0) return { kp: title.trim(), scene: "" } + return { kp: title.slice(at + 2).trim(), scene: title.slice(0, at).trim() } +} + +/** 编辑时拆开的两半拼回去 */ +export function joinTitle(kp: string, scene: string) { + return scene.trim() ? `${scene.trim()}——${kp.trim()}` : kp.trim() +} + +export interface ProjectGroup { + /** 原样的项目名,「项目三 · 智慧农业·蜜橘园」;没填是空串 */ + project: string + /** 「项目三」,没有这一截时是空串 */ + no: string + /** 「智慧农业·蜜橘园」 */ + name: string + icon: string + tone: "blue" | "purple" | "green" | "orange" | "gray" + /** 这个项目里的课,`step` 是在整门课里的序号(1 起) */ + lessons: { id: number; title: string; step: number }[] +} + +/** 「项目三 · 智慧农业·蜜橘园」拆成「项目三」和「智慧农业·蜜橘园」 */ +export function splitProject(project: string) { + const match = project.match(/^(项目[一二三四五六七八九十\d]+)\s*[·・.]\s*(.+)$/) + if (!match) return { no: "", name: project || "其他" } + return { no: match[1]!, name: match[2]!.trim() } +} + +/** 项目图标按情境挑,认不出的给一本书 */ +function projectLook(name: string): Pick { + if (name.includes("家居")) return { icon: "ph:house", tone: "blue" } + if (name.includes("工业") || name.includes("设备")) return { icon: "ph:factory", tone: "purple" } + if (name.includes("农业")) return { icon: "ph:plant", tone: "green" } + if (name.includes("交通")) return { icon: "ph:car", tone: "orange" } + return { icon: "ph:book-open", tone: "gray" } +} + +/** 按项目分组,保持课程原来的顺序;同一个项目被别的项目隔开时各算一组 */ +export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] { + const groups: ProjectGroup[] = [] + titles.forEach((item, index) => { + const last = groups[groups.length - 1] + if (last && last.project === item.project) { + last.lessons.push({ id: item.id, title: item.title, step: index + 1 }) + return + } + const { no, name } = splitProject(item.project) + groups.push({ + project: item.project, + no, + name, + ...projectLook(name), + lessons: [{ id: item.id, title: item.title, step: index + 1 }], + }) + }) + return groups +} + +export type LessonState = "done" | "started" | "todo" + +/** + * 目录上的三态。学完 = 练一练全对(契约的 `isLessonDone`);打开过或者做过练一练但没 + * 全对是「在学」;别的是没开始。 + */ +export function lessonState(progress: TutorialProgress | undefined): LessonState { + if (!progress) return "todo" + if (isLessonDone(progress)) return "done" + if (progress.viewCount > 0 || progress.exerciseSolved > 0) return "started" + return "todo" +} + +/** + * 「接着学」哪一课:最近打开的那课;它已经学完了,就往后找第一课没学完的。 + * 一课没打开过返回 null(首页换成「开始学」)。 + */ +export function continueStep( + titles: TutorialSummary[], + progress: Record, +) { + let latest: TutorialProgress | null = null + for (const item of titles) { + const row = progress[item.id] + if (row?.lastViewedAt && (!latest || row.lastViewedAt > latest.lastViewedAt!)) latest = row + } + if (!latest) return null + const index = titles.findIndex((item) => item.id === latest!.tutorialId) + if (index < 0) return null + if (lessonState(latest) !== "done") return index + 1 + for (let i = index + 1; i < titles.length; i++) { + if (lessonState(progress[titles[i]!.id]) !== "done") return i + 1 + } + return index + 1 +} + +export function doneCount(titles: TutorialSummary[], progress: Record) { + return titles.filter((item) => lessonState(progress[item.id]) === "done").length +} + +export function lessonPath(type: TutorialType, step: number) { + return `/learn/${type}/${step.toString().padStart(2, "0")}` +} diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index 2309735..c7d4911 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -121,6 +121,11 @@ export const ojs: RouteRecordRaw = { component: () => import("oj/user/message.vue"), meta: { requiresAuth: true }, }, + { + path: "learn", + name: "learn home", + component: () => import("oj/learn/home.vue"), + }, { path: "learn/:type/:step", name: "learn", diff --git a/apps/web/src/shared/components/Header.vue b/apps/web/src/shared/components/Header.vue index c019ab5..9db328f 100644 --- a/apps/web/src/shared/components/Header.vue +++ b/apps/web/src/shared/components/Header.vue @@ -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(() => [ + { 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(() => 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(() => [ - { 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>(() => {