Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
530e1a3d5d
commit
195069450f
480 files changed
+15507
-24650
No files matched your search
@@ -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>
|
||||
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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 }>()
|
||||
|
||||
@@ -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) }">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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')}`
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user