style(格式化): 改成单引号、一行最长 160,全仓重新格式化
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 13:52:24 +08:00
1 parent 530e1a3d5d
commit 195069450f
480 files changed
+15507 -24650

No files matched your search

+60 -103
View File
@@ -1,17 +1,17 @@
<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 "utils/pythonError"
import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
import { mainWrapExtensions, wrapMain } from "../wrapMain"
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 'utils/pythonError'
import { explainRuntimeError } from 'oj/problem/utils/runtimeError'
import { useAuthModalStore } from 'shared/store/authModal'
import { useUserStore } from 'shared/store/user'
import { mainWrapExtensions, wrapMain } from '../wrapMain'
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
const CodeEditor = defineAsyncComponent(() => import('shared/components/CodeEditor.vue'))
/**
* 正文里的一段代码:就地改、就地运行(设计稿「代码块就地运行」那块画板)。
@@ -24,7 +24,7 @@ const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEdit
*/
const props = defineProps<{
code: string
lang: "python" | "c"
lang: 'python' | 'c'
tutorialId: number
wrapMain?: boolean
}>()
@@ -38,116 +38,100 @@ const original = computed(() => (props.wrapMain ? wrapMain(props.code) : props.c
// 手机上也不折行,长行横着滚:折下来的那截看着像另起了一行代码
const extensions = computed(() => (props.wrapMain ? mainWrapExtensions : []))
const value = ref(original.value)
const input = ref("")
const input = ref('')
const running = ref(false)
type Outcome =
| { kind: "output"; text: string }
| { kind: "error"; title: string; message: string; raw: string; needInput?: boolean }
| { kind: "busy"; message: string }
| { kind: 'output'; text: string }
| { kind: 'error'; title: string; message: string; raw: string; needInput?: boolean }
| { kind: 'busy'; message: string }
const outcome = ref<Outcome | null>(null)
// 换课时组件实例可能被复用(v-for 的 key 是段落序号),跟着原文重置
watch(original, (code) => {
value.value = code
input.value = ""
input.value = ''
outcome.value = null
})
const edited = computed(() => value.value !== original.value)
// 看原文不看改过的:学生把 main 删了,按钮还在,点了才知道错在哪
const runnable = computed(() => props.lang === "python" || /\bmain\s*\(/.test(original.value))
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 runnable = computed(() => props.lang === 'python' || /\bmain\s*\(/.test(original.value))
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 =
"程序输出太多了,一直停不下来:多半是循环没有结束的条件(条件一直成立、循环变量忘了改),在不停地打印。"
const TOO_MUCH_OUTPUT = '程序输出太多了,一直停不下来:多半是循环没有结束的条件(条件一直成立、循环变量忘了改),在不停地打印。'
/** 判题机的临时目录名对学生没有意义,只留文件名 */
function cleanRaw(text: string) {
return text.replace(/\/judger\/run\/[^/"]+\//g, "").trim()
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")
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 ?? "",
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 {
function runtimeOutcome(result: SubmissionStatus, output: string, info: NonNullable<StatisticInfo['runtime_error']> | undefined): Outcome {
// 读输入读到头了:题目页那句「input() 用的次数超过了输入的行数」是说程序写错了,
// 教程里多半是下面的输入框还空着,要说的是「先填输入」
if (result === SubmissionStatus.runtime_error && info?.type === "EOFError") {
if (result === SubmissionStatus.runtime_error && info?.type === 'EOFError') {
return input.value.trim()
? {
kind: "error",
title: "输入不够",
message:
"程序每执行一次 input() 就读一行。输入框里的行数比它要读的少,按一行一个再多填几行。",
kind: 'error',
title: '输入不够',
message: '程序每执行一次 input() 就读一行。输入框里的行数比它要读的少,按一行一个再多填几行。',
raw: cleanRaw(output),
needInput: true,
}
: {
kind: "error",
title: "还没填输入",
message: "程序运行到 input() 在等你输入内容。先在上面的「输入」框里填好,再点运行。",
kind: 'error',
title: '还没填输入',
message: '程序运行到 input() 在等你输入内容。先在上面的「输入」框里填好,再点运行。',
raw: cleanRaw(output),
needInput: true,
}
}
if (result === SubmissionStatus.runtime_error) {
return {
kind: "error",
title: info?.line ? `运行到第 ${info.line} 行出错了` : "运行到一半出错了",
message: info ? explainRuntimeError(info, value.value) : "",
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
) {
if (result === SubmissionStatus.cpu_time_limit_exceeded || result === SubmissionStatus.real_time_limit_exceeded) {
return {
kind: "error",
title: "运行超时了",
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: 'error', title: '内存用太多了', message: '', raw: cleanRaw(output) }
}
return { kind: "busy", message: "运行没成功,过一会儿再点一次。" }
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: "" }
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: "运行没成功,过一会儿再点一次。" }
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 { kind: 'output', text: item.output.trimEnd() }
}
return runtimeOutcome(result, item.output, item.runtimeError)
}
@@ -170,8 +154,8 @@ async function run() {
} catch (error) {
// 后端的文案是给学生看的(「上一次还没跑完」「试运行的人太多了」)
outcome.value = {
kind: "busy",
message: errorMessage(error, "运行的服务暂时连不上,过一会儿再试。"),
kind: 'busy',
message: errorMessage(error, '运行的服务暂时连不上,过一会儿再试。'),
}
} finally {
running.value = false
@@ -185,7 +169,7 @@ function restore() {
async function copyCode() {
await copy(value.value)
message.success("已复制")
message.success('已复制')
}
</script>
@@ -196,18 +180,9 @@ async function copyCode() {
<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>
<button type="button" class="tool" @click="restore"><Icon icon="ph:arrow-counter-clockwise" :width="14" />还原</button>
</template>
<button
v-if="runnable"
type="button"
class="run"
:class="{ busy: running }"
:disabled="running"
@click="run"
>
<button v-if="runnable" 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>
@@ -216,33 +191,15 @@ async function copyCode() {
</button>
</div>
<div class="editor" :style="{ minHeight: `${lineCount * 22 + 16}px` }">
<CodeEditor
v-model:value="value"
:language="language"
:font-size="14"
height="auto"
:extra-extensions="extensions"
/>
<CodeEditor v-model:value="value" :language="language" :font-size="14" height="auto" :extra-extensions="extensions" />
</div>
<div
v-if="runnable && needsInput"
class="pane input"
:class="{ ask: outcome?.kind === 'error' && outcome.needInput }"
>
<div v-if="runnable && needsInput" class="pane input" :class="{ ask: outcome?.kind === 'error' && outcome.needInput }">
<div class="label">输入</div>
<n-input
v-model:value="input"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="程序要读的内容写在这里,一行一个"
class="mono"
/>
<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>
<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>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseDebugData } from "utils/types"
import { highlightLines } from "shared/highlight"
import ExerciseResult from './ExerciseResult.vue'
import type { Exercise, ExerciseDebugData } from 'utils/types'
import { highlightLines } from 'shared/highlight'
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -33,46 +33,39 @@ function toggle(i: number) {
selected.value = s
}
function lineStatus(i: number): "correct" | "wrong" | "selected" | "default" {
if (!submitted.value) return selected.value.has(i) ? "selected" : "default"
function lineStatus(i: number): 'correct' | 'wrong' | 'selected' | 'default' {
if (!submitted.value) return selected.value.has(i) ? 'selected' : 'default'
const isAns = data.value.answer.includes(i)
const isSel = selected.value.has(i)
if (isAns) return isSel ? "correct" : "wrong" // 漏选也标红
if (isSel) return "wrong"
return "default"
if (isAns) return isSel ? 'correct' : 'wrong' // 漏选也标红
if (isSel) return 'wrong'
return 'default'
}
function lineStyle(i: number): Record<string, string> {
const status = lineStatus(i)
const color =
status === "correct"
? "#18a058"
: status === "wrong"
? "#d03050"
: status === "selected"
? "#2080f0"
: "var(--learn-control-line)"
const plain = color === "var(--learn-control-line)"
const color = status === 'correct' ? '#18a058' : status === 'wrong' ? '#d03050' : status === 'selected' ? '#2080f0' : 'var(--learn-control-line)'
const plain = color === 'var(--learn-control-line)'
return {
display: "flex",
alignItems: "center",
gap: "10px",
padding: "6px 12px",
borderRadius: "6px",
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '6px 12px',
borderRadius: '6px',
border: `1.5px solid ${color}`,
background: plain ? "transparent" : color + "14",
cursor: locked.value ? "default" : "pointer",
fontFamily: "var(--learn-code-font)",
userSelect: "none",
background: plain ? 'transparent' : color + '14',
cursor: locked.value ? 'default' : 'pointer',
fontFamily: 'var(--learn-code-font)',
userSelect: 'none',
}
}
function submit() {
submitted.value = true
const picked = [...selected.value].sort((a, b) => a - b).map((i) => i + 1)
emit("attempt", {
emit('attempt', {
correct: allCorrect.value,
answer: picked.length ? `选了第 ${picked.join("、")} 行` : "一行都没选",
answer: picked.length ? `选了第 ${picked.join('、')} 行` : '一行都没选',
})
}
@@ -87,15 +80,11 @@ function reset() {
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--learn-muted); font-size: 13px; margin: 0 0 12px">
点击你认为有错误的代码行(可多选)。
</p>
<p style="color: var(--learn-muted); font-size: 13px; margin: 0 0 12px">点击你认为有错误的代码行(可多选)。</p>
<!-- 不折行:长行整组一起横着滚,每行一样宽(= 最长那行),和正文的代码块一样 -->
<div style="overflow-x: auto">
<div
style="display: flex; flex-direction: column; gap: 6px; width: max-content; min-width: 100%"
>
<div style="display: flex; flex-direction: column; gap: 6px; width: max-content; min-width: 100%">
<div v-for="(_, idx) in data.lines" :key="idx" :style="lineStyle(idx)" @click="toggle(idx)">
<span style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0">
{{ idx + 1 }}
@@ -105,11 +94,7 @@ function reset() {
</div>
</div>
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '找对了!' : '还没找全,红色行是错误所在'"
>
<ExerciseResult v-if="submitted" :type="allCorrect ? 'success' : 'error'" :title="allCorrect ? '找对了!' : '还没找全,红色行是错误所在'">
<template v-if="submitted && data.explanation" #default>
{{ data.explanation }}
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseFillData } from "utils/types"
import { highlightWithHoles } from "shared/highlight"
import ExerciseResult from './ExerciseResult.vue'
import type { Exercise, ExerciseFillData } from 'utils/types'
import { highlightWithHoles } from 'shared/highlight'
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -10,18 +10,18 @@ const emit = defineEmits<{
const data = computed(() => props.exercise.data as ExerciseFillData)
type CodeSeg = { type: "code"; html: string }
type BlankSeg = { type: "blank"; answers: string[]; index: number }
type CodeSeg = { type: 'code'; html: string }
type BlankSeg = { type: 'blank'; answers: string[]; index: number }
type Segment = CodeSeg | BlankSeg
const segments = computed<Segment[]>(() => {
// 空里先填上第一个答案再解析,免得空把语法搅乱;答案本身不输出,见 highlightWithHoles
const blanks: string[][] = []
const holes: { from: number; to: number }[] = []
let code = ""
let code = ''
let last = 0
for (const m of data.value.code.matchAll(/\{\{([^}]+)\}\}/g)) {
const answers = m[1].split("|")
const answers = m[1].split('|')
code += data.value.code.slice(last, m.index)
holes.push({ from: code.length, to: code.length + answers[0].length })
code += answers[0]
@@ -31,9 +31,7 @@ const segments = computed<Segment[]>(() => {
code += data.value.code.slice(last)
return highlightWithHoles(code, holes, props.lang).map((part) =>
typeof part === "string"
? { type: "code", html: part }
: { type: "blank", answers: blanks[part], index: part },
typeof part === 'string' ? { type: 'code', html: part } : { type: 'blank', answers: blanks[part], index: part },
)
})
@@ -44,19 +42,19 @@ const segments = computed<Segment[]>(() => {
const lines = computed(() => {
const rows: { parts: Segment[] }[] = [{ parts: [] }]
for (const seg of segments.value) {
if (seg.type === "blank") {
if (seg.type === 'blank') {
rows[rows.length - 1].parts.push(seg)
continue
}
seg.html.split("\n").forEach((html, i) => {
seg.html.split('\n').forEach((html, i) => {
if (i > 0) rows.push({ parts: [] })
if (html) rows[rows.length - 1].parts.push({ type: "code", html })
if (html) rows[rows.length - 1].parts.push({ type: 'code', html })
})
}
return rows
})
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)
@@ -64,7 +62,7 @@ const allCorrect = ref(false)
watch(() => props.exercise.id, reset, { immediate: true })
function reset() {
userInputs.value = Array(blankCount.value).fill("")
userInputs.value = Array(blankCount.value).fill('')
wrongBlanks.value = new Set()
allCorrect.value = false
}
@@ -73,21 +71,21 @@ function submit() {
if (allCorrect.value) return
const wrong = new Set<number>()
for (const seg of segments.value) {
if (seg.type !== "blank") continue
if (!seg.answers.includes(userInputs.value[seg.index]?.trim() ?? "")) {
if (seg.type !== 'blank') continue
if (!seg.answers.includes(userInputs.value[seg.index]?.trim() ?? '')) {
wrong.add(seg.index)
}
}
wrongBlanks.value = wrong
allCorrect.value = wrong.size === 0
emit("attempt", {
emit('attempt', {
correct: allCorrect.value,
answer: `填了 ${userInputs.value.map((v) => v.trim() || "(空)").join(" | ")}`,
answer: `填了 ${userInputs.value.map((v) => v.trim() || '(空)').join(' | ')}`,
})
}
function inputWidth(idx: number): string {
return Math.max(4, (userInputs.value[idx]?.length ?? 0) + 2) + "ch"
return Math.max(4, (userInputs.value[idx]?.length ?? 0) + 2) + 'ch'
}
</script>
@@ -126,18 +124,8 @@ function inputWidth(idx: number): string {
lineHeight: '1.4',
padding: '2px 6px',
borderRadius: '4px',
border: `1.5px solid ${
allCorrect
? '#18a058'
: wrongBlanks.has(seg.index)
? '#d03050'
: 'var(--learn-control-line)'
}`,
background: allCorrect
? 'rgba(24,160,88,0.08)'
: wrongBlanks.has(seg.index)
? 'rgba(208,48,80,0.07)'
: 'var(--learn-pane)',
border: `1.5px solid ${allCorrect ? '#18a058' : wrongBlanks.has(seg.index) ? '#d03050' : 'var(--learn-control-line)'}`,
background: allCorrect ? 'rgba(24,160,88,0.08)' : wrongBlanks.has(seg.index) ? 'rgba(208,48,80,0.07)' : 'var(--learn-pane)',
outline: 'none',
color: 'inherit',
minWidth: '5ch',
@@ -1,8 +1,8 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseGroupData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
import { useBreakpoints } from "shared/composables/breakpoints"
import ExerciseResult from './ExerciseResult.vue'
import type { Exercise, ExerciseGroupData } from 'utils/types'
import { shuffle } from '../composables/useShuffle'
import { useBreakpoints } from 'shared/composables/breakpoints'
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -71,43 +71,42 @@ function itemsIn(bucket: number): number[] {
return order.value.filter((i) => placement.value[i] === bucket)
}
function itemStatus(i: number): "correct" | "wrong" | "default" {
if (!submitted.value || placement.value[i] === -1) return "default"
return placement.value[i] === data.value.answer[i] ? "correct" : "wrong"
function itemStatus(i: number): 'correct' | 'wrong' | 'default' {
if (!submitted.value || placement.value[i] === -1) return 'default'
return placement.value[i] === data.value.answer[i] ? 'correct' : 'wrong'
}
function chipStyle(i: number): Record<string, string> {
const status = itemStatus(i)
const color =
status === "correct" ? "#18a058" : status === "wrong" ? "#d03050" : "var(--learn-control-line)"
const plain = color === "var(--learn-control-line)"
const color = status === 'correct' ? '#18a058' : status === 'wrong' ? '#d03050' : 'var(--learn-control-line)'
const plain = color === 'var(--learn-control-line)'
if (pickedIdx.value === i) {
return {
padding: "5.5px 11.5px",
borderRadius: "6px",
border: "2px solid #2080f0",
background: "rgba(32,128,240,0.08)",
cursor: "pointer",
userSelect: "none",
fontSize: "15px",
padding: '5.5px 11.5px',
borderRadius: '6px',
border: '2px solid #2080f0',
background: 'rgba(32,128,240,0.08)',
cursor: 'pointer',
userSelect: 'none',
fontSize: '15px',
}
}
return {
padding: "6px 12px",
borderRadius: "6px",
padding: '6px 12px',
borderRadius: '6px',
border: `1.5px solid ${color}`,
background: plain ? "var(--learn-pane)" : color + "14",
cursor: locked.value ? "default" : "grab",
userSelect: "none",
fontSize: "15px",
background: plain ? 'var(--learn-pane)' : color + '14',
cursor: locked.value ? 'default' : 'grab',
userSelect: 'none',
fontSize: '15px',
}
}
function submit() {
submitted.value = true
emit("attempt", {
emit('attempt', {
correct: allCorrect.value,
answer: `分组 ${placement.value.map((b, i) => `${i + 1}→${b === -1 ? "?" : b + 1}`).join("、")}`,
answer: `分组 ${placement.value.map((b, i) => `${i + 1}→${b === -1 ? '?' : b + 1}`).join('、')}`,
})
}
@@ -122,11 +121,7 @@ function reset() {
{{ data.question }}
</p>
<p style="color: var(--learn-muted); font-size: 13px; margin: 0 0 12px">
{{
isDesktop
? "把下面的项目拖到对应的分组里,可在分组间拖动调整。"
: "先点一个项目,再点它该去的分组。"
}}
{{ isDesktop ? '把下面的项目拖到对应的分组里,可在分组间拖动调整。' : '先点一个项目,再点它该去的分组。' }}
</p>
<div
@@ -144,17 +139,8 @@ function reset() {
@drop="dropTo(-1)"
@click="onAreaClick(-1)"
>
<span v-if="poolItems.length === 0" style="color: var(--learn-muted); font-size: 13px">
(已全部归类)
</span>
<div
v-for="i in poolItems"
:key="i"
draggable="true"
:style="chipStyle(i)"
@dragstart="onDragStart(i)"
@click.stop="onItemClick(i)"
>
<span v-if="poolItems.length === 0" style="color: var(--learn-muted); font-size: 13px"> (已全部归类) </span>
<div v-for="i in poolItems" :key="i" draggable="true" :style="chipStyle(i)" @dragstart="onDragStart(i)" @click.stop="onItemClick(i)">
{{ data.items[i] }}
</div>
</div>
@@ -183,25 +169,14 @@ function reset() {
{{ bucket }}
</p>
<n-space :size="8">
<div
v-for="i in itemsIn(b)"
:key="i"
draggable="true"
:style="chipStyle(i)"
@dragstart="onDragStart(i)"
@click.stop="onItemClick(i)"
>
<div v-for="i in itemsIn(b)" :key="i" draggable="true" :style="chipStyle(i)" @dragstart="onDragStart(i)" @click.stop="onItemClick(i)">
{{ data.items[i] }}
</div>
</n-space>
</div>
</div>
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '归类全部正确!' : '有归类错误,红色项需要调整'"
/>
<ExerciseResult v-if="submitted" :type="allCorrect ? 'success' : 'error'" :title="allCorrect ? '归类全部正确!' : '有归类错误,红色项需要调整'" />
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="!allPlaced || locked" @click="submit"> 提交 </n-button>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseMatchData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
import ExerciseResult from './ExerciseResult.vue'
import type { Exercise, ExerciseMatchData } from 'utils/types'
import { shuffle } from '../composables/useShuffle'
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -10,16 +10,7 @@ const emit = defineEmits<{
const data = computed(() => props.exercise.data as ExerciseMatchData)
const PALETTE = [
"#2080f0",
"#18a058",
"#f0a020",
"#d03050",
"#8a2be2",
"#0891b2",
"#db2777",
"#65a30d",
]
const PALETTE = ['#2080f0', '#18a058', '#f0a020', '#d03050', '#8a2be2', '#0891b2', '#db2777', '#65a30d']
const rightOrder = ref<number[]>([]) // 显示顺序里的 right 原始下标
const pairs = ref<(number | null)[]>([]) // pairs[leftIdx] = 配对的 right 原始下标
@@ -72,9 +63,9 @@ function onRightClick(rightIdx: number) {
function submit() {
submitted.value = true
emit("attempt", {
emit('attempt', {
correct: allCorrect.value,
answer: `配对 ${pairs.value.map((p, i) => `${i + 1}→${p === null ? "?" : p + 1}`).join("、")}`,
answer: `配对 ${pairs.value.map((p, i) => `${i + 1}→${p === null ? '?' : p + 1}`).join('、')}`,
})
}
@@ -84,47 +75,47 @@ function reset() {
function leftColor(i: number): string {
if (submitted.value) {
if (pairs.value[i] === null) return "#d03050"
return pairs.value[i] === data.value.answer[i] ? "#18a058" : "#d03050"
if (pairs.value[i] === null) return '#d03050'
return pairs.value[i] === data.value.answer[i] ? '#18a058' : '#d03050'
}
if (selectedLeft.value === i) return "#2080f0"
if (selectedLeft.value === i) return '#2080f0'
if (pairs.value[i] !== null) return PALETTE[i % PALETTE.length]
return "var(--learn-control-line)"
return 'var(--learn-control-line)'
}
function rightColor(rightIdx: number): string {
const l = leftOf(rightIdx)
if (submitted.value) {
if (l === -1) return "var(--learn-control-line)"
return pairs.value[l] === data.value.answer[l] ? "#18a058" : "#d03050"
if (l === -1) return 'var(--learn-control-line)'
return pairs.value[l] === data.value.answer[l] ? '#18a058' : '#d03050'
}
if (l === -1) return "var(--learn-control-line)"
if (l === -1) return 'var(--learn-control-line)'
return PALETTE[l % PALETTE.length]
}
function itemStyle(color: string, selected: boolean): Record<string, string> {
const plain = color === "var(--learn-control-line)"
const plain = color === 'var(--learn-control-line)'
return {
display: "flex",
alignItems: "center",
gap: "8px",
padding: "10px 12px",
borderRadius: "6px",
border: `${selected ? "2px" : "1.5px"} solid ${color}`,
background: plain ? "transparent" : color + "14",
cursor: locked.value ? "default" : "pointer",
userSelect: "none",
fontSize: "15px",
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 12px',
borderRadius: '6px',
border: `${selected ? '2px' : '1.5px'} solid ${color}`,
background: plain ? 'transparent' : color + '14',
cursor: locked.value ? 'default' : 'pointer',
userSelect: 'none',
fontSize: '15px',
}
}
function dotStyle(color: string): Record<string, string> {
return {
width: "10px",
height: "10px",
borderRadius: "50%",
width: '10px',
height: '10px',
borderRadius: '50%',
background: color,
flexShrink: "0",
flexShrink: '0',
}
}
</script>
@@ -134,47 +125,25 @@ function dotStyle(color: string): Record<string, string> {
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--learn-muted); font-size: 13px; margin: 0 0 12px">
先点左边一项,再点右边一项即可连线;点击已连线的项可取消。
</p>
<p style="color: var(--learn-muted); font-size: 13px; margin: 0 0 12px">先点左边一项,再点右边一项即可连线;点击已连线的项可取消。</p>
<div style="display: flex; gap: 24px; align-items: flex-start">
<n-space vertical :size="8" style="flex: 1">
<div
v-for="(item, i) in data.left"
:key="'l' + i"
:style="itemStyle(leftColor(i), selectedLeft === i)"
@click="onLeftClick(i)"
>
<span
v-if="pairs[i] !== null && !submitted"
:style="dotStyle(PALETTE[i % PALETTE.length])"
/>
<div v-for="(item, i) in data.left" :key="'l' + i" :style="itemStyle(leftColor(i), selectedLeft === i)" @click="onLeftClick(i)">
<span v-if="pairs[i] !== null && !submitted" :style="dotStyle(PALETTE[i % PALETTE.length])" />
<span>{{ item }}</span>
</div>
</n-space>
<n-space vertical :size="8" style="flex: 1">
<div
v-for="rightIdx in rightOrder"
:key="'r' + rightIdx"
:style="itemStyle(rightColor(rightIdx), false)"
@click="onRightClick(rightIdx)"
>
<span
v-if="leftOf(rightIdx) !== -1 && !submitted"
:style="dotStyle(PALETTE[leftOf(rightIdx) % PALETTE.length])"
/>
<div v-for="rightIdx in rightOrder" :key="'r' + rightIdx" :style="itemStyle(rightColor(rightIdx), false)" @click="onRightClick(rightIdx)">
<span v-if="leftOf(rightIdx) !== -1 && !submitted" :style="dotStyle(PALETTE[leftOf(rightIdx) % PALETTE.length])" />
<span>{{ data.right[rightIdx] }}</span>
</div>
</n-space>
</div>
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '全部匹配正确!' : '有匹配错误,红色项需要重新连线'"
/>
<ExerciseResult v-if="submitted" :type="allCorrect ? 'success' : 'error'" :title="allCorrect ? '全部匹配正确!' : '有匹配错误,红色项需要重新连线'" />
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="!allPaired || locked" @click="submit"> 提交 </n-button>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseMcqData } from "utils/types"
import ExerciseResult from './ExerciseResult.vue'
import type { Exercise, ExerciseMcqData } from 'utils/types'
const props = defineProps<{ exercise: Exercise }>()
const emit = defineEmits<{
@@ -37,7 +37,7 @@ function submit() {
const answer = new Set(data.value.answer)
const sel = selected.value
const isEqual = sel.size === answer.size && [...sel].every((v) => answer.has(v))
emit("attempt", { correct: isEqual, answer: describe(sel) })
emit('attempt', { correct: isEqual, answer: describe(sel) })
if (isEqual) {
correct.value = true
wrong.value = false
@@ -60,7 +60,7 @@ function describe(sel: Set<number>) {
return `选了 ${[...sel]
.sort((a, b) => a - b)
.map((i) => String.fromCharCode(65 + i))
.join("、")}`
.join('、')}`
}
function reset() {
@@ -70,10 +70,10 @@ function reset() {
partial.value = false
}
function optionType(idx: number): "default" | "primary" | "success" {
if (correct.value && data.value.answer.includes(idx)) return "success"
if (selected.value.has(idx)) return "primary"
return "default"
function optionType(idx: number): 'default' | 'primary' | 'success' {
if (correct.value && data.value.answer.includes(idx)) return 'success'
if (selected.value.has(idx)) return 'primary'
return 'default'
}
</script>
@@ -84,14 +84,7 @@ function optionType(idx: number): "default" | "primary" | "success" {
</p>
<div class="opts" :class="{ wide: twoColumns }">
<button
v-for="(opt, idx) in data.options"
:key="idx"
type="button"
class="opt"
:class="optionType(idx)"
@click="select(idx)"
>
<button v-for="(opt, idx) in data.options" :key="idx" type="button" class="opt" :class="optionType(idx)" @click="select(idx)">
<span class="key">{{ String.fromCharCode(65 + idx) }}</span>
<span>{{ opt }}</span>
</button>
@@ -104,9 +97,7 @@ function optionType(idx: number): "default" | "primary" | "success" {
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="selected.size === 0 || correct" @click="submit">
提交
</n-button>
<n-button type="primary" :disabled="selected.size === 0 || correct" @click="submit"> 提交 </n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</div>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExercisePredictData } from "utils/types"
import { highlight } from "shared/highlight"
import ExerciseResult from './ExerciseResult.vue'
import type { Exercise, ExercisePredictData } from 'utils/types'
import { highlight } from 'shared/highlight'
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -12,24 +12,22 @@ const data = computed(() => props.exercise.data as ExercisePredictData)
const codeHtml = computed(() => highlight(data.value.code, props.lang))
const userInput = ref("")
const userInput = ref('')
const submitted = ref(false)
watch(() => props.exercise.id, reset, { immediate: true })
function normalize(s: string): string {
return s
.replace(/\r\n/g, "\n")
.split("\n")
.map((l) => l.replace(/\s+$/, ""))
.join("\n")
.replace(/^\n+/, "")
.replace(/\n+$/, "")
.replace(/\r\n/g, '\n')
.split('\n')
.map((l) => l.replace(/\s+$/, ''))
.join('\n')
.replace(/^\n+/, '')
.replace(/\n+$/, '')
}
const allCorrect = computed(() =>
data.value.answer.some((a) => normalize(a) === normalize(userInput.value)),
)
const allCorrect = computed(() => data.value.answer.some((a) => normalize(a) === normalize(userInput.value)))
// 改了答案就把上一次的判定收回去,等他重新点提交 —— 排序/连线/找错/分组四种题
// 本来就是这么做的(各自的交互处都会把 submitted 置回 false),只有这里漏了。
@@ -42,14 +40,14 @@ watch(userInput, () => {
function submit() {
submitted.value = true
emit("attempt", {
emit('attempt', {
correct: allCorrect.value,
answer: `答「${userInput.value.replace(/\n/g, "⏎")}」`,
answer: `答「${userInput.value.replace(/\n/g, '⏎')}」`,
})
}
function reset() {
userInput.value = ""
userInput.value = ''
submitted.value = false
}
</script>
@@ -75,9 +73,7 @@ function reset() {
><code style="font-family: inherit" v-html="codeHtml" /></pre>
<!-- 题目本身多半就是这句,再写一遍就成了同一个问题问两次 -->
<p v-if="!data.question.includes('输出什么')" style="font-weight: 500; margin: 14px 0 8px">
这段代码会输出什么?
</p>
<p v-if="!data.question.includes('输出什么')" style="font-weight: 500; margin: 14px 0 8px">这段代码会输出什么?</p>
<div v-else style="height: 14px" />
<n-input
v-model:value="userInput"
@@ -89,11 +85,7 @@ function reset() {
style="font-family: var(--learn-code-font)"
/>
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '输出正确!' : '输出不正确,再读读代码看看'"
/>
<ExerciseResult v-if="submitted" :type="allCorrect ? 'success' : 'error'" :title="allCorrect ? '输出正确!' : '输出不正确,再读读代码看看'" />
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { Icon } from '@iconify/vue'
/** 练一练判完的那一行:对了绿色打勾,错了红字。原来是 n-alert 一整块色条,比题目还抢眼 */
defineProps<{ type: "success" | "warning" | "error"; title: string }>()
defineProps<{ type: 'success' | 'warning' | 'error'; title: string }>()
</script>
<template>
@@ -1,9 +1,9 @@
<script setup lang="ts">
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseSortData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
import { highlightLines } from "shared/highlight"
import { useBreakpoints } from "shared/composables/breakpoints"
import ExerciseResult from './ExerciseResult.vue'
import type { Exercise, ExerciseSortData } from 'utils/types'
import { shuffle } from '../composables/useShuffle'
import { highlightLines } from 'shared/highlight'
import { useBreakpoints } from 'shared/composables/breakpoints'
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -67,19 +67,19 @@ function onLineClick(idx: number) {
pickedIdx.value = null
}
function lineStatus(idx: number): "correct" | "wrong" | "default" {
if (!submitted.value) return "default"
return lines.value[idx].originalIdx === idx ? "correct" : "wrong"
function lineStatus(idx: number): 'correct' | 'wrong' | 'default' {
if (!submitted.value) return 'default'
return lines.value[idx].originalIdx === idx ? 'correct' : 'wrong'
}
const allCorrect = computed(() => lines.value.every((item, i) => item.originalIdx === i))
function submit() {
submitted.value = true
emit("attempt", {
emit('attempt', {
correct: allCorrect.value,
// 报的是「他把原文第几行排在了第几位」,老师对着题面就能看出错在哪
answer: `顺序 ${lines.value.map((item) => item.originalIdx + 1).join("-")}`,
answer: `顺序 ${lines.value.map((item) => item.originalIdx + 1).join('-')}`,
})
}
@@ -95,15 +95,11 @@ const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props
<p :style="{ fontWeight: 500, fontSize: '16px', marginBottom: isDesktop ? '12px' : '8px' }">
{{ data.question }}
</p>
<p v-if="!isDesktop" style="color: var(--learn-muted); font-size: 13px; margin: 0 0 12px">
先点一行,再点它该去的位置。
</p>
<p v-if="!isDesktop" style="color: var(--learn-muted); font-size: 13px; margin: 0 0 12px">先点一行,再点它该去的位置。</p>
<!-- 不折行:长行整组一起横着滚,每行一样宽(= 最长那行),和正文的代码块一样 -->
<div style="overflow-x: auto">
<div
style="display: flex; flex-direction: column; gap: 6px; width: max-content; min-width: 100%"
>
<div style="display: flex; flex-direction: column; gap: 6px; width: max-content; min-width: 100%">
<div
v-for="(line, idx) in lines"
:key="line.originalIdx"
@@ -118,11 +114,7 @@ const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props
pickedIdx === idx
? '2px solid #2080f0'
: `1.5px ${submitted ? 'solid' : 'dashed'} ${
lineStatus(idx) === 'correct'
? '#18a058'
: lineStatus(idx) === 'wrong'
? '#d03050'
: 'var(--learn-control-line)'
lineStatus(idx) === 'correct' ? '#18a058' : lineStatus(idx) === 'wrong' ? '#d03050' : 'var(--learn-control-line)'
}`,
background:
pickedIdx === idx
@@ -147,11 +139,7 @@ const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props
</div>
</div>
<ExerciseResult
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '顺序正确!' : '顺序有误,红色行需要调整'"
/>
<ExerciseResult v-if="submitted" :type="allCorrect ? 'success' : 'error'" :title="allCorrect ? '顺序正确!' : '顺序有误,红色行需要调整'" />
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
@@ -1,16 +1,16 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { Exercise, ExerciseMcqData } from "utils/types"
import { reportExerciseAttempt } from "oj/api"
import { useUserStore } from "shared/store/user"
import { Icon } from '@iconify/vue'
import type { Exercise, ExerciseMcqData } from 'utils/types'
import { reportExerciseAttempt } from 'oj/api'
import { useUserStore } from 'shared/store/user'
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 ExerciseDebug = defineAsyncComponent(() => import("./ExerciseDebug.vue"))
const ExerciseGroup = defineAsyncComponent(() => import("./ExerciseGroup.vue"))
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 ExerciseDebug = defineAsyncComponent(() => import('./ExerciseDebug.vue'))
const ExerciseGroup = defineAsyncComponent(() => import('./ExerciseGroup.vue'))
const props = defineProps<{
exercise: Exercise
@@ -26,17 +26,17 @@ const emit = defineEmits<{ solved: [exerciseId: number] }>()
const userStore = useUserStore()
const KIND: Record<string, string> = {
sort: "代码排序",
fill: "代码填空",
match: "连线匹配",
predict: "输出预测",
debug: "代码找错",
group: "归类分组",
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 ? "单选题" : "多选题"
if (props.exercise.type !== 'mcq') return KIND[props.exercise.type] ?? '练习'
return (props.exercise.data as ExerciseMcqData).answer.length === 1 ? '单选题' : '多选题'
})
/**
@@ -61,9 +61,9 @@ watch(
function onAttempt(payload: { correct: boolean; answer?: string }) {
// 做对了就告诉页面(目录、大纲、课末的进度跟着变),没登录也算 —— 只是不留痕
if (payload.correct) emit("solved", props.exercise.id)
if (payload.correct) emit('solved', props.exercise.id)
if (!userStore.isAuthed || solved) return
const answer = payload.answer ?? ""
const answer = payload.answer ?? ''
if (answer === lastAnswer) return
lastAnswer = answer
if (payload.correct) solved = true
@@ -81,40 +81,12 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
<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"
/>
<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>
+10 -16
View File
@@ -1,14 +1,14 @@
<script setup lang="ts">
import Markdown from "shared/components/Markdown.vue"
import type { LessonSegment } from "../composables/useLessonParse"
import CodeRunBlock from "./CodeRunBlock.vue"
import Markdown from 'shared/components/Markdown.vue'
import type { LessonSegment } from '../composables/useLessonParse'
import CodeRunBlock from './CodeRunBlock.vue'
const ExerciseWidget = defineAsyncComponent(() => import("./ExerciseWidget.vue"))
const ExerciseWidget = defineAsyncComponent(() => import('./ExerciseWidget.vue'))
/** 一课的正文:按 useLessonParse 拆好的段落逐段渲染 */
defineProps<{
segments: LessonSegment[]
lang: "python" | "c"
lang: 'python' | 'c'
tutorialId: number
exerciseCount: number
solvedIds: Set<number>
@@ -18,11 +18,11 @@ const emit = defineEmits<{ solved: [exerciseId: number] }>()
/** 学习目标那几行只有行内的 **加粗** 和 `代码`,先转义再换成标签,别为它拉一整个 Markdown 渲染 */
function inline(text: string) {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/`([^`]+)`/g, '<code class="ic">$1</code>')
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
}
</script>
@@ -35,13 +35,7 @@ function inline(text: string) {
<div class="goals-title">学完这课你能</div>
<div v-for="item in seg.items" :key="item">· <span v-html="inline(item)" /></div>
</div>
<CodeRunBlock
v-else-if="seg.type === 'code'"
:code="seg.code"
:lang="seg.lang"
:wrap-main="seg.wrapMain"
:tutorial-id="tutorialId"
/>
<CodeRunBlock v-else-if="seg.type === 'code'" :code="seg.code" :lang="seg.lang" :wrap-main="seg.wrapMain" :tutorial-id="tutorialId" />
<ExerciseWidget
v-else
:exercise="seg.exercise"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { LessonState } from "../lesson"
import { Icon } from '@iconify/vue'
import type { LessonState } from '../lesson'
/** 目录里每课前面的圆:学完打勾、在学橙圈、当前绿圈、没开始灰圈 */
defineProps<{ step: number; state: LessonState; current?: boolean }>()
+8 -25
View File
@@ -1,8 +1,8 @@
<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"
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 课学完了」→
@@ -45,10 +45,7 @@ const nextParts = computed(() => (props.next ? splitTitle(props.next.title) : nu
:gap="compact ? 2 : 3"
:group-gap="compact ? 5 : 8"
/>
<span class="muted num"
>{{ compact ? "" : `${courseLabel} 学完 ` }}{{ doneCount }} / {{ total
}}{{ compact ? "" : " 课" }}</span
>
<span class="muted num">{{ compact ? '' : `${courseLabel} 学完 ` }}{{ doneCount }} / {{ total }}{{ compact ? '' : ' 课' }}</span>
</div>
</div>
</section>
@@ -66,12 +63,7 @@ const nextParts = computed(() => (props.next ? splitTitle(props.next.title) : nu
<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"
>
<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>
@@ -81,9 +73,7 @@ const nextParts = computed(() => (props.next ? splitTitle(props.next.title) : nu
</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-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>
@@ -96,14 +86,7 @@ const nextParts = computed(() => (props.next ? splitTitle(props.next.title) : nu
<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)"
>
<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>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { OutlineItem } from "../composables/useLessonParse"
import { Icon } from '@iconify/vue'
import type { OutlineItem } from '../composables/useLessonParse'
/**
* 右边「这一课」:各节标题,有练一练的节后面挂一支笔(做对了再打个勾),
@@ -14,10 +14,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{ jump: [id: string] }>()
const solvedCount = computed(
() =>
props.items.flatMap((item) => item.exerciseIds).filter((id) => props.solvedIds.has(id)).length,
)
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))
@@ -27,14 +24,7 @@ function allSolved(item: OutlineItem) {
<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)"
>
<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) }">
+5 -13
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { TutorialProgress } from "@oj2/contract"
import { lessonState, splitTitle } from "../lesson"
import LessonDot from "./LessonDot.vue"
import type { TutorialProgress } from '@oj2/contract'
import { lessonState, splitTitle } from '../lesson'
import LessonDot from './LessonDot.vue'
/** 一课一行:圆点 + 知识点(粗)+ 情境(小字),在学的课右边带练一练做到几道 */
const props = defineProps<{
@@ -20,21 +20,13 @@ const state = computed(() => lessonState(props.progress))
</script>
<template>
<router-link
:to="to"
class="row"
:class="{ active }"
:style="{ height: `${height ?? 46}px` }"
@click="emit('go')"
>
<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>
<span v-if="state === 'started' && progress?.exerciseTotal" class="count"> {{ progress.exerciseSolved }}/{{ progress.exerciseTotal }} </span>
</router-link>
</template>
@@ -1,18 +1,13 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import type { ProjectGroup } from "../lesson"
import { Icon } from '@iconify/vue'
import type { ProjectGroup } from '../lesson'
/** 项目的小方块图标:智能家居一个房子、智慧农业一片叶子…… */
defineProps<{ icon: string; tone: ProjectGroup["tone"]; size?: number }>()
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"
>
<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>
@@ -1,9 +1,9 @@
<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"
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 的左栏、手机的目录抽屉)。
@@ -43,7 +43,7 @@ function toggle(group: ProjectGroup) {
}
function doneIn(group: ProjectGroup) {
return group.lessons.filter((lesson) => lessonState(props.progress[lesson.id]) === "done").length
return group.lessons.filter((lesson) => lessonState(props.progress[lesson.id]) === 'done').length
}
</script>
@@ -53,10 +53,7 @@ function doneIn(group: ProjectGroup) {
<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"
/>
<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>
@@ -70,10 +67,7 @@ function doneIn(group: ProjectGroup) {
</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"
/>
<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">
+2 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { LessonState, ProjectGroup } from "../lesson"
import type { LessonState, ProjectGroup } from '../lesson'
/**
* 一门课的进度条:一课一小段,按项目分组隔开。绿 = 学完,橙 = 在学,灰 = 没开始,
@@ -19,12 +19,7 @@ defineProps<{
<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` }"
>
<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"
@@ -1,4 +1,4 @@
import { reportLearnProgress } from "oj/api"
import { reportLearnProgress } from 'oj/api'
/** 计时心跳。攒够 FLUSH_SECONDS 才上报一次,别让每个学生每秒钟打一次接口 */
const TICK_MS = 15_000
@@ -42,7 +42,7 @@ export function useLearnTrace(tutorialId: Ref<number>, enabled: Ref<boolean>) {
const timer = window.setInterval(() => {
if (!enabled.value || tutorialId.value <= 0) return
if (visibility.value !== "visible" || idle.value) return
if (visibility.value !== 'visible' || idle.value) return
pendingId = tutorialId.value
pending += TICK_MS / 1000
if (pending >= FLUSH_SECONDS) flush()
@@ -59,7 +59,7 @@ export function useLearnTrace(tutorialId: Ref<number>, enabled: Ref<boolean>) {
})
watch(visibility, (value) => {
if (value === "hidden") flush()
if (value === 'hidden') flush()
})
onBeforeUnmount(() => {
@@ -1,4 +1,4 @@
import type { Exercise } from "utils/types"
import type { Exercise } from 'utils/types'
/**
* 把一课的 Markdown 正文拆成几段,各段交给不同的组件渲染:
@@ -15,11 +15,11 @@ import type { Exercise } from "utils/types"
* 已经有了,每课都重复一张同样的项目图只是把正文往下推。
*/
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; wrapMain: boolean }
| { type: "exercise"; exercise: Exercise; index: number }
| { 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; wrapMain: boolean }
| { type: 'exercise'; exercise: Exercise; index: number }
/** 大纲里的一项:一个二级标题,挂着它这一节里的练一练 */
export interface OutlineItem {
@@ -28,17 +28,17 @@ export interface OutlineItem {
exerciseIds: number[]
}
const CODE_LANGS: Record<string, "python" | "c"> = {
python: "python",
py: "python",
python3: "python",
c: "c",
cpp: "c",
"c++": "c",
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 = "学习目标"
const GOALS_TITLE = '学习目标'
/** 去掉开头的标题、所属项目、项目配图(中间的空行一起) */
function stripPreamble(lines: string[]) {
@@ -47,12 +47,12 @@ function stripPreamble(lines: string[]) {
while (i < lines.length && !lines[i]!.trim()) i++
}
skipBlank()
if (lines[i]?.startsWith("# ")) i++
if (lines[i]?.startsWith('# ')) i++
skipBlank()
if (/^\*\*所属项目[::]/.test(lines[i]?.trim() ?? "")) {
if (/^\*\*所属项目[::]/.test(lines[i]?.trim() ?? '')) {
i++
skipBlank()
if (/^!\[[^\]]*\]\([^)]*\)\s*$/.test(lines[i]?.trim() ?? "")) i++
if (/^!\[[^\]]*\]\([^)]*\)\s*$/.test(lines[i]?.trim() ?? '')) i++
}
return lines.slice(i)
}
@@ -67,12 +67,12 @@ export function parseLesson(content: string, exercises: Exercise[]) {
let headingIndex = 0
const flush = () => {
const text = md.join("\n")
if (text.trim()) segments.push({ type: "md", content: text })
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"))
const lines = stripPreamble(content.replace(/\r\n/g, '\n').split('\n'))
let i = 0
while (i < lines.length) {
const line = lines[i]!
@@ -86,11 +86,11 @@ export function parseLesson(content: string, exercises: Exercise[]) {
if (lang) {
flush()
segments.push({
type: "code",
type: 'code',
lang,
code: body.join("\n"),
code: body.join('\n'),
index: codeIndex++,
wrapMain: lang === "c" && !!fence[4],
wrapMain: lang === 'c' && !!fence[4],
})
} else {
md.push(line, ...body, lines[j] ?? marker)
@@ -113,12 +113,12 @@ export function parseLesson(content: string, exercises: Exercise[]) {
if (item) items.push(item[1]!.trim())
j++
}
segments.push({ type: "goals", id, items })
outline.push({ id, text: "学完这课你能", exerciseIds: [] })
segments.push({ type: 'goals', id, items })
outline.push({ id, text: '学完这课你能', exerciseIds: [] })
i = j
continue
}
segments.push({ type: "heading", id, text })
segments.push({ type: 'heading', id, text })
outline.push({ id, text, exerciseIds: [] })
i++
continue
@@ -139,7 +139,7 @@ export function parseLesson(content: string, exercises: Exercise[]) {
const exercise = byId.get(Number(match[1]))
if (!exercise) continue
flush()
segments.push({ type: "exercise", exercise, index: exerciseIndex++ })
segments.push({ type: 'exercise', exercise, index: exerciseIndex++ })
outline[outline.length - 1]?.exerciseIds.push(exercise.id)
}
md.push(line.slice(last))
@@ -153,12 +153,10 @@ export function parseLesson(content: string, exercises: Exercise[]) {
flush()
// 正文里没放占位符的练一练接在最后:学完一课要全做对,看不见的题会让这课永远学不完
const placed = new Set(
segments.flatMap((item) => (item.type === "exercise" ? [item.exercise.id] : [])),
)
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++ })
segments.push({ type: 'exercise', exercise, index: exerciseIndex++ })
outline[outline.length - 1]?.exerciseIds.push(exercise.id)
}
return { segments, outline, exerciseCount: exerciseIndex }
+31 -106
View File
@@ -1,27 +1,17 @@
<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 AdminLink from "shared/components/AdminLink.vue"
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"
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 AdminLink from 'shared/components/AdminLink.vue'
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(设计稿「自学首页」):最上面「接着学」,下面按项目分组的课程地图。
@@ -44,16 +34,13 @@ interface Course {
const courses = ref<Course[]>([])
const loaded = ref(false)
const tab = ref<TutorialType>(route.query.course === "c" ? "c" : "python")
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([]),
])
const [titles, rows] = await Promise.all([getTutorials(type), authed ? getLearnProgress(type).catch(() => []) : Promise.resolve([])])
return {
type,
label,
@@ -68,17 +55,12 @@ async function load() {
}
function latestViewed(course: Course) {
return Object.values(course.progress).reduce<string>(
(acc, row) => (row.lastViewedAt && row.lastViewedAt > acc ? row.lastViewedAt : acc),
"",
)
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 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)
@@ -87,11 +69,7 @@ const resume = computed(() => {
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 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 ?? []))
@@ -113,7 +91,7 @@ function pickTab(type: TutorialType) {
}
function doneIn(course: Course, ids: number[]) {
return ids.filter((id) => lessonState(course.progress[id]) === "done").length
return ids.filter((id) => lessonState(course.progress[id]) === 'done').length
}
watch(() => userStore.isAuthed, load)
@@ -148,34 +126,17 @@ onMounted(load)
: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>
<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))"
>
<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"
>
<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>
@@ -183,11 +144,7 @@ onMounted(load)
{{ course.titles.length }} 课
<template v-if="resume">
·
{{
courseStep(course)
? `学完 ${doneCount(course.titles, course.progress)} 课`
: "还没开始"
}}</template
{{ courseStep(course) ? `学完 ${doneCount(course.titles, course.progress)} 课` : '还没开始' }}</template
>
<template v-else> · {{ groupByProject(course.titles).length }} 个项目</template>
</span>
@@ -197,26 +154,13 @@ onMounted(load)
<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 课开始" }}
<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))"
>
<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>
@@ -244,23 +188,10 @@ onMounted(load)
</span>
</button>
<span class="grow" />
<button
v-if="!userStore.isAuthed"
type="button"
class="login"
@click="authModal.openLoginModal()"
>
登录后记下学到哪
</button>
<button v-if="!userStore.isAuthed" type="button" class="login" @click="authModal.openLoginModal()">登录后记下学到哪</button>
<div v-if="isDesktop && userStore.isTeacherOrAbove" class="admin-links">
<AdminLink to="/admin/learn" size="small">自学情况</AdminLink>
<AdminLink
v-if="userStore.isSuperAdmin"
:to="{ path: '/admin/tutorial/list', query: { type: tab } }"
size="small"
>
管理教程
</AdminLink>
<AdminLink v-if="userStore.isSuperAdmin" :to="{ path: '/admin/tutorial/list', query: { type: tab } }" size="small"> 管理教程 </AdminLink>
</div>
</div>
@@ -282,9 +213,7 @@ onMounted(load)
"
class="project-count all"
>
<Icon icon="ph:check-bold" :width="13" />{{ group.lessons.length }}/{{
group.lessons.length
}}
<Icon icon="ph:check-bold" :width="13" />{{ group.lessons.length }}/{{ group.lessons.length }}
</span>
<span
v-else-if="
@@ -325,11 +254,7 @@ onMounted(load)
: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"
/>
<n-empty v-if="!courses.some((c) => c.titles.length)" description="还没有公开的教程" style="margin-top: 80px" />
</template>
<n-spin v-else class="spin" />
</div>
+45 -112
View File
@@ -1,30 +1,22 @@
<script setup lang="ts">
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 { useBackClose } from "shared/composables/backClose"
import { useBreakpoints } from "shared/composables/breakpoints"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user"
import {
COURSES,
doneCount,
groupByProject,
lessonPath,
lessonState,
splitTitle,
type TutorialType,
} from "./lesson"
import LessonBody from "./components/LessonBody.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"
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 { useBackClose } from 'shared/composables/backClose'
import { useBreakpoints } from 'shared/composables/breakpoints'
import AdminLink from 'shared/components/AdminLink.vue'
import { useUserStore } from 'shared/store/user'
import { COURSES, doneCount, groupByProject, lessonPath, lessonState, splitTitle, type TutorialType } from './lesson'
import LessonBody from './components/LessonBody.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(选定):左边目录按项目折叠、中间正文、
@@ -39,10 +31,10 @@ const theme = useThemeVars()
// 未登录也能看教程,只是不留痕、不记进度
const traced = computed(() => userStore.isAuthed)
const type = computed<TutorialType>(() => (route.params.type === "c" ? "c" : "python"))
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 = parseInt((route.params.step as string | undefined) ?? "1")
const value = parseInt((route.params.step as string | undefined) ?? '1')
return Number.isFinite(value) && value > 0 ? value : 1
})
@@ -58,18 +50,12 @@ const menuOpen = ref(false)
useBackClose(menuOpen, () => !isDesktop.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 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)) {
if (seg.type === 'exercise' && !solvedIds.value.has(seg.exercise.id)) {
return { id: seg.exercise.id, index: seg.index + 1 }
}
}
@@ -129,17 +115,13 @@ async function init() {
return
}
const id = list[step.value - 1]!.id
const [lesson, exs] = await Promise.allSettled([
getTutorial(id),
getExercises(id),
loadProgress(),
])
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 : []
tutorial.value = lesson.status === 'fulfilled' ? lesson.value : null
exercises.value = exs.status === 'fulfilled' ? exs.value : []
solvedIds.value = new Set(exercises.value.filter((item) => item.solved).map((item) => item.id))
loading.value = false
activeId.value = ""
activeId.value = ''
await nextTick()
reader.value?.scrollTo({ top: 0 })
// 手机上滚的是外层 n-layout 的容器,不是 window —— 原来 window.scrollTo 什么都没滚,
@@ -152,7 +134,7 @@ async function init() {
watch(
() => [route.params.type, route.params.step],
() => {
if (route.name === "learn") init()
if (route.name === 'learn') init()
},
{ immediate: true },
)
@@ -163,10 +145,7 @@ watch(traced, async () => {
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),
])
solvedIds.value = new Set([...solvedIds.value, ...exs.filter((item) => item.solved).map((item) => item.id)])
})
/** 刚做对一道:本课的进度就地加上,目录、进度条、课末跟着变,不用等刷新 */
@@ -198,7 +177,7 @@ const atEnd = ref(false)
function scroller(): HTMLElement | null {
for (let el = mobileRoot.value?.parentElement; el; el = el.parentElement) {
const overflow = getComputedStyle(el).overflowY
if ((overflow === "auto" || overflow === "scroll") && el.scrollHeight > el.clientHeight) {
if ((overflow === 'auto' || overflow === 'scroll') && el.scrollHeight > el.clientHeight) {
return el
}
}
@@ -220,7 +199,7 @@ function onMobileScroll(event: Event) {
// 滚动事件不冒泡,在 document 上用捕获拿,不用先找到容器再挂
useEventListener(
document,
"scroll",
'scroll',
(event: Event) => {
const box = event.target
if (isDesktop.value || !(box instanceof HTMLElement)) return
@@ -233,13 +212,13 @@ useEventListener(
// ------------------------------------------------------------------ 读到哪一节
const reader = ref<HTMLElement | null>(null)
const activeId = ref("")
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 ?? ""
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
@@ -248,7 +227,7 @@ function onScroll() {
}
function jump(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" })
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function jumpExercise(id: number) {
@@ -263,23 +242,13 @@ function jumpExercise(id: number) {
<!-- 桌面:目录 | 正文 | 这一课 -->
<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>
<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"
/>
<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>
@@ -296,12 +265,7 @@ function jumpExercise(id: number) {
<span class="num">第 {{ step }} / {{ titles.length }} 课</span>
<template v-if="userStore.isSuperAdmin">
<span class="grow" />
<AdminLink
:to="{ name: 'admin tutorial edit', params: { tutorialID: tutorial.id } }"
size="tiny"
>
编辑这一课
</AdminLink>
<AdminLink :to="{ name: 'admin tutorial edit', params: { tutorialID: tutorial.id } }" size="tiny"> 编辑这一课 </AdminLink>
</template>
</div>
<h1 class="h1">{{ parts.kp }}</h1>
@@ -338,14 +302,7 @@ function jumpExercise(id: number) {
上一课<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)"
>
<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>
@@ -377,12 +334,7 @@ function jumpExercise(id: number) {
<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 v-for="n in parsed.exerciseCount" :key="n" class="mbox" :class="{ on: n <= solvedCount }" />
</span>
</span>
</div>
@@ -428,26 +380,14 @@ function jumpExercise(id: number) {
目录
</n-button>
<span class="grow" />
<n-button
v-if="next"
size="large"
type="primary"
icon-placement="right"
@click="router.push(next.to)"
>
<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"
:auto-focus="false"
>
<n-drawer v-model:show="menuOpen" placement="bottom" height="85%" class="sheet" :auto-focus="false">
<n-drawer-content :native-scrollbar="false" closable>
<template #header>
<span class="sheet-head">
@@ -456,14 +396,7 @@ function jumpExercise(id: number) {
<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"
/>
<ProjectTree :groups="groups" :progress="progress" :type="type" :current-step="step" big @go="menuOpen = false" />
</n-drawer-content>
</n-drawer>
</div>
@@ -614,7 +547,7 @@ function jumpExercise(id: number) {
padding: 0 12px;
box-sizing: border-box;
border-bottom: 1px solid var(--learn-line);
background: v-bind("theme.bodyColor");
background: v-bind('theme.bodyColor');
display: flex;
align-items: center;
gap: 8px;
@@ -655,7 +588,7 @@ function jumpExercise(id: number) {
border-top: 1px solid var(--learn-line);
display: flex;
align-items: center;
background: v-bind("theme.bodyColor");
background: v-bind('theme.bodyColor');
transition: transform 0.2s ease;
}
.mpager.hidden {
+24 -27
View File
@@ -1,10 +1,10 @@
import { isLessonDone, type TutorialProgress, type TutorialSummary } from "@oj2/contract"
import { isLessonDone, type TutorialProgress, type TutorialSummary } from '@oj2/contract'
export type TutorialType = "python" | "c"
export type TutorialType = 'python' | 'c'
export const COURSES: { type: TutorialType; label: string }[] = [
{ type: "python", label: "Python" },
{ type: "c", label: "C 语言" },
{ type: 'python', label: 'Python' },
{ type: 'c', label: 'C 语言' },
]
/**
@@ -12,8 +12,8 @@ export const COURSES: { type: TutorialType; label: string }[] = [
* 所以显示时知识点在前、情境在后。没有「——」的老教程整个当知识点。
*/
export function splitTitle(title: string) {
const at = title.indexOf("——")
if (at < 0) return { kp: title.trim(), scene: "" }
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() }
}
@@ -30,7 +30,7 @@ export interface ProjectGroup {
/** 「智慧农业·蜜橘园」 */
name: string
icon: string
tone: "blue" | "purple" | "green" | "orange" | "gray"
tone: 'blue' | 'purple' | 'green' | 'orange' | 'gray'
/** 这个项目里的课,`step` 是在整门课里的序号(1 起) */
lessons: { id: number; title: string; step: number }[]
}
@@ -38,17 +38,17 @@ export interface ProjectGroup {
/** 「项目三 · 智慧农业·蜜橘园」拆成「项目三」和「智慧农业·蜜橘园」 */
export function splitProject(project: string) {
const match = project.match(/^(项目[一二三四五六七八九十\d]+)\s*[·・.]\s*(.+)$/)
if (!match) return { no: "", name: project || "其他" }
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" }
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' }
}
/** 按项目分组,保持课程原来的顺序;同一个项目被别的项目隔开时各算一组 */
@@ -72,27 +72,24 @@ export function groupByProject(titles: TutorialSummary[]): ProjectGroup[] {
return groups
}
export type LessonState = "done" | "started" | "todo"
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"
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>,
) {
export function continueStep(titles: TutorialSummary[], progress: Record<number, TutorialProgress>) {
let latest: TutorialProgress | null = null
for (const item of titles) {
const row = progress[item.id]
@@ -101,17 +98,17 @@ export function continueStep(
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
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
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
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")}`
return `/learn/${type}/${step.toString().padStart(2, '0')}`
}
+9 -10
View File
@@ -1,5 +1,5 @@
import { EditorState, RangeSetBuilder, Transaction, type Extension } from "@codemirror/state"
import { Decoration, EditorView } from "@codemirror/view"
import { EditorState, RangeSetBuilder, Transaction, type Extension } from '@codemirror/state'
import { Decoration, EditorView } from '@codemirror/view'
/**
* C 教程里没写 main 的片段(```c main):运行时套上一层 main,套的这几行灰着、改不了。
@@ -7,12 +7,12 @@ import { Decoration, EditorView } from "@codemirror/view"
* 套的行就放在编辑器里,不是另外拼在提交的代码上 —— 这样报错的行号和学生看到的一样,
* 复制出去的也是一份完整能跑的程序。
*/
const HEAD = ["#include <stdio.h>", "", "int main(void) {"]
const FOOT = [" return 0;", "}"]
const HEAD = ['#include <stdio.h>', '', 'int main(void) {']
const FOOT = [' return 0;', '}']
export function wrapMain(code: string) {
const body = code.split("\n").map((line) => (line ? ` ${line}` : line))
return [...HEAD, ...body, ...FOOT].join("\n")
const body = code.split('\n').map((line) => (line ? ` ${line}` : line))
return [...HEAD, ...body, ...FOOT].join('\n')
}
/** 头尾两段的字符范围:头到片段第一行行首,尾从片段最后一行行尾起(换行符算在套的那边) */
@@ -31,14 +31,13 @@ const lock = EditorState.changeFilter.of((tr) => {
return [0, headEnd, footStart, tr.startState.doc.length]
})
const dim = Decoration.line({ class: "cm-main-wrap" })
const dim = Decoration.line({ class: 'cm-main-wrap' })
const dimLines = EditorView.decorations.compute(["doc"], (state) => {
const dimLines = EditorView.decorations.compute(['doc'], (state) => {
const builder = new RangeSetBuilder<Decoration>()
const doc = state.doc
for (let n = 1; n <= doc.lines; n++) {
if (n <= HEAD.length || n > doc.lines - FOOT.length)
builder.add(doc.line(n).from, doc.line(n).from, dim)
if (n <= HEAD.length || n > doc.lines - FOOT.length) builder.add(doc.line(n).from, doc.line(n).from, dim)
}
return builder.finish()
})