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

+1 -1
View File
@@ -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"
+16 -18
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { continueStep, doneCount, lessonPath, splitTitle, type TutorialType } from "oj/learn/lesson"
import { Icon } from "@iconify/vue"
import {
getAnnouncement,
@@ -29,8 +30,6 @@ import type {
WeeklyRank,
} from "utils/types"
type TutorialType = "python" | "c"
interface Track {
type: TutorialType
label: string
@@ -38,8 +37,8 @@ interface Track {
step: number
title: string
total: number
/** 打开过的课数 */
read: number
/** 学完的课数(练一练全对) */
done: number
lastViewedAt: string | null
}
@@ -107,27 +106,26 @@ const weeklyText = computed(() => {
})
/**
* 接着学哪一课看的是**服务端留痕**里最近打开的那一课,不是导航栏用的
* localStorage:机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
* 接着学哪一课看的是**服务端留痕**(和自学首页同一套,oj/learn/lesson.ts),不是本机记的:
* 机房一台电脑轮着好几个班用,本地记的是上一个人学到哪。
*/
async function loadTrack(type: TutorialType, label: string): Promise<Track | null> {
const [titles, progress] = await Promise.all([getTutorials(type), getLearnProgress(type)])
const [titles, rows] = await Promise.all([getTutorials(type), getLearnProgress(type)])
if (!titles.length) return null
const seen = progress.filter((p) => p.lastViewedAt)
const latest = seen.reduce<(typeof seen)[number] | null>(
(acc, p) => (!acc || p.lastViewedAt! > acc.lastViewedAt! ? p : acc),
const progress = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
const step = continueStep(titles, progress) ?? 1
const lastViewedAt = rows.reduce<string | null>(
(acc, row) => (row.lastViewedAt && (!acc || row.lastViewedAt > acc) ? row.lastViewedAt : acc),
null,
)
const index = latest ? titles.findIndex((t) => t.id === latest.tutorialId) : -1
const step = index >= 0 ? index + 1 : 1
return {
type,
label,
step,
title: titles[step - 1]!.title,
title: splitTitle(titles[step - 1]!.title).kp,
total: titles.length,
read: seen.length,
lastViewedAt: latest?.lastViewedAt ?? null,
done: doneCount(titles, progress),
lastViewedAt,
}
}
@@ -271,7 +269,7 @@ watch(
)
function learnLink(track: Track) {
return `/learn/${track.type}/${track.step.toString().padStart(2, "0")}`
return lessonPath(track.type, track.step)
}
const { jump, jumping } = useProblemJump()
@@ -441,13 +439,13 @@ async function openAnnouncement(item: AnnouncementListItem) {
<div class="track-title">{{ track.title }}</div>
<n-progress
type="line"
:percentage="Math.round((track.read / track.total) * 100)"
:percentage="Math.round((track.done / track.total) * 100)"
status="success"
:show-indicator="false"
:height="6"
/>
<n-text depth="3" class="track-meta">
已打开 {{ track.read }} 课
学完 {{ track.done }} 课
<template v-if="track.lastViewedAt">
· 上次 {{ relativeTime(track.lastViewedAt) }}
</template>
@@ -0,0 +1,343 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { StatisticInfo, TrialRunResponse } from "@oj2/contract"
import { errorMessage } from "utils/api"
import { SubmissionStatus } from "utils/constants"
import { trialRun } from "utils/judge"
import { explainCCompileError } from "oj/problem/utils/cError"
import { explainPythonCompileError } from "oj/problem/utils/pythonError"
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
/**
* 正文里的一段代码:就地改、就地运行(设计稿「代码块就地运行」那块画板)。
*
* 运行走题目页同一台判题机(POST /trial-runs,带 tutorialId),不算提交。
* 报错用题目页那套中文解释(pythonError / cError / runtimeError),原文收在下面。
* 代码里读输入(input() / scanf)才出输入框。
*/
const props = defineProps<{
code: string
lang: "python" | "c"
tutorialId: number
}>()
const userStore = useUserStore()
const authModal = useAuthModalStore()
const message = useMessage()
const { copy } = useClipboard({ legacy: true })
const value = ref(props.code)
const input = ref("")
const running = ref(false)
type Outcome =
| { kind: "output"; text: string }
| { kind: "error"; title: string; message: string; raw: string }
| { kind: "busy"; message: string }
const outcome = ref<Outcome | null>(null)
// 换课时组件实例可能被复用(v-for 的 key 是段落序号),跟着原文重置
watch(
() => props.code,
(code) => {
value.value = code
input.value = ""
outcome.value = null
},
)
const edited = computed(() => value.value !== props.code)
const needsInput = computed(() =>
props.lang === "python"
? /\binput\s*\(/.test(value.value)
: /\b(scanf|getchar|gets|fgets|cin)\b/.test(value.value),
)
const language = computed(() => (props.lang === "c" ? "C" : "Python"))
const lineCount = computed(() => value.value.split("\n").length)
const TOO_MUCH_OUTPUT =
"程序输出太多了,一直停不下来:多半是循环没有结束的条件(条件一直成立、循环变量忘了改),在不停地打印。"
/** 判题机的临时目录名对学生没有意义,只留文件名 */
function cleanRaw(text: string) {
return text.replace(/\/judger\/run\/[^/"]+\//g, "").trim()
}
function compileOutcome(raw: string): Outcome {
const ex =
props.lang === "python"
? explainPythonCompileError(raw, value.value)
: explainCCompileError(raw, value.value, "C")
return {
kind: "error",
title: ex?.line ? `第 ${ex.line} 行出错` : "代码有错,没能运行",
message: ex?.message ?? "",
raw: cleanRaw(raw),
}
}
function runtimeOutcome(
result: SubmissionStatus,
output: string,
info: NonNullable<StatisticInfo["runtime_error"]> | undefined,
): Outcome {
if (result === SubmissionStatus.runtime_error) {
return {
kind: "error",
title: info?.line ? `运行到第 ${info.line} 行出错了` : "运行到一半出错了",
message: info ? explainRuntimeError(info, value.value) : "",
raw: cleanRaw(output),
}
}
if (
result === SubmissionStatus.cpu_time_limit_exceeded ||
result === SubmissionStatus.real_time_limit_exceeded
) {
return {
kind: "error",
title: "运行超时了",
message: needsInput.value
? "程序一直没停下来:看看是不是在等输入(上面的输入框填够了吗),或者循环停不下来。"
: "程序一直没停下来:多半是循环没有结束的条件。",
raw: cleanRaw(output),
}
}
if (result === SubmissionStatus.memory_limit_exceeded) {
return { kind: "error", title: "内存用太多了", message: "", raw: cleanRaw(output) }
}
return { kind: "busy", message: "运行没成功,过一会儿再点一次。" }
}
function toOutcome(response: TrialRunResponse): Outcome {
if (response.status === "compile-error") return compileOutcome(response.message)
if (response.status === "too-much-output") {
return { kind: "error", title: "输出太多了", message: TOO_MUCH_OUTPUT, raw: "" }
}
const [item] = response.cases
if (!item) return { kind: "busy", message: "运行没成功,过一会儿再点一次。" }
const result = item.result as SubmissionStatus
if (result === SubmissionStatus.accepted || result === SubmissionStatus.wrong_answer) {
return { kind: "output", text: item.output.trimEnd() }
}
return runtimeOutcome(result, item.output, item.runtimeError)
}
async function run() {
if (!userStore.isAuthed) {
authModal.openLoginModal()
return
}
if (running.value || !value.value.trim()) return
running.value = true
try {
const response = await trialRun({
tutorialId: props.tutorialId,
language: language.value,
code: value.value,
cases: [{ input: input.value }],
})
outcome.value = toOutcome(response)
} catch (error) {
// 后端的文案是给学生看的(「上一次还没跑完」「试运行的人太多了」)
outcome.value = {
kind: "busy",
message: errorMessage(error, "运行的服务暂时连不上,过一会儿再试。"),
}
} finally {
running.value = false
}
}
function restore() {
value.value = props.code
outcome.value = null
}
async function copyCode() {
await copy(value.value)
message.success("已复制")
}
</script>
<template>
<div class="block">
<div class="bar">
<span class="lang">{{ language }}</span>
<span class="grow" />
<template v-if="edited">
<span class="edited">改过</span>
<button type="button" class="tool" @click="restore">
<Icon icon="ph:arrow-counter-clockwise" :width="14" />还原
</button>
</template>
<button type="button" class="run" :class="{ busy: running }" :disabled="running" @click="run">
<template v-if="running">运行中…</template>
<template v-else><Icon icon="ph:play-fill" :width="12" />运行</template>
</button>
<button type="button" class="tool" aria-label="复制" @click="copyCode">
<Icon icon="ph:copy" :width="14" />
</button>
</div>
<div class="editor" :style="{ minHeight: `${lineCount * 22 + 16}px` }">
<CodeEditor v-model:value="value" :language="language" :font-size="14" height="auto" />
</div>
<div v-if="needsInput" class="pane input">
<div class="label">输入</div>
<n-input
v-model:value="input"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="程序要读的内容写在这里,一行一个"
class="mono"
/>
</div>
<div v-if="outcome?.kind === 'output'" class="pane">
<div class="label">输出</div>
<pre class="out" :class="{ empty: !outcome.text }">{{
outcome.text || "(什么都没有输出)"
}}</pre>
</div>
<div v-else-if="outcome?.kind === 'error'" class="pane error">
<div class="label strong">{{ outcome.title }}</div>
<div v-if="outcome.message" class="explain">{{ outcome.message }}</div>
<pre v-if="outcome.raw" class="raw">{{ outcome.raw }}</pre>
</div>
<div v-else-if="outcome?.kind === 'busy'" class="pane">
<div class="busy-text">{{ outcome.message }}</div>
</div>
</div>
</template>
<style scoped>
.block {
margin: 0 0 16px;
border: 1px solid var(--learn-line);
border-radius: 6px;
overflow: hidden;
background: var(--learn-soft);
}
.bar {
height: 36px;
box-sizing: border-box;
padding: 0 6px 0 14px;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid var(--learn-line);
font-size: 12px;
}
.lang {
opacity: 0.55;
}
.grow {
flex: 1;
}
.edited {
color: var(--learn-orange-text);
}
.tool {
height: 26px;
padding: 0 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: inherit;
opacity: 0.7;
font: inherit;
font-size: 12px;
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.tool:hover {
opacity: 1;
background: var(--learn-soft);
}
.run {
height: 26px;
box-sizing: border-box;
padding: 0 10px;
border-radius: 4px;
border: 1px solid var(--learn-green);
background: transparent;
color: var(--learn-green-text);
font: inherit;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: 5px;
cursor: pointer;
}
.run:hover {
background: var(--learn-green-bg);
}
.run.busy {
border-color: var(--learn-line);
color: inherit;
opacity: 0.6;
font-weight: 400;
cursor: default;
}
.editor :deep(.cm-editor) {
background: transparent;
}
.editor :deep(.cm-gutters) {
background: transparent;
border-right: 0;
}
.editor :deep(.cm-scroller) {
font-family: var(--learn-code-font);
line-height: 22px;
padding: 8px 0;
}
.pane {
border-top: 1px solid var(--learn-line);
padding: 10px 14px 12px;
background: var(--learn-pane);
}
.label {
font-size: 12px;
opacity: 0.6;
margin-bottom: 4px;
}
.label.strong {
opacity: 1;
font-weight: 600;
color: var(--learn-red-text);
}
.out,
.raw {
margin: 0;
font-family: var(--learn-code-font);
font-size: 14px;
line-height: 22px;
white-space: pre-wrap;
word-break: break-all;
}
.out.empty {
opacity: 0.5;
}
.explain {
font-size: 14px;
line-height: 1.7;
margin-bottom: 6px;
}
.raw {
font-size: 12px;
line-height: 18px;
opacity: 0.55;
}
.busy-text {
font-size: 14px;
color: var(--learn-orange-text);
}
.mono :deep(textarea) {
font-family: var(--learn-code-font);
}
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseDebugData } from "utils/types"
import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
@@ -83,11 +84,7 @@ function reset() {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="info" :bordered="false">练一练 · 代码找错</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
@@ -109,20 +106,19 @@ function reset() {
</div>
</n-space>
<n-alert
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '找对了!' : '还没找全,红色行是错误所在'"
style="margin-top: 12px"
>
<template v-if="submitted && data.explanation" #default>
{{ data.explanation }}
</template>
</n-alert>
</ExerciseResult>
<n-space style="margin-top: 12px" :size="8">
<n-button type="info" :disabled="locked" @click="submit">提交</n-button>
<n-button type="primary" :disabled="locked" @click="submit">提交</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseFillData } from "utils/types"
import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
@@ -72,11 +73,7 @@ function inputWidth(idx: number): string {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="warning" :bordered="false">练一练 · 代码填空</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
@@ -124,7 +121,7 @@ function inputWidth(idx: number): string {
@input="userInputs[seg.index] = ($event.target as HTMLInputElement).value"
/></template></pre>
<n-alert
<ExerciseResult
v-if="wrongBlanks.size > 0 || allCorrect"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '全部正确!' : '有填写错误,请检查红色标注的空位'"
@@ -132,8 +129,8 @@ function inputWidth(idx: number): string {
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="warning" :disabled="allCorrect" @click="submit"> 提交 </n-button>
<n-button type="primary" :disabled="allCorrect" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseGroupData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
@@ -80,11 +81,7 @@ function reset() {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="warning" :bordered="false">练一练 · 归类分组</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
@@ -156,16 +153,15 @@ function reset() {
</div>
</div>
<n-alert
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '归类全部正确!' : '有归类错误,红色项需要调整'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit"> 提交 </n-button>
<n-button type="primary" :disabled="!allPlaced || locked" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseMatchData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
@@ -129,11 +130,7 @@ function dotStyle(color: string): Record<string, string> {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="primary" :bordered="false">练一练 · 连线匹配</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
@@ -173,16 +170,15 @@ function dotStyle(color: string): Record<string, string> {
</n-space>
</div>
<n-alert
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '全部匹配正确!' : '有匹配错误,红色项需要重新连线'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="!allPaired || locked" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseMcqData } from "utils/types"
const props = defineProps<{ exercise: Exercise }>()
@@ -8,6 +9,8 @@ const emit = defineEmits<{
const data = computed(() => props.exercise.data as ExerciseMcqData)
const isSingle = computed(() => data.value.answer.length === 1)
// 选项都不长就排两列,省一半高度;有一条长的就一条一行,免得折成两三行
const twoColumns = computed(() => data.value.options.every((opt) => opt.length <= 16))
const selected = ref<Set<number>>(new Set())
const correct = ref(false)
@@ -75,46 +78,29 @@ function optionType(idx: number): "default" | "primary" | "success" {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-space align="center" :size="8">
<n-tag type="success" :bordered="false">
练一练 · {{ isSingle ? "单选题" : "多选题" }}
</n-tag>
</n-space>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
<div>
<p class="question">
{{ data.question }}
</p>
<n-space vertical :size="8">
<n-button
<div class="opts" :class="{ wide: twoColumns }">
<button
v-for="(opt, idx) in data.options"
:key="idx"
:type="optionType(idx)"
:secondary="optionType(idx) !== 'default'"
:tertiary="optionType(idx) === 'default'"
:strong="selected.has(idx)"
:style="{
justifyContent: 'flex-start',
width: '100%',
textAlign: 'left',
}"
type="button"
class="opt"
:class="optionType(idx)"
@click="select(idx)"
>
<template #icon>
<span style="font-weight: 700">{{ String.fromCharCode(65 + idx) }}</span>
</template>
{{ opt }}
</n-button>
</n-space>
<span class="key">{{ String.fromCharCode(65 + idx) }}</span>
<span>{{ opt }}</span>
</button>
</div>
<n-alert
<ExerciseResult
v-if="correct || wrong || partial"
:type="correct ? 'success' : partial ? 'warning' : 'error'"
:title="correct ? '正确!' : partial ? '部分正确,请重试' : '选择有误,请重试'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
@@ -123,5 +109,73 @@ function optionType(idx: number): "default" | "primary" | "success" {
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
<style scoped>
.question {
font-weight: 600;
font-size: 15px;
line-height: 1.6;
margin: 0 0 12px;
}
.opts {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.opts.wide {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 768px) {
.opts.wide {
grid-template-columns: minmax(0, 1fr);
}
}
.opt {
min-height: 40px;
box-sizing: border-box;
padding: 8px 12px;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
font-size: 14px;
text-align: left;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.opt:hover {
border-color: #18a058;
}
.key {
width: 22px;
height: 22px;
flex-shrink: 0;
box-sizing: border-box;
border-radius: 11px;
border: 1.5px solid rgba(128, 128, 128, 0.5);
display: grid;
place-items: center;
font-size: 12px;
opacity: 0.85;
}
.opt.primary {
border-color: #18a058;
background: rgba(24, 160, 88, 0.08);
}
.opt.primary .key,
.opt.success .key {
border-color: #18a058;
background: #18a058;
color: #fff;
opacity: 1;
}
.opt.success {
border-color: #18a058;
background: rgba(24, 160, 88, 0.12);
}
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExercisePredictData } from "utils/types"
import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
@@ -55,11 +56,7 @@ function reset() {
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="error" :bordered="false">练一练 · 输出预测</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
@@ -88,16 +85,15 @@ function reset() {
style="font-family: Monaco"
/>
<n-alert
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '输出正确!' : '输出不正确,再读读代码看看'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="error" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
<n-button type="primary" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
/** 练一练判完的那一行:对了绿色打勾,错了红字。原来是 n-alert 一整块色条,比题目还抢眼 */
defineProps<{ type: "success" | "warning" | "error"; title: string }>()
</script>
<template>
<div class="result" :class="type">
<div class="line">
<Icon v-if="type === 'success'" icon="ph:check-bold" :width="16" />
<span>{{ title }}</span>
</div>
<div v-if="$slots.default" class="more"><slot /></div>
</div>
</template>
<style scoped>
.result {
margin-top: 12px;
font-size: 14px;
line-height: 1.6;
}
.line {
display: flex;
align-items: center;
gap: 6px;
}
.success .line {
color: #18a058;
font-weight: 600;
}
.warning .line {
color: var(--learn-orange-text);
}
.error .line {
color: var(--learn-red-text);
}
.more {
margin-top: 4px;
opacity: 0.85;
}
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseSortData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
import { highlightLines } from "../composables/useCodeHighlight"
@@ -70,11 +71,7 @@ const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="info" :bordered="false">练一练 · 代码排序</n-tag>
</template>
<div>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
@@ -116,16 +113,15 @@ const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props
</div>
</n-space>
<n-alert
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '顺序正确!' : '顺序有误,红色行需要调整'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="info" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
<n-button type="primary" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</div>
</template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { Exercise } from "utils/types"
import { Icon } from "@iconify/vue"
import type { Exercise, ExerciseMcqData } from "utils/types"
import { reportExerciseAttempt } from "oj/api"
import { useUserStore } from "shared/store/user"
@@ -11,10 +12,33 @@ const ExercisePredict = defineAsyncComponent(() => import("./ExercisePredict.vue
const ExerciseDebug = defineAsyncComponent(() => import("./ExerciseDebug.vue"))
const ExerciseGroup = defineAsyncComponent(() => import("./ExerciseGroup.vue"))
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const props = defineProps<{
exercise: Exercise
lang?: string
/** 这一课的第几道、一共几道 */
index: number
total: number
/** 做对过(以前做对的,或者刚才做对的) */
solved: boolean
}>()
const emit = defineEmits<{ solved: [exerciseId: number] }>()
const userStore = useUserStore()
const KIND: Record<string, string> = {
sort: "代码排序",
fill: "代码填空",
match: "连线匹配",
predict: "输出预测",
debug: "代码找错",
group: "归类分组",
}
const kind = computed(() => {
if (props.exercise.type !== "mcq") return KIND[props.exercise.type] ?? "练习"
return (props.exercise.data as ExerciseMcqData).answer.length === 1 ? "单选题" : "多选题"
})
/**
* 七种题型各自判完对错后都往上抛 attempt,留痕只在这里做一次 ——
* 每个题型组件里各写一遍上报,早晚会漏掉一两个。
@@ -36,6 +60,8 @@ watch(
)
function onAttempt(payload: { correct: boolean; answer?: string }) {
// 做对了就告诉页面(目录、大纲、课末的进度跟着变),没登录也算 —— 只是不留痕
if (payload.correct) emit("solved", props.exercise.id)
if (!userStore.isAuthed || solved) return
const answer = payload.answer ?? ""
if (answer === lastAnswer) return
@@ -46,31 +72,95 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
</script>
<template>
<ExerciseMcq v-if="exercise.type === 'mcq'" :exercise="exercise" @attempt="onAttempt" />
<ExerciseSort
v-else-if="exercise.type === 'sort'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseFill
v-else-if="exercise.type === 'fill'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseMatch v-else-if="exercise.type === 'match'" :exercise="exercise" @attempt="onAttempt" />
<ExercisePredict
v-else-if="exercise.type === 'predict'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseDebug
v-else-if="exercise.type === 'debug'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseGroup v-else-if="exercise.type === 'group'" :exercise="exercise" @attempt="onAttempt" />
<section class="exercise" :id="`exercise-${exercise.id}`">
<header class="head">
<span class="tag">练一练</span>
<span class="kind">{{ kind }}</span>
<span class="grow" />
<span v-if="solved" class="done"><Icon icon="ph:check-bold" :width="13" />做对过</span>
<span class="num">第 {{ index }} 道 · 共 {{ total }} 道</span>
</header>
<ExerciseMcq v-if="exercise.type === 'mcq'" :exercise="exercise" @attempt="onAttempt" />
<ExerciseSort
v-else-if="exercise.type === 'sort'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseFill
v-else-if="exercise.type === 'fill'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseMatch
v-else-if="exercise.type === 'match'"
:exercise="exercise"
@attempt="onAttempt"
/>
<ExercisePredict
v-else-if="exercise.type === 'predict'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseDebug
v-else-if="exercise.type === 'debug'"
:exercise="exercise"
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseGroup
v-else-if="exercise.type === 'group'"
:exercise="exercise"
@attempt="onAttempt"
/>
</section>
</template>
<style scoped>
.exercise {
margin: 0 0 18px;
padding: 16px 18px;
border: 1px solid var(--learn-line);
border-radius: 8px;
scroll-margin-top: 16px;
}
.head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 13px;
}
.tag {
height: 22px;
padding: 0 8px;
border-radius: 3px;
display: inline-flex;
align-items: center;
font-size: 12px;
font-weight: 600;
background: var(--learn-green-bg);
color: var(--learn-green-text);
}
.kind {
opacity: 0.6;
}
.grow {
flex: 1;
}
.done {
display: inline-flex;
align-items: center;
gap: 3px;
color: #18a058;
font-size: 12px;
font-weight: 600;
}
.num {
font-size: 12px;
opacity: 0.55;
font-variant-numeric: tabular-nums;
}
</style>
@@ -1,49 +0,0 @@
<script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import type { TutorialProgress } from "utils/types"
const props = defineProps<{
titles: { id: number; title: string }[]
progress: Record<number, TutorialProgress>
traced: boolean
}>()
const stats = computed(() => {
const rows = props.titles.map((t) => props.progress[t.id])
const read = rows.filter((p) => p && p.totalSeconds >= TUTORIAL_READ_SECONDS).length
const solved = rows.reduce((n, p) => n + (p?.exerciseSolved ?? 0), 0)
const total = rows.reduce((n, p) => n + (p?.exerciseTotal ?? 0), 0)
return { read, solved, total }
})
const percent = computed(() =>
props.titles.length ? Math.round((stats.value.read / props.titles.length) * 100) : 0,
)
</script>
<template>
<div v-if="traced && titles.length" class="summary">
<n-progress
type="line"
:percentage="percent"
:height="8"
:show-indicator="false"
status="success"
/>
<n-text depth="3" class="numbers">
已读 {{ stats.read }}/{{ titles.length }} 课
<template v-if="stats.total"> · 练一练 {{ stats.solved }}/{{ stats.total }} </template>
</n-text>
</div>
</template>
<style scoped>
.summary {
padding: 4px 10px 12px;
}
.numbers {
display: block;
margin-top: 6px;
font-size: 12px;
}
</style>
+77 -12
View File
@@ -1,22 +1,87 @@
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import type { Segment } from "../composables/useExerciseParse"
import type { LessonSegment } from "../composables/useLessonParse"
import CodeRunBlock from "./CodeRunBlock.vue"
defineProps<{ segments: Segment[]; lang?: string }>()
const ExerciseWidget = defineAsyncComponent(() => import("./ExerciseWidget.vue"))
/** 一课的正文:按 useLessonParse 拆好的段落逐段渲染 */
defineProps<{
segments: LessonSegment[]
lang: "python" | "c"
tutorialId: number
exerciseCount: number
solvedIds: Set<number>
}>()
const emit = defineEmits<{ solved: [exerciseId: number] }>()
const isDark = useDark()
const ExerciseWidget = defineAsyncComponent(() => import("./ExerciseWidget.vue"))
</script>
<template>
<template v-for="(seg, i) in segments" :key="i">
<MdPreview
v-if="seg.type === 'md'"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
:model-value="seg.content"
/>
<ExerciseWidget v-else :exercise="seg.exercise" :lang="lang" />
</template>
<div class="body">
<template v-for="(seg, i) in segments" :key="i">
<MdPreview
v-if="seg.type === 'md'"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
:model-value="seg.content"
class="md"
/>
<h2 v-else-if="seg.type === 'heading'" :id="seg.id" class="h2">{{ seg.text }}</h2>
<div v-else-if="seg.type === 'goals'" :id="seg.id" class="goals">
<div class="goals-title">学完这课你能</div>
<div v-for="item in seg.items" :key="item">· {{ item.replace(/`/g, "") }}</div>
</div>
<CodeRunBlock
v-else-if="seg.type === 'code'"
:code="seg.code"
:lang="seg.lang"
:tutorial-id="tutorialId"
/>
<ExerciseWidget
v-else
:exercise="seg.exercise"
:lang="lang"
:index="seg.index + 1"
:total="exerciseCount"
:solved="solvedIds.has(seg.exercise.id)"
@solved="emit('solved', $event)"
/>
</template>
</div>
</template>
<style scoped>
.body {
font-size: 15px;
line-height: 1.85;
}
.md :deep(.md-editor-preview) {
font-size: 15px;
padding: 0;
}
.md :deep(.md-editor-preview-wrapper) {
padding: 0;
}
.h2 {
font-size: 20px;
line-height: 1.4;
margin: 28px 0 12px;
scroll-margin-top: 16px;
}
.goals {
margin: 4px 0 8px;
padding: 12px 16px;
border-radius: 6px;
background: var(--learn-soft);
font-size: 14px;
line-height: 1.8;
scroll-margin-top: 16px;
}
.goals-title {
font-weight: 700;
margin-bottom: 2px;
}
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { LessonState } from "../lesson"
/** 目录里每课前面的圆:学完打勾、在学橙圈、当前绿圈、没开始灰圈 */
defineProps<{ step: number; state: LessonState; current?: boolean }>()
</script>
<template>
<span class="dot" :class="state === 'done' ? 'done' : current ? 'current' : state">
<Icon v-if="state === 'done'" icon="ph:check-bold" :width="13" />
<template v-else>{{ step }}</template>
</span>
</template>
<style scoped>
.dot {
width: 24px;
height: 24px;
flex-shrink: 0;
box-sizing: border-box;
border-radius: 12px;
display: grid;
place-items: center;
font-size: 12px;
font-variant-numeric: tabular-nums;
border: 1.5px solid var(--learn-todo);
opacity: 0.9;
}
.done {
border: 0;
background: var(--learn-green);
color: #fff;
opacity: 1;
}
.started {
border: 2px solid var(--learn-orange);
color: var(--learn-orange-text);
font-weight: 600;
}
.current {
border: 2px solid var(--learn-green);
color: var(--learn-green-text);
font-weight: 700;
opacity: 1;
}
</style>
@@ -0,0 +1,296 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { TutorialProblem } from "@oj2/contract"
import { splitTitle, type LessonState, type ProjectGroup } from "../lesson"
import SegBar from "./SegBar.vue"
/**
* 一课的结尾(设计稿「一课的结尾」):练一练做到几道 → 全对就是「第 N 课学完了」→
* 老师挑的几道题 → 下一课。练一练没做完时下一课按钮是次要样式,不拦着。
*/
const props = defineProps<{
step: number
total: number
courseLabel: string
exerciseCount: number
solvedCount: number
/** 第一道还没做对的练一练,点了跳回去 */
firstUnsolved: { id: number; index: number } | null
problems: TutorialProblem[]
next: { title: string; to: string } | null
groups: ProjectGroup[]
stateOf: (id: number) => LessonState
doneCount: number
/** 窄屏:进度条短一点、题号挪到题目名下面 */
compact?: boolean
}>()
const emit = defineEmits<{ jump: [exerciseId: number] }>()
const finished = computed(() => props.exerciseCount > 0 && props.solvedCount >= props.exerciseCount)
const nextParts = computed(() => (props.next ? splitTitle(props.next.title) : null))
</script>
<template>
<div class="end">
<section v-if="finished" class="banner">
<span class="check"><Icon icon="ph:check-bold" :width="compact ? 22 : 24" /></span>
<div class="banner-text">
<span class="banner-title">第 {{ step }} 课学完了</span>
<div class="banner-progress">
<SegBar
:groups="groups"
:state-of="stateOf"
:width="compact ? 8 : 13"
:height="compact ? 5 : 6"
:gap="compact ? 2 : 3"
:group-gap="compact ? 5 : 8"
/>
<span class="muted num"
>{{ compact ? "" : `${courseLabel} 学完 ` }}{{ doneCount }} / {{ total
}}{{ compact ? "" : " 课" }}</span
>
</div>
</div>
</section>
<section v-else-if="exerciseCount" class="card practice">
<span class="strong">练一练</span>
<span class="num">{{ solvedCount }} / {{ exerciseCount }}</span>
<span class="boxes">
<span v-for="n in exerciseCount" :key="n" class="box" :class="{ on: n <= solvedCount }" />
</span>
<span class="grow" />
<a v-if="firstUnsolved" href="#" class="link" @click.prevent="emit('jump', firstUnsolved.id)">
<Icon icon="ph:arrow-up-bold" :width="14" />第 {{ firstUnsolved.index }} 道还没做对
</a>
</section>
<section v-if="problems.length" class="card problems">
<div class="problems-head strong">去做几道题</div>
<router-link
v-for="problem in problems"
:key="problem.id"
:to="`/problem/${problem.displayId}`"
class="problem"
>
<span v-if="!compact" class="pid num">{{ problem.displayId }}</span>
<span class="ptitle">
<span class="ellipsis">{{ problem.title }}</span>
<span v-if="compact" class="sub">
<span class="num muted">{{ problem.displayId }}</span>
<span v-if="problem.mustDo" class="must">必会</span>
</span>
</span>
<span v-if="!compact && problem.mustDo" class="must">必会</span>
<span v-if="problem.solved" class="solved"
><Icon icon="ph:check-bold" :width="13" />做对过</span
>
<span v-else class="go">去做<Icon icon="ph:caret-right-bold" :width="12" /></span>
</router-link>
</section>
<section v-if="next && nextParts" class="card next">
<div class="next-text">
<div class="muted small">下一课 · 第 {{ step + 1 }} 课</div>
<div class="next-title">
<span class="next-kp">{{ nextParts.kp }}</span>
<span v-if="nextParts.scene" class="muted">{{ nextParts.scene }}</span>
</div>
</div>
<n-button
:type="finished ? 'primary' : 'default'"
size="large"
icon-placement="right"
tag="a"
:href="next.to"
@click.prevent="$router.push(next.to)"
>
下一课
<template #icon><Icon icon="ph:caret-right-bold" /></template>
</n-button>
</section>
</div>
</template>
<style scoped>
.end {
display: flex;
flex-direction: column;
gap: 16px;
margin: 28px 0 24px;
}
.card {
border: 1px solid var(--learn-line);
border-radius: 8px;
}
.strong {
font-weight: 700;
font-size: 15px;
}
.num {
font-variant-numeric: tabular-nums;
}
.muted {
opacity: 0.6;
}
.small {
font-size: 12px;
}
.grow {
flex: 1;
}
.banner {
border: 1px solid var(--learn-green-line);
background: var(--learn-green-bg);
border-radius: 10px;
padding: 18px 20px;
display: flex;
align-items: center;
gap: 16px;
}
.check {
width: 44px;
height: 44px;
border-radius: 22px;
flex-shrink: 0;
background: var(--learn-green);
color: #fff;
display: grid;
place-items: center;
}
.banner-text {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.banner-title {
font-size: 19px;
font-weight: 700;
}
.banner-progress {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
font-size: 13px;
}
.practice {
padding: 14px 18px;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.boxes {
display: flex;
gap: 4px;
}
.box {
width: 34px;
height: 8px;
border-radius: 4px;
background: var(--learn-todo);
}
.box.on {
background: var(--learn-green);
}
.link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 600;
color: var(--learn-green-text);
text-decoration: none;
}
.problems {
overflow: hidden;
}
.problems-head {
height: 46px;
padding: 0 16px;
display: flex;
align-items: center;
}
.problem {
min-height: 48px;
padding: 0 16px;
border-top: 1px solid var(--learn-line);
display: flex;
align-items: center;
gap: 12px;
color: inherit;
text-decoration: none;
}
.problem:hover {
background: var(--learn-soft);
}
.pid {
width: 40px;
font-size: 13px;
opacity: 0.6;
}
.ptitle {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
font-size: 14px;
padding: 6px 0;
}
.ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.must {
height: 20px;
padding: 0 6px;
border-radius: 3px;
font-size: 12px;
display: inline-flex;
align-items: center;
background: var(--learn-orange-bg);
color: var(--learn-orange-text);
flex-shrink: 0;
}
.solved,
.go {
width: 64px;
justify-content: flex-end;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 13px;
font-weight: 600;
color: var(--learn-green-text);
}
.next {
padding: 16px 18px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.next-text {
flex: 1;
min-width: 0;
}
.next-title {
display: flex;
align-items: baseline;
gap: 10px;
flex-wrap: wrap;
}
.next-kp {
font-size: 17px;
font-weight: 700;
}
</style>
@@ -1,124 +0,0 @@
<script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import type { TutorialProgress } from "utils/types"
const props = defineProps<{
titles: { id: number; title: string }[]
step: number
/** 按教程 id 索引的自学留痕,未登录时是空的 */
progress: Record<number, TutorialProgress>
/** 是否在留痕(登录了才留) */
traced: boolean
}>()
const emit = defineEmits<{ select: [lesson: number] }>()
type Status = "todo" | "reading" | "done"
/**
* 三态:没打开过 / 读过但没读满或练习没做完 / 读满且练习全对。
* 没有练习的课只看阅读;「已读」的门槛沿用契约的 TUTORIAL_READ_SECONDS。
*/
function statusOf(id: number): Status {
const p = props.progress[id]
if (!p?.viewCount) return "todo"
const read = p.totalSeconds >= TUTORIAL_READ_SECONDS
const practiced = !p.exerciseTotal || p.exerciseSolved >= p.exerciseTotal
return read && practiced ? "done" : "reading"
}
function hint(id: number) {
const p = props.progress[id]
if (!p?.exerciseTotal) return ""
return `练一练 ${p.exerciseSolved}/${p.exerciseTotal}`
}
</script>
<template>
<ol class="lessons">
<li
v-for="(item, index) in titles"
:key="item.id"
class="lesson"
:class="{ active: step === index + 1 }"
@click="emit('select', index + 1)"
>
<span class="dot" :class="traced ? statusOf(item.id) : 'todo'">
<template v-if="traced && statusOf(item.id) === 'done'">✓</template>
<template v-else>{{ index + 1 }}</template>
</span>
<span class="text">
<span class="title">{{ item.title }}</span>
<span v-if="traced && hint(item.id)" class="hint">
{{ hint(item.id) }}
</span>
</span>
</li>
</ol>
<n-text v-if="!traced" depth="3" class="login-tip"> 登录后可以记录学习进度 </n-text>
</template>
<style scoped>
.lessons {
list-style: none;
margin: 0;
padding: 0;
}
.lesson {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.15s;
}
.lesson:hover {
background: rgba(128, 128, 128, 0.12);
}
.lesson.active {
background: rgba(24, 160, 88, 0.14);
}
.dot {
flex: none;
width: 24px;
height: 24px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
border: 1.5px solid rgba(128, 128, 128, 0.5);
}
.dot.reading {
border-color: #f0a020;
color: #f0a020;
}
.dot.done {
border-color: #18a058;
background: #18a058;
color: #fff;
}
.active .dot.todo {
border-color: #18a058;
color: #18a058;
}
.text {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
line-height: 1.4;
}
.active .title {
font-weight: 600;
}
.hint {
font-size: 12px;
opacity: 0.6;
}
.login-tip {
display: block;
padding: 8px 10px;
}
</style>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { OutlineItem } from "../composables/useLessonParse"
/**
* 右边「这一课」:各节标题,有练一练的节后面挂一支笔(做对了再打个勾),
* 读到哪一节哪一节变绿。底下是这课练一练做到几道。
*/
const props = defineProps<{
items: OutlineItem[]
activeId: string
solvedIds: Set<number>
exerciseCount: number
}>()
const emit = defineEmits<{ jump: [id: string] }>()
const solvedCount = computed(
() =>
props.items.flatMap((item) => item.exerciseIds).filter((id) => props.solvedIds.has(id)).length,
)
function allSolved(item: OutlineItem) {
return item.exerciseIds.every((id) => props.solvedIds.has(id))
}
</script>
<template>
<nav class="outline" aria-label="这一课">
<div class="title">这一课</div>
<a
v-for="item in items"
:key="item.id"
:href="`#${item.id}`"
class="item"
:class="{ active: item.id === activeId }"
@click.prevent="emit('jump', item.id)"
>
<span class="mark" />
<span class="text">{{ item.text }}</span>
<span v-if="item.exerciseIds.length" class="ex" :class="{ ok: allSolved(item) }">
<Icon icon="ph:pencil-simple" :width="12" />
<Icon v-if="allSolved(item)" icon="ph:check-bold" :width="12" />
</span>
</a>
<div v-if="exerciseCount" class="practice">
<div class="practice-head">
<span>练一练</span>
<span class="num">{{ solvedCount }} / {{ exerciseCount }}</span>
</div>
<div class="boxes">
<span v-for="n in exerciseCount" :key="n" class="box" :class="{ on: n <= solvedCount }" />
</div>
</div>
</nav>
</template>
<style scoped>
.outline {
display: flex;
flex-direction: column;
}
.title {
font-size: 12px;
font-weight: 700;
opacity: 0.55;
margin-bottom: 6px;
}
.item {
min-height: 32px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: inherit;
text-decoration: none;
opacity: 0.85;
}
.item:hover {
opacity: 1;
}
.item.active {
color: var(--learn-green-text);
font-weight: 700;
opacity: 1;
}
.mark {
width: 6px;
height: 6px;
border-radius: 3px;
flex-shrink: 0;
}
.active .mark {
background: var(--learn-green);
}
.text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ex {
display: inline-flex;
gap: 1px;
opacity: 0.5;
}
.ex.ok {
opacity: 1;
color: var(--learn-green-text);
}
.practice {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--learn-line);
}
.practice-head {
display: flex;
gap: 6px;
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.num {
font-weight: 400;
opacity: 0.6;
font-variant-numeric: tabular-nums;
}
.boxes {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.box {
width: 28px;
height: 8px;
border-radius: 4px;
background: var(--learn-todo);
}
.box.on {
background: var(--learn-green);
}
</style>
@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { TutorialProgress } from "@oj2/contract"
import { lessonState, splitTitle } from "../lesson"
import LessonDot from "./LessonDot.vue"
/** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */
const props = defineProps<{
title: string
step: number
to: string
progress?: TutorialProgress
active?: boolean
/** 行高,首页 48、目录 46、手机 50 */
height?: number
}>()
const emit = defineEmits<{ go: [] }>()
const parts = computed(() => splitTitle(props.title))
const state = computed(() => lessonState(props.progress))
</script>
<template>
<router-link
:to="to"
class="row"
:class="{ active }"
:style="{ height: `${height ?? 46}px` }"
@click="emit('go')"
>
<LessonDot :step="step" :state="state" :current="active" />
<span class="text">
<span class="kp">{{ parts.kp }}</span>
<span v-if="parts.scene" class="scene">{{ parts.scene }}</span>
</span>
<span v-if="state === 'started' && progress?.exerciseTotal" class="count">
{{ progress.exerciseSolved }}/{{ progress.exerciseTotal }}
</span>
</router-link>
</template>
<style scoped>
.row {
box-sizing: border-box;
padding: 0 10px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 10px;
color: inherit;
text-decoration: none;
}
.row:hover {
background: var(--learn-soft);
}
.row.active {
background: var(--learn-green-bg);
}
.text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.kp,
.scene {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.kp {
font-size: 14px;
line-height: 20px;
font-weight: 500;
}
.active .kp {
font-weight: 700;
}
.scene {
font-size: 12px;
line-height: 16px;
opacity: 0.55;
}
.count {
flex-shrink: 0;
font-size: 12px;
color: var(--learn-orange-text);
font-variant-numeric: tabular-nums;
}
</style>
@@ -1,36 +0,0 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
defineProps<{ step: number; total: number }>()
const theme = useThemeVars()
const emit = defineEmits<{ go: [lesson: number] }>()
</script>
<template>
<nav class="pager" :style="{ background: theme.bodyColor }">
<n-button secondary :disabled="step <= 1" @click="emit('go', step - 1)"> ← 上一课 </n-button>
<n-text depth="3">{{ step }} / {{ total }}</n-text>
<n-button
type="primary"
:secondary="step >= total"
:disabled="step >= total"
@click="emit('go', step + 1)"
>
下一课 →
</n-button>
</nav>
</template>
<style scoped>
.pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
margin-top: 16px;
position: sticky;
bottom: 0;
border-top: 1px solid rgba(128, 128, 128, 0.2);
}
</style>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { ProjectGroup } from "../lesson"
/** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */
defineProps<{ icon: string; tone: ProjectGroup["tone"]; size?: number }>()
</script>
<template>
<span
class="tile"
:class="tone"
:style="{ width: `${size ?? 36}px`, height: `${size ?? 36}px` }"
aria-hidden="true"
>
<Icon :icon="icon" :width="Math.round((size ?? 36) * 0.55)" />
</span>
</template>
<style scoped>
.tile {
flex-shrink: 0;
border-radius: 8px;
display: grid;
place-items: center;
}
.blue {
background: rgba(32, 128, 240, 0.12);
color: #1d5bb5;
}
.purple {
background: rgba(114, 46, 209, 0.1);
color: #5b3fa8;
}
.green {
background: rgba(24, 160, 88, 0.12);
color: #0c6b3b;
}
.orange {
background: rgba(240, 160, 32, 0.16);
color: #8a5200;
}
.gray {
background: rgba(128, 128, 128, 0.12);
color: inherit;
}
html.dark .blue {
color: #70c0e8;
}
html.dark .purple {
color: #b9a2f0;
}
html.dark .green {
color: #63e2b7;
}
html.dark .orange {
color: #f2c97d;
}
</style>
@@ -0,0 +1,178 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { TutorialProgress } from "@oj2/contract"
import { lessonPath, lessonState, type ProjectGroup, type TutorialType } from "../lesson"
import LessonRow from "./LessonRow.vue"
import ProjectTile from "./ProjectTile.vue"
/**
* 目录:按项目折叠,默认只展开正在学的那个项目(设计稿 A 的左栏、手机的目录抽屉)。
* `big` 是手机那版:项目行高一些、带图标。
*/
const props = defineProps<{
groups: ProjectGroup[]
progress: Record<number, TutorialProgress>
type: TutorialType
currentStep: number
big?: boolean
}>()
const emit = defineEmits<{ go: [] }>()
const open = ref(new Set<string>())
function keyOf(group: ProjectGroup) {
return `${group.project}:${group.lessons[0]!.id}`
}
// 换课换到别的项目时,把那个项目也展开(别的已经展开的不收)
watch(
() => [props.groups, props.currentStep] as const,
([groups, step]) => {
const group = groups.find((item) => item.lessons.some((lesson) => lesson.step === step))
if (group) open.value = new Set([...open.value, keyOf(group)])
},
{ immediate: true },
)
function toggle(group: ProjectGroup) {
const next = new Set(open.value)
const key = keyOf(group)
if (next.has(key)) next.delete(key)
else next.add(key)
open.value = next
}
function doneIn(group: ProjectGroup) {
return group.lessons.filter((lesson) => lessonState(props.progress[lesson.id]) === "done").length
}
</script>
<template>
<div class="tree" :class="{ big }">
<template v-for="group in groups" :key="keyOf(group)">
<button type="button" class="group" @click="toggle(group)">
<ProjectTile v-if="big" :icon="group.icon" :tone="group.tone" :size="30" />
<span v-else class="chev">
<Icon
:icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
:width="13"
/>
</span>
<span class="name">
<span v-if="big && group.no" class="no">{{ group.no }}</span>
<span class="label">
<span v-if="!big && group.no" class="no-inline">{{ group.no.slice(2) }}</span>
{{ group.name }}
</span>
</span>
<span v-if="doneIn(group) === group.lessons.length" class="count all">
<Icon icon="ph:check-bold" :width="12" />{{ doneIn(group) }}/{{ group.lessons.length }}
</span>
<span v-else class="count">{{ doneIn(group) }}/{{ group.lessons.length }}</span>
<span v-if="big" class="chev">
<Icon
:icon="open.has(keyOf(group)) ? 'ph:caret-down-bold' : 'ph:caret-right-bold'"
:width="14"
/>
</span>
</button>
<div v-if="open.has(keyOf(group))" class="lessons">
<LessonRow
v-for="lesson in group.lessons"
:key="lesson.id"
:title="lesson.title"
:step="lesson.step"
:to="lessonPath(type, lesson.step)"
:progress="progress[lesson.id]"
:active="lesson.step === currentStep"
:height="big ? 50 : 46"
@go="emit('go')"
/>
</div>
</template>
</div>
</template>
<style scoped>
.tree {
display: flex;
flex-direction: column;
gap: 2px;
}
.group {
height: 36px;
padding: 0 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: inherit;
font: inherit;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
text-align: left;
}
.group:hover {
background: var(--learn-soft);
}
.big .group {
height: 52px;
gap: 10px;
border-top: 1px solid var(--learn-line);
border-radius: 0;
}
.chev {
display: flex;
opacity: 0.5;
}
.name {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.label {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.big .label {
font-size: 14px;
font-weight: 700;
}
.no {
font-size: 11px;
opacity: 0.55;
}
.no-inline {
font-weight: 400;
opacity: 0.5;
margin-right: 6px;
}
.count {
flex-shrink: 0;
font-size: 12px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
gap: 3px;
}
.count.all {
opacity: 1;
color: var(--learn-green-text);
font-weight: 600;
}
.lessons {
display: flex;
flex-direction: column;
gap: 2px;
padding-left: 6px;
}
.big .lessons {
padding: 0 0 8px;
}
</style>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { LessonState, ProjectGroup } from "../lesson"
/**
* 一门课的进度条:一课一小段,按项目分组隔开。绿 = 学完,橙 = 在学,灰 = 没开始,
* 当前这课描一圈。
*/
defineProps<{
groups: ProjectGroup[]
stateOf: (id: number) => LessonState
current?: number
/** 每段的宽、高 */
width?: number
height?: number
gap?: number
groupGap?: number
}>()
</script>
<template>
<div class="seg" :style="{ gap: `${groupGap ?? 10}px` }" aria-hidden="true">
<div
v-for="group in groups"
:key="group.lessons[0]!.id"
class="group"
:style="{ gap: `${gap ?? 3}px` }"
>
<span
v-for="lesson in group.lessons"
:key="lesson.id"
class="cell"
:class="[stateOf(lesson.id), { current: lesson.id === current }]"
:style="{
width: `${width ?? 20}px`,
height: `${height ?? 6}px`,
borderRadius: `${(height ?? 6) / 2}px`,
}"
/>
</div>
</div>
</template>
<style scoped>
.seg,
.group {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.cell {
flex-shrink: 0;
background: var(--learn-todo);
}
.cell.done {
background: var(--learn-green);
}
.cell.started {
background: var(--learn-orange);
}
.cell.current {
outline: 2px solid var(--learn-green-text);
outline-offset: 1px;
}
</style>
@@ -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
}
@@ -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<string, "python" | "c"> = {
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 }
}
+575
View File
@@ -0,0 +1,575 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { TutorialProgress, TutorialSummary } from "@oj2/contract"
import { getLearnProgress, getTutorials } from "../api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
import {
COURSES,
continueStep,
doneCount,
groupByProject,
lessonPath,
lessonState,
splitProject,
splitTitle,
type TutorialType,
} from "./lesson"
import LessonRow from "./components/LessonRow.vue"
import ProjectTile from "./components/ProjectTile.vue"
import ProjectTree from "./components/ProjectTree.vue"
import SegBar from "./components/SegBar.vue"
import "./learn.css"
/**
* 自学首页 /learn(设计稿「自学首页」):最上面「接着学」,下面按项目分组的课程地图。
*
* 「接着学」看的是**服务端留痕**里最近打开的那一课,不是本机记的:机房一台电脑
* 轮着好几个班用,本地记的是上一个人学到哪。
*/
const userStore = useUserStore()
const authModal = useAuthModalStore()
const { isDesktop } = useBreakpoints()
const route = useRoute()
const router = useRouter()
interface Course {
type: TutorialType
label: string
titles: TutorialSummary[]
progress: Record<number, TutorialProgress>
}
const courses = ref<Course[]>([])
const loaded = ref(false)
const tab = ref<TutorialType>(route.query.course === "c" ? "c" : "python")
async function load() {
const authed = userStore.isAuthed
courses.value = await Promise.all(
COURSES.map(async ({ type, label }) => {
const [titles, rows] = await Promise.all([
getTutorials(type),
authed ? getLearnProgress(type).catch(() => []) : Promise.resolve([]),
])
return {
type,
label,
titles,
progress: Object.fromEntries(rows.map((row) => [row.tutorialId, row])),
}
}),
)
// 没指定看哪门课时,打开最近在学的那门
if (!route.query.course && resume.value) tab.value = resume.value.course.type
loaded.value = true
}
function latestViewed(course: Course) {
return Object.values(course.progress).reduce<string>(
(acc, row) => (row.lastViewedAt && row.lastViewedAt > acc ? row.lastViewedAt : acc),
"",
)
}
/** 接着学:最近打开过的那门课里该接着学的那课 */
const resume = computed(() => {
const started = courses.value
.filter((course) => course.titles.length && latestViewed(course))
.sort((a, b) => latestViewed(b).localeCompare(latestViewed(a)))
const course = started[0]
if (!course) return null
const step = continueStep(course.titles, course.progress)
if (!step) return null
const item = course.titles[step - 1]!
return { course, step, item, parts: splitTitle(item.title), project: splitProject(item.project) }
})
const others = computed(() =>
courses.value.filter(
(course) => course.titles.length && course.type !== resume.value?.course.type,
),
)
const current = computed(() => courses.value.find((course) => course.type === tab.value))
const groups = computed(() => groupByProject(current.value?.titles ?? []))
function stateOf(course: Course) {
return (id: number) => lessonState(course.progress[id])
}
function firstLesson(course: Course) {
return course.titles[0] ? splitTitle(course.titles[0].title) : null
}
function courseStep(course: Course) {
return continueStep(course.titles, course.progress)
}
function pickTab(type: TutorialType) {
tab.value = type
router.replace({ query: { ...route.query, course: type } })
}
function doneIn(course: Course, ids: number[]) {
return ids.filter((id) => lessonState(course.progress[id]) === "done").length
}
watch(() => userStore.isAuthed, load)
onMounted(load)
</script>
<template>
<div class="home">
<template v-if="loaded">
<!-- 第一排:接着学 + 另一门课;一课没学过就是两张「开始学」 -->
<div class="top" :class="{ two: !resume }">
<section v-if="resume" class="resume">
<div class="resume-main">
<div class="resume-meta">
<span class="resume-label">接着学</span>
<span class="muted">
{{ resume.course.label }}
<template v-if="resume.item.project"> · {{ resume.item.project }}</template>
· 第 {{ resume.step }} 课
</span>
</div>
<div class="resume-title">
<span class="resume-kp">{{ resume.parts.kp }}</span>
<span v-if="resume.parts.scene" class="resume-scene">{{ resume.parts.scene }}</span>
</div>
<div class="resume-progress">
<SegBar
:groups="groupByProject(resume.course.titles)"
:state-of="stateOf(resume.course)"
:current="resume.item.id"
:width="isDesktop ? 20 : 11"
:gap="isDesktop ? 3 : 2"
:group-gap="isDesktop ? 10 : 6"
/>
<span class="num sec">
学完 {{ doneCount(resume.course.titles, resume.course.progress) }} /
{{ resume.course.titles.length }} 课
</span>
</div>
</div>
<n-button
type="primary"
size="large"
class="resume-go"
icon-placement="right"
@click="router.push(lessonPath(resume.course.type, resume.step))"
>
<template #icon><Icon icon="ph:caret-right-bold" /></template>
接着学
</n-button>
</section>
<!-- 手机上有了「接着学」就不再摆另一门课的卡,下面的页签切得到 -->
<section
v-for="course in resume
? isDesktop
? others
: []
: courses.filter((c) => c.titles.length)"
:key="course.type"
class="course card"
>
<div class="course-text">
<div class="course-head">
<span class="course-name">{{ course.label }}</span>
<span class="muted small">
{{ course.titles.length }} 课
<template v-if="resume">
·
{{
courseStep(course)
? `学完 ${doneCount(course.titles, course.progress)} 课`
: "还没开始"
}}</template
>
<template v-else> · {{ groupByProject(course.titles).length }} 个项目</template>
</span>
</div>
<div v-if="!resume && firstLesson(course)" class="first">
<span class="muted">第 1 课</span>
<span class="first-kp">{{ firstLesson(course)!.kp }}</span>
<span class="sec">{{ firstLesson(course)!.scene }}</span>
</div>
<SegBar
:groups="groupByProject(course.titles)"
:state-of="stateOf(course)"
:width="resume ? 12 : 14"
/>
<router-link
v-if="resume"
:to="lessonPath(course.type, courseStep(course) ?? 1)"
class="course-link"
>
{{ courseStep(course) ? `接着学第 ${courseStep(course)} 课` : "从第 1 课开始" }}
<Icon icon="ph:caret-right-bold" :width="13" />
</router-link>
</div>
<n-button
v-if="!resume"
type="primary"
icon-placement="right"
@click="router.push(lessonPath(course.type, 1))"
>
<template #icon><Icon icon="ph:caret-right-bold" /></template>
开始学
</n-button>
</section>
</div>
<div class="tabs" role="tablist">
<button
v-for="course in courses.filter((c) => c.titles.length)"
:key="course.type"
type="button"
role="tab"
class="tab"
:class="{ on: tab === course.type }"
:aria-selected="tab === course.type"
@click="pickTab(course.type)"
>
{{ course.label }}
<span class="tab-sub">
{{
userStore.isAuthed && doneCount(course.titles, course.progress)
? `学完 ${doneCount(course.titles, course.progress)}/${course.titles.length}`
: `${course.titles.length} 课`
}}
</span>
</button>
<span class="grow" />
<button
v-if="!userStore.isAuthed"
type="button"
class="login"
@click="authModal.openLoginModal()"
>
登录后记下学到哪
</button>
</div>
<div v-if="current && isDesktop" class="map">
<section v-for="group in groups" :key="group.lessons[0]!.id" class="project card">
<div class="project-head">
<ProjectTile :icon="group.icon" :tone="group.tone" />
<span class="project-name">
<span v-if="group.no" class="muted small">{{ group.no }}</span>
<span class="project-title">{{ group.name }}</span>
</span>
<span
v-if="
userStore.isAuthed &&
doneIn(
current,
group.lessons.map((l) => l.id),
) === group.lessons.length
"
class="project-count all"
>
<Icon icon="ph:check-bold" :width="13" />{{ group.lessons.length }}/{{
group.lessons.length
}}
</span>
<span
v-else-if="
userStore.isAuthed &&
doneIn(
current,
group.lessons.map((l) => l.id),
)
"
class="project-count"
>
{{
doneIn(
current,
group.lessons.map((l) => l.id),
)
}}/{{ group.lessons.length }}
</span>
<span v-else class="project-count">{{ group.lessons.length }} 课</span>
</div>
<LessonRow
v-for="lesson in group.lessons"
:key="lesson.id"
:title="lesson.title"
:step="lesson.step"
:to="lessonPath(current.type, lesson.step)"
:progress="current.progress[lesson.id]"
:active="resume?.course.type === current.type && resume.step === lesson.step"
:height="48"
/>
</section>
</div>
<ProjectTree
v-else-if="current"
:groups="groups"
:progress="current.progress"
:type="current.type"
:current-step="resume?.course.type === current.type ? resume.step : 0"
big
/>
<n-empty
v-if="!courses.some((c) => c.titles.length)"
description="还没有公开的教程"
style="margin-top: 80px"
/>
</template>
<n-spin v-else class="spin" />
</div>
</template>
<style scoped>
.home {
max-width: 1286px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.muted {
opacity: 0.6;
}
.sec {
opacity: 0.8;
}
.small {
font-size: 13px;
}
.num {
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
.spin {
display: flex;
justify-content: center;
margin-top: 80px;
}
.card {
border: 1px solid var(--learn-line);
border-radius: 8px;
}
.top {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 20px;
}
.top.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.resume {
border: 1px solid var(--learn-green-line);
background: var(--learn-green-bg);
border-radius: 10px;
padding: 20px 26px;
display: flex;
align-items: center;
gap: 24px;
}
.resume-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.resume-meta {
display: flex;
gap: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.resume-label {
color: var(--learn-green-text);
font-weight: 700;
}
.resume-title {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
}
.resume-kp {
font-size: 26px;
font-weight: 700;
}
.resume-scene {
font-size: 15px;
opacity: 0.8;
}
.resume-progress {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
margin-top: 6px;
font-size: 13px;
}
.course {
padding: 18px 22px;
display: flex;
align-items: center;
gap: 20px;
}
.course-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.course-head {
display: flex;
align-items: baseline;
gap: 8px;
}
.course-name {
font-size: 18px;
font-weight: 700;
}
.first {
display: flex;
gap: 6px;
font-size: 14px;
flex-wrap: wrap;
}
.first-kp {
font-weight: 600;
}
.course-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 600;
color: var(--learn-green-text);
text-decoration: none;
}
.tabs {
display: flex;
align-items: stretch;
gap: 24px;
height: 40px;
border-bottom: 1px solid var(--learn-line);
}
.tab {
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
color: inherit;
font: inherit;
font-size: 15px;
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 0;
}
.tab.on {
color: var(--learn-green-text);
font-weight: 700;
border-bottom-color: var(--learn-green);
}
.tab-sub {
font-size: 13px;
font-weight: 400;
opacity: 0.6;
}
.login {
border: 0;
background: transparent;
color: var(--learn-green-text);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.map {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
align-items: start;
}
@media (max-width: 1180px) {
.map {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.top {
grid-template-columns: minmax(0, 1fr);
}
}
.project {
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.project-head {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 8px 10px;
}
.project-name {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.project-title {
font-size: 15px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.project-count {
font-size: 13px;
opacity: 0.6;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
gap: 3px;
}
.project-count.all {
opacity: 1;
color: var(--learn-green-text);
font-weight: 600;
}
@media (max-width: 768px) {
.home {
gap: 14px;
}
.top,
.top.two {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.resume {
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 14px 16px;
}
.resume-kp {
font-size: 22px;
}
.resume-go {
width: 100%;
}
.course {
padding: 14px 16px;
}
.tabs {
gap: 20px;
}
}
</style>
+522 -150
View File
@@ -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>
+24
View File
@@ -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);
}
+117
View File
@@ -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<ProjectGroup, "icon" | "tone"> {
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<number, TutorialProgress>,
) {
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<number, TutorialProgress>) {
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")}`
}