update
Deploy / deploy (push) Canceled after 0s

This commit is contained in:
xuyue committed 2026-09-27 19:36:28 +08:00
1 parent 23844ae301
commit d25b5dbe6d
268 files changed
+3684 -8861

No files matched your search

@@ -102,9 +102,7 @@ function reset() {
:style="lineStyle(idx)"
@click="toggle(idx)"
>
<span
style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0"
>
<span style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0">
{{ idx + 1 }}
</span>
<span v-html="lineHtml[idx]" style="white-space: pre" />
@@ -36,9 +36,7 @@ const segments = computed<Segment[]>(() => {
return result
})
const blankCount = computed(
() => segments.value.filter((s) => s.type === "blank").length,
)
const blankCount = computed(() => segments.value.filter((s) => s.type === "blank").length)
const userInputs = ref<string[]>([])
const wrongBlanks = ref<Set<number>>(new Set())
const allCorrect = ref(false)
@@ -134,9 +132,7 @@ 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="warning" :disabled="allCorrect" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
@@ -25,9 +25,7 @@ onMounted(init)
watch(() => props.exercise.id, init)
const allPlaced = computed(() => placement.value.every((p) => p !== -1))
const allCorrect = computed(() =>
placement.value.every((p, i) => p === data.value.answer[i]),
)
const allCorrect = computed(() => placement.value.every((p, i) => p === data.value.answer[i]))
const locked = computed(() => submitted.value && allCorrect.value)
function onDragStart(i: number) {
@@ -42,9 +40,7 @@ function dropTo(bucket: number) {
submitted.value = false
}
const poolItems = computed(() =>
order.value.filter((i) => placement.value[i] === -1),
)
const poolItems = computed(() => order.value.filter((i) => placement.value[i] === -1))
function itemsIn(bucket: number): number[] {
return order.value.filter((i) => placement.value[i] === bucket)
}
@@ -57,11 +53,7 @@ function itemStatus(i: number): "correct" | "wrong" | "default" {
function chipStyle(i: number): Record<string, string> {
const status = itemStatus(i)
const color =
status === "correct"
? "#18a058"
: status === "wrong"
? "#d03050"
: "var(--n-border-color)"
status === "correct" ? "#18a058" : status === "wrong" ? "#d03050" : "var(--n-border-color)"
const plain = color === "var(--n-border-color)"
return {
padding: "6px 12px",
@@ -114,10 +106,7 @@ function reset() {
@dragover.prevent
@drop="dropTo(-1)"
>
<span
v-if="poolItems.length === 0"
style="color: var(--n-text-color-3); font-size: 13px"
>
<span v-if="poolItems.length === 0" style="color: var(--n-text-color-3); font-size: 13px">
(已全部归类)
</span>
<div
@@ -150,14 +139,7 @@ function reset() {
@dragover.prevent
@drop="dropTo(b)"
>
<p
style="
font-weight: 600;
margin: 0 0 8px;
text-align: center;
font-size: 14px;
"
>
<p style="font-weight: 600; margin: 0 0 8px; text-align: center; font-size: 14px">
{{ bucket }}
</p>
<n-space :size="8">
@@ -182,9 +164,7 @@ function reset() {
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit">
提交
</n-button>
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
@@ -37,9 +37,7 @@ onMounted(init)
watch(() => props.exercise.id, init)
const allPaired = computed(() => pairs.value.every((p) => p !== null))
const allCorrect = computed(() =>
pairs.value.every((p, i) => p === data.value.answer[i]),
)
const allCorrect = computed(() => pairs.value.every((p, i) => p === data.value.answer[i]))
const locked = computed(() => submitted.value && allCorrect.value)
function leftOf(rightIdx: number): number {
@@ -183,9 +181,7 @@ function dotStyle(color: string): Record<string, string> {
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="!allPaired || locked" @click="submit">
提交
</n-button>
<n-button type="primary" :disabled="!allPaired || locked" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
@@ -33,8 +33,7 @@ function submit() {
if (selected.value.size === 0 || correct.value) return
const answer = new Set(data.value.answer)
const sel = selected.value
const isEqual =
sel.size === answer.size && [...sel].every((v) => answer.has(v))
const isEqual = sel.size === answer.size && [...sel].every((v) => answer.has(v))
emit("attempt", { correct: isEqual, answer: describe(sel) })
if (isEqual) {
correct.value = true
@@ -105,9 +104,7 @@ function optionType(idx: number): "default" | "primary" | "success" {
@click="select(idx)"
>
<template #icon>
<span style="font-weight: 700">{{
String.fromCharCode(65 + idx)
}}</span>
<span style="font-weight: 700">{{ String.fromCharCode(65 + idx) }}</span>
</template>
{{ opt }}
</n-button>
@@ -116,18 +113,12 @@ function optionType(idx: number): "default" | "primary" | "success" {
<n-alert
v-if="correct || wrong || partial"
:type="correct ? 'success' : partial ? 'warning' : 'error'"
:title="
correct ? '正确!' : partial ? '部分正确,请重试' : '选择有误,请重试'
"
:title="correct ? '正确!' : partial ? '部分正确,请重试' : '选择有误,请重试'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button
type="primary"
:disabled="selected.size === 0 || correct"
@click="submit"
>
<n-button type="primary" :disabled="selected.size === 0 || correct" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
@@ -96,13 +96,7 @@ function reset() {
/>
<n-space style="margin-top: 12px" :size="8">
<n-button
type="error"
:disabled="submitted && allCorrect"
@click="submit"
>
提交
</n-button>
<n-button type="error" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
@@ -17,9 +17,7 @@ const lines = ref<LineItem[]>([])
const submitted = ref(false)
function init() {
const shuffled = shuffle(
data.value.lines.map((text, idx) => ({ originalIdx: idx, text })),
)
const shuffled = shuffle(data.value.lines.map((text, idx) => ({ originalIdx: idx, text })))
// 打乱后若恰好与原顺序一致,交换前两项,避免一进入就是已解出状态
const isCorrect = shuffled.every((item, i) => item.originalIdx === i)
if (isCorrect && shuffled.length > 1) {
@@ -53,9 +51,7 @@ function lineStatus(idx: number): "correct" | "wrong" | "default" {
return lines.value[idx].originalIdx === idx ? "correct" : "wrong"
}
const allCorrect = computed(() =>
lines.value.every((item, i) => item.originalIdx === i),
)
const allCorrect = computed(() => lines.value.every((item, i) => item.originalIdx === i))
function submit() {
submitted.value = true
@@ -70,9 +66,7 @@ function reset() {
init()
}
const lineHtml = computed<string[]>(() =>
highlightLines(data.value.lines, props.lang),
)
const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props.lang))
</script>
<template>
@@ -130,9 +124,7 @@ const lineHtml = computed<string[]>(() =>
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="info" :disabled="submitted && allCorrect" @click="submit">
提交
</n-button>
<n-button type="info" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
@@ -7,9 +7,7 @@ const ExerciseMcq = defineAsyncComponent(() => import("./ExerciseMcq.vue"))
const ExerciseSort = defineAsyncComponent(() => import("./ExerciseSort.vue"))
const ExerciseFill = defineAsyncComponent(() => import("./ExerciseFill.vue"))
const ExerciseMatch = defineAsyncComponent(() => import("./ExerciseMatch.vue"))
const ExercisePredict = defineAsyncComponent(
() => import("./ExercisePredict.vue"),
)
const ExercisePredict = defineAsyncComponent(() => import("./ExercisePredict.vue"))
const ExerciseDebug = defineAsyncComponent(() => import("./ExerciseDebug.vue"))
const ExerciseGroup = defineAsyncComponent(() => import("./ExerciseGroup.vue"))
@@ -48,11 +46,7 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
</script>
<template>
<ExerciseMcq
v-if="exercise.type === 'mcq'"
:exercise="exercise"
@attempt="onAttempt"
/>
<ExerciseMcq v-if="exercise.type === 'mcq'" :exercise="exercise" @attempt="onAttempt" />
<ExerciseSort
v-else-if="exercise.type === 'sort'"
:exercise="exercise"
@@ -65,11 +59,7 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseMatch
v-else-if="exercise.type === 'match'"
:exercise="exercise"
@attempt="onAttempt"
/>
<ExerciseMatch v-else-if="exercise.type === 'match'" :exercise="exercise" @attempt="onAttempt" />
<ExercisePredict
v-else-if="exercise.type === 'predict'"
:exercise="exercise"
@@ -82,9 +72,5 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
:lang="lang"
@attempt="onAttempt"
/>
<ExerciseGroup
v-else-if="exercise.type === 'group'"
:exercise="exercise"
@attempt="onAttempt"
/>
<ExerciseGroup v-else-if="exercise.type === 'group'" :exercise="exercise" @attempt="onAttempt" />
</template>
@@ -10,18 +10,14 @@ const props = defineProps<{
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 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,
props.titles.length ? Math.round((stats.value.read / props.titles.length) * 100) : 0,
)
</script>
@@ -36,9 +32,7 @@ const percent = computed(() =>
/>
<n-text depth="3" class="numbers">
已读 {{ stats.read }}/{{ titles.length }} 课
<template v-if="stats.total">
· 练一练 {{ stats.solved }}/{{ stats.total }}
</template>
<template v-if="stats.total"> · 练一练 {{ stats.solved }}/{{ stats.total }} </template>
</n-text>
</div>
</template>
@@ -6,9 +6,7 @@ import type { Segment } from "../composables/useExerciseParse"
defineProps<{ segments: Segment[]; lang?: string }>()
const isDark = useDark()
const ExerciseWidget = defineAsyncComponent(
() => import("./ExerciseWidget.vue"),
)
const ExerciseWidget = defineAsyncComponent(() => import("./ExerciseWidget.vue"))
</script>
<template>
@@ -55,9 +55,7 @@ function hint(id: number) {
</span>
</li>
</ol>
<n-text v-if="!traced" depth="3" class="login-tip">
登录后可以记录学习进度
</n-text>
<n-text v-if="!traced" depth="3" class="login-tip"> 登录后可以记录学习进度 </n-text>
</template>
<style scoped>
@@ -8,9 +8,7 @@ const emit = defineEmits<{ go: [lesson: number] }>()
<template>
<nav class="pager" :style="{ background: theme.bodyColor }">
<n-button secondary :disabled="step <= 1" @click="emit('go', step - 1)">
← 上一课
</n-button>
<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"
@@ -1,13 +1,8 @@
import type { Exercise } from "utils/types"
export type Segment =
| { type: "md"; content: string }
| { type: "exercise"; exercise: Exercise }
export type Segment = { type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
export function parseExercises(
content: string,
exercises: Exercise[],
): Segment[] {
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
+14 -60
View File
@@ -2,17 +2,9 @@
<div class="learn-container">
<template v-if="tutorial.id">
<!-- 桌面端:目录 | 正文(居中限宽) | 可收起的示例代码 -->
<div
v-if="isDesktop"
class="learn-layout"
:class="{ 'with-code': codeOpen }"
>
<div v-if="isDesktop" class="learn-layout" :class="{ 'with-code': codeOpen }">
<aside class="rail">
<LearnSummary
:titles="titles"
:progress="progress"
:traced="traced"
/>
<LearnSummary :titles="titles" :progress="progress" :traced="traced" />
<LessonList
:titles="titles"
:step="step"
@@ -28,12 +20,7 @@
<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"
>
<n-button v-if="tutorial.code" size="small" secondary @click="codeOpen = !codeOpen">
{{ codeOpen ? "收起示例代码" : "展开示例代码" }}
</n-button>
</n-flex>
@@ -44,11 +31,7 @@
</main>
<aside v-if="tutorial.code && codeOpen" class="code-panel">
<CodeEditor
:language="editorLanguage"
v-model="tutorial.code"
height="100%"
/>
<CodeEditor :language="editorLanguage" v-model="tutorial.code" height="100%" />
</aside>
</div>
@@ -76,27 +59,13 @@
</template>
</template>
<n-empty
v-if="isEmpty"
description="该教程还没有公开"
style="margin-top: 80px"
/>
<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 type { Tutorial, Exercise, LANGUAGE, TutorialProgress } from "utils/types"
import { getTutorial, getTutorials, getExercises, getLearnProgress } from "../api"
import { parseExercises } from "./composables/useExerciseParse"
import { useLearnTrace } from "./composables/useLearnTrace"
import { useBreakpoints } from "shared/composables/breakpoints"
@@ -106,9 +75,7 @@ import LessonList from "./components/LessonList.vue"
import LearnSummary from "./components/LearnSummary.vue"
import LessonBody from "./components/LessonBody.vue"
import PagerBar from "./components/PagerBar.vue"
const CodeEditor = defineAsyncComponent(
() => import("shared/components/CodeEditor.vue"),
)
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
const route = useRoute()
const router = useRouter()
@@ -125,9 +92,7 @@ const step = computed(() => {
return parseInt(value)
})
const type = computed<"python" | "c">(() =>
route.params.type === "c" ? "c" : "python",
)
const type = computed<"python" | "c">(() => (route.params.type === "c" ? "c" : "python"))
const tutorial = ref<Partial<Tutorial>>({
id: 0,
@@ -136,9 +101,7 @@ const tutorial = ref<Partial<Tutorial>>({
code: "",
})
const editorLanguage = computed<LANGUAGE>(() =>
tutorial.value.type === "c" ? "C" : "Python",
)
const editorLanguage = computed<LANGUAGE>(() => (tutorial.value.type === "c" ? "C" : "Python"))
const titles = ref<{ id: number; title: string }[]>([])
const progress = ref<Record<number, TutorialProgress>>({})
const exercises = ref<Exercise[]>([])
@@ -147,9 +110,7 @@ const activeTab = ref("content")
const codeOpen = useStorage("oj2:learn-code-open", true)
const isEmpty = ref(false)
const segments = computed(() =>
parseExercises(tutorial.value.content ?? "", exercises.value),
)
const segments = computed(() => parseExercises(tutorial.value.content ?? "", exercises.value))
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
useLearnTrace(
@@ -159,9 +120,7 @@ useLearnTrace(
function goToLesson(lessonNumber: number) {
activeTab.value = "content"
router.push(
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
)
router.push(`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`)
}
/**
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响上课,
@@ -174,9 +133,7 @@ async function loadProgress() {
}
try {
const rows = await getLearnProgress(type.value)
progress.value = Object.fromEntries(
rows.map((row) => [row.tutorialId, row]),
)
progress.value = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
} catch {
progress.value = {}
}
@@ -188,10 +145,7 @@ async function init() {
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),
])
const [res2, exs] = await Promise.allSettled([getTutorial(id), getExercises(id)])
if (res2.status === "fulfilled") tutorial.value = res2.value
exercises.value = exs.status === "fulfilled" ? exs.value : []
learnStep.value[type.value] = step.value